diff --git a/Configuration/UITests/UITests.xcconfig b/Configuration/UITests/UITests.xcconfig index 5e19bffd99..7e3c83d07a 100644 --- a/Configuration/UITests/UITests.xcconfig +++ b/Configuration/UITests/UITests.xcconfig @@ -15,17 +15,22 @@ #include "../Common.xcconfig" -CODE_SIGN_STYLE = Automatic CODE_SIGN_IDENTITY[config=CI][sdk=macosx*] = - +CODE_SIGN_STYLE[config=CI][sdk=macosx*] = Manual +CODE_SIGNING_ALLOWED[config=CI][sdk=macosx*] = NO +COPY_PHASE_STRIP = NO DEAD_CODE_STRIPPING = YES +DEVELOPMENT_TEAM[config=CI][sdk=macosx*] = +ENABLE_USER_SCRIPT_SANDBOXING = YES +GCC_NO_COMMON_BLOCKS = YES +GENERATE_INFOPLIST_FILE = YES +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks @loader_path/../Frameworks +LOCALIZATION_PREFERS_STRING_CATALOGS = YES MACOSX_DEPLOYMENT_TARGET = 11.4 -INFOPLIST_FILE = UI Tests/Info.plist PRODUCT_BUNDLE_IDENTIFIER = com.duckduckgo.UI-Tests PRODUCT_NAME = $(TARGET_NAME) - -TEST_TARGET_NAME = DuckDuckGo - -LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks @loader_path/../Frameworks +PROVISIONING_PROFILE_SPECIFIER[config=CI][sdk=macosx*] = +SWIFT_EMIT_LOC_STRINGS = NO +TEST_TARGET_NAME = DuckDuckGo Privacy Browser diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 65a75719da..4b0b46f7e8 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2624,6 +2624,7 @@ B6080BC82B21E78100B418EF /* DataImportErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6080BC42B21E78100B418EF /* DataImportErrorView.swift */; }; B6085D062743905F00A9C456 /* CoreDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6085D052743905F00A9C456 /* CoreDataStore.swift */; }; B6085D092743AAB600A9C456 /* FireproofDomains.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = B6085D072743993C00A9C456 /* FireproofDomains.xcdatamodeld */; }; + B60C5F232B86189800FFA1D2 /* TestsURLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6EC37FB29B83E99001ACE79 /* TestsURLExtension.swift */; }; B60C6F7729B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60C6F7629B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift */; }; B60C6F7829B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60C6F7629B0E286007BFAA8 /* SearchNonexistentDomainNavigationResponder.swift */; }; B60C6F7E29B1B41D007BFAA8 /* TestRunHelperInitializer.m in Sources */ = {isa = PBXBuildFile; fileRef = B60C6F7D29B1B41D007BFAA8 /* TestRunHelperInitializer.m */; }; @@ -2683,7 +2684,6 @@ B630794226731F5400DCEE41 /* WKDownloadMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B630794126731F5400DCEE41 /* WKDownloadMock.swift */; }; B630E7FE29C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B630E7FD29C887ED00363609 /* NSErrorAdditionalInfo.swift */; }; B630E7FF29C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B630E7FD29C887ED00363609 /* NSErrorAdditionalInfo.swift */; }; - B630E80029C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B630E7FD29C887ED00363609 /* NSErrorAdditionalInfo.swift */; }; B630E80129C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B630E7FD29C887ED00363609 /* NSErrorAdditionalInfo.swift */; }; B630E80229C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B630E7FD29C887ED00363609 /* NSErrorAdditionalInfo.swift */; }; B634DBDF293C8F7F00C3C99E /* Tab+UIDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B634DBDE293C8F7F00C3C99E /* Tab+UIDelegate.swift */; }; @@ -2704,8 +2704,6 @@ B63ED0E026AFE32F00A9DAD1 /* GeolocationProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63ED0DF26AFE32F00A9DAD1 /* GeolocationProviderMock.swift */; }; B63ED0E326B3E7FA00A9DAD1 /* CLLocationManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63ED0E226B3E7FA00A9DAD1 /* CLLocationManagerMock.swift */; }; B63ED0E526BB8FB900A9DAD1 /* SharingMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63ED0E426BB8FB900A9DAD1 /* SharingMenu.swift */; }; - B63FCB3529B5B2680022C61A /* TestRunHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60C6F8029B1B4AD007BFAA8 /* TestRunHelper.swift */; }; - B63FCB3629B5B2730022C61A /* FileManagerTempDirReplacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60C6F8329B1BAD3007BFAA8 /* FileManagerTempDirReplacement.swift */; }; B642738227B65BAC0005DFD1 /* SecureVaultErrorReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B642738127B65BAC0005DFD1 /* SecureVaultErrorReporter.swift */; }; B643BF1427ABF772000BACEC /* NSWorkspaceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B643BF1327ABF772000BACEC /* NSWorkspaceExtension.swift */; }; B644B43D29D56829003FA9AB /* SearchNonexistentDomainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B644B43929D565DB003FA9AB /* SearchNonexistentDomainTests.swift */; }; @@ -2724,6 +2722,9 @@ B64C853826944B880048FEBE /* StoredPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = B64C853726944B880048FEBE /* StoredPermission.swift */; }; B64C853D26944B940048FEBE /* PermissionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B64C853C26944B940048FEBE /* PermissionStore.swift */; }; B64C85422694590B0048FEBE /* PermissionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B64C85412694590B0048FEBE /* PermissionButton.swift */; }; + B64CE01C2B861B4F00126CA5 /* BrowserServicesKit in Frameworks */ = {isa = PBXBuildFile; productRef = B64CE01B2B861B4F00126CA5 /* BrowserServicesKit */; }; + B64CE01E2B8622D700126CA5 /* AddressBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B64CE01D2B8622D700126CA5 /* AddressBarTests.swift */; }; + B64CE01F2B8622D700126CA5 /* AddressBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B64CE01D2B8622D700126CA5 /* AddressBarTests.swift */; }; B65211252B29A42C00B30633 /* BookmarkStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA652CDA25DDAB32009059CC /* BookmarkStoreMock.swift */; }; B65211262B29A42E00B30633 /* BookmarkStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA652CDA25DDAB32009059CC /* BookmarkStoreMock.swift */; }; B65211272B29A43000B30633 /* BookmarkStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA652CDA25DDAB32009059CC /* BookmarkStoreMock.swift */; }; @@ -2744,7 +2745,6 @@ B65CD8CD2B316DFC00A595BB /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = B65CD8CC2B316DFC00A595BB /* SnapshotTesting */; }; B65CD8CF2B316E0200A595BB /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = B65CD8CE2B316E0200A595BB /* SnapshotTesting */; }; B65CD8D12B316E0C00A595BB /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = B65CD8D02B316E0C00A595BB /* SnapshotTesting */; }; - B65CD8D32B316E1700A595BB /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = B65CD8D22B316E1700A595BB /* SnapshotTesting */; }; B65CD8D52B316FCA00A595BB /* __Snapshots__ in Resources */ = {isa = PBXBuildFile; fileRef = B65CD8D42B316FCA00A595BB /* __Snapshots__ */; }; B65CD8D62B316FCA00A595BB /* __Snapshots__ in Resources */ = {isa = PBXBuildFile; fileRef = B65CD8D42B316FCA00A595BB /* __Snapshots__ */; }; B65DA5EF2A77CC3A00CBEE8D /* Bundle+NetworkProtectionExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B602E81F2A1E2603006D261F /* Bundle+NetworkProtectionExtensions.swift */; }; @@ -3224,6 +3224,13 @@ remoteGlobalIDString = AA585D7D248FD31100E9A3E2; remoteInfo = DuckDuckGo; }; + B696E8182B8619D6008368F0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AA585D76248FD31100E9A3E2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B6EC37E729B5DA2A001ACE79; + remoteInfo = "tests-server"; + }; B6EC37F129B5DA8F001ACE79 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = AA585D76248FD31100E9A3E2 /* Project object */; @@ -3802,7 +3809,6 @@ 7B3618C12ADE75C8000D6154 /* NetworkProtectionNavBarPopoverManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionNavBarPopoverManager.swift; sourceTree = ""; }; 7B430EA02A71411A00BAC4A1 /* NetworkProtectionSimulateFailureMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionSimulateFailureMenu.swift; sourceTree = ""; }; 7B4CE8DA26F02108009134B1 /* UI Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 7B4CE8DE26F02108009134B1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 7B4CE8E626F02134009134B1 /* TabBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarTests.swift; sourceTree = ""; }; 7B5291882A1697680022E406 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 7B5291892A169BC90022E406 /* DeveloperID.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = DeveloperID.xcconfig; sourceTree = ""; }; @@ -4221,6 +4227,7 @@ B64C853726944B880048FEBE /* StoredPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredPermission.swift; sourceTree = ""; }; B64C853C26944B940048FEBE /* PermissionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionStore.swift; sourceTree = ""; }; B64C85412694590B0048FEBE /* PermissionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionButton.swift; sourceTree = ""; }; + B64CE01D2B8622D700126CA5 /* AddressBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressBarTests.swift; sourceTree = ""; }; B65349A9265CF45000DCC645 /* DispatchQueueExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchQueueExtensionsTests.swift; sourceTree = ""; }; B6553691268440D700085A79 /* WKProcessPool+GeolocationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKProcessPool+GeolocationProvider.swift"; sourceTree = ""; }; B65536962684413900085A79 /* WKGeolocationProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WKGeolocationProvider.h; sourceTree = ""; }; @@ -4643,7 +4650,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B65CD8D32B316E1700A595BB /* SnapshotTesting in Frameworks */, + B64CE01C2B861B4F00126CA5 /* BrowserServicesKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6148,7 +6155,6 @@ isa = PBXGroup; children = ( 7B4CE8E626F02134009134B1 /* TabBarTests.swift */, - 7B4CE8DE26F02108009134B1 /* Info.plist */, ); path = UITests; sourceTree = ""; @@ -7880,6 +7886,7 @@ children = ( B644B43929D565DB003FA9AB /* SearchNonexistentDomainTests.swift */, B693766D2B6B5F26005BD9D4 /* ErrorPageTests.swift */, + B64CE01D2B8622D700126CA5 /* AddressBarTests.swift */, ); path = Tab; sourceTree = ""; @@ -8650,12 +8657,11 @@ buildRules = ( ); dependencies = ( - B6080B9B2B20AF6400B418EF /* PBXTargetDependency */, - 7B4CE8E026F02108009134B1 /* PBXTargetDependency */, + B696E8192B8619D6008368F0 /* PBXTargetDependency */, ); name = "UI Tests"; packageProductDependencies = ( - B65CD8D22B316E1700A595BB /* SnapshotTesting */, + B64CE01B2B861B4F00126CA5 /* BrowserServicesKit */, ); productName = "UI Tests"; productReference = 7B4CE8DA26F02108009134B1 /* UI Tests.xctest */; @@ -10574,6 +10580,7 @@ 3706FEA5293F662100E42796 /* CoreDataEncryptionTesting.xcdatamodeld in Sources */, B603973D29BF1D7D00902A34 /* AutoconsentIntegrationTests.swift in Sources */, B60C6F8729B1CAB2007BFAA8 /* TestRunHelper.swift in Sources */, + B64CE01F2B8622D700126CA5 /* AddressBarTests.swift in Sources */, B603972D29BEDF2100902A34 /* ExpectedNavigationExtension.swift in Sources */, 3706FEA6293F662100E42796 /* EncryptionKeyStoreTests.swift in Sources */, B6F5656A299A414300A04298 /* WKWebViewMockingExtension.swift in Sources */, @@ -10615,6 +10622,7 @@ B644B43D29D56829003FA9AB /* SearchNonexistentDomainTests.swift in Sources */, B603973C29BF1D7D00902A34 /* AutoconsentIntegrationTests.swift in Sources */, B60C6F8629B1CAB0007BFAA8 /* TestRunHelper.swift in Sources */, + B64CE01E2B8622D700126CA5 /* AddressBarTests.swift in Sources */, B603972C29BEDF2100902A34 /* ExpectedNavigationExtension.swift in Sources */, 4B1AD8D525FC38DD00261379 /* EncryptionKeyStoreTests.swift in Sources */, B6F56568299A414300A04298 /* WKWebViewMockingExtension.swift in Sources */, @@ -11489,9 +11497,7 @@ buildActionMask = 2147483647; files = ( 7B4CE8E726F02135009134B1 /* TabBarTests.swift in Sources */, - B63FCB3529B5B2680022C61A /* TestRunHelper.swift in Sources */, - B630E80029C887ED00363609 /* NSErrorAdditionalInfo.swift in Sources */, - B63FCB3629B5B2730022C61A /* FileManagerTempDirReplacement.swift in Sources */, + B60C5F232B86189800FFA1D2 /* TestsURLExtension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -12596,10 +12602,6 @@ target = AA585D7D248FD31100E9A3E2 /* DuckDuckGo Privacy Browser */; targetProxy = AA585D91248FD31400E9A3E2 /* PBXContainerItemProxy */; }; - B6080B9B2B20AF6400B418EF /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = B6080B9A2B20AF6400B418EF /* SwiftLintPlugin */; - }; B6080B9D2B20AF7700B418EF /* PBXTargetDependency */ = { isa = PBXTargetDependency; productRef = B6080B9C2B20AF7700B418EF /* SwiftLintPlugin */; @@ -12632,6 +12634,11 @@ isa = PBXTargetDependency; productRef = B692D0DE2B209FB7003F2548 /* SwiftLintPlugin */; }; + B696E8192B8619D6008368F0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B6EC37E729B5DA2A001ACE79 /* tests-server */; + targetProxy = B696E8182B8619D6008368F0 /* PBXContainerItemProxy */; + }; B69D06142A4C0AC50032D14D /* PBXTargetDependency */ = { isa = PBXTargetDependency; productRef = B69D06132A4C0AC50032D14D /* SwiftLintPlugin */; @@ -14102,11 +14109,6 @@ package = AA06B6B52672AF8100F541C5 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; - B6080B9A2B20AF6400B418EF /* SwiftLintPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; - productName = "plugin:SwiftLintPlugin"; - }; B6080B9C2B20AF7700B418EF /* SwiftLintPlugin */ = { isa = XCSwiftPackageProductDependency; package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; @@ -14142,6 +14144,11 @@ package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = "plugin:SwiftLintPlugin"; }; + B64CE01B2B861B4F00126CA5 /* BrowserServicesKit */ = { + isa = XCSwiftPackageProductDependency; + package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; + productName = BrowserServicesKit; + }; B65CD8CA2B316DF100A595BB /* SnapshotTesting */ = { isa = XCSwiftPackageProductDependency; package = B65CD8C92B316DF100A595BB /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; @@ -14162,11 +14169,6 @@ package = B65CD8C92B316DF100A595BB /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; productName = SnapshotTesting; }; - B65CD8D22B316E1700A595BB /* SnapshotTesting */ = { - isa = XCSwiftPackageProductDependency; - package = B65CD8C92B316DF100A595BB /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */; - productName = SnapshotTesting; - }; B692D0DE2B209FB7003F2548 /* SwiftLintPlugin */ = { isa = XCSwiftPackageProductDependency; package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index 95f1317564..11658e65d3 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -240,7 +240,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, FileDownloadManagerDel startupSync() - stateRestorationManager.applicationDidFinishLaunching() + if [.normal, .uiTests].contains(NSApp.runType) { + stateRestorationManager.applicationDidFinishLaunching() + } BWManager.shared.initCommunication() diff --git a/DuckDuckGo/Common/Extensions/NSViewExtension.swift b/DuckDuckGo/Common/Extensions/NSViewExtension.swift index 0d8bbc4f59..b82095c1f0 100644 --- a/DuckDuckGo/Common/Extensions/NSViewExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSViewExtension.swift @@ -84,6 +84,8 @@ extension NSView { os_log("%s: Window not available", type: .error, className) return } + // prevent all text selection on repeated Address Bar activation + guard window.firstResponder !== (self as? NSControl)?.currentEditor() ?? self else { return } window.makeFirstResponder(self) } diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index eee5b3f4e5..1c18aaa7a8 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -236,6 +236,9 @@ struct UserText { static let addressBarVisitSuffix = NSLocalizedString("address.bar.visit.suffix", value: "Visit", comment: "Address bar suffix of possibly visited website. Example: spreadprivacy.com . Visit spreadprivacy.com") + static let addressBarPlaceholder = NSLocalizedString("address.bar.placeholder", + value: "Search or enter address", + comment: "Empty Address Bar placeholder text displayed on the new tab page.") static let navigateBack = NSLocalizedString("navigate.back", value: "Back", comment: "Context menu item") static let closeAndReturnToParentFormat = NSLocalizedString("close.tab.on.back.format", diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index ddf6e62331..347b987a69 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -235,6 +235,18 @@ "Address:" : { "comment" : "Add Bookmark dialog bookmark url field heading" }, + "address.bar.placeholder" : { + "comment" : "Empty Address Bar placeholder text displayed on the new tab page.", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search or enter address" + } + } + } + }, "address.bar.search.suffix" : { "comment" : "Suffix of searched terms in address bar. Example: best watching machine . Search DuckDuckGo", "extractionState" : "extracted_with_value", diff --git a/DuckDuckGo/MainWindow/MainView.swift b/DuckDuckGo/MainWindow/MainView.swift index 23a92c2e0a..6ef7452e5d 100644 --- a/DuckDuckGo/MainWindow/MainView.swift +++ b/DuckDuckGo/MainWindow/MainView.swift @@ -61,7 +61,6 @@ final class MainView: NSView { bookmarksBarHeightConstraint = bookmarksBarContainerView.heightAnchor.constraint(equalToConstant: 34) navigationBarTopConstraint = navigationBarContainerView.topAnchor.constraint(equalTo: topAnchor, constant: 38) - addressBarHeightConstraint = navigationBarContainerView.heightAnchor.constraint(equalToConstant: 42) NSLayoutConstraint.activate([ tabBarContainerView.topAnchor.constraint(equalTo: topAnchor), @@ -82,7 +81,6 @@ final class MainView: NSView { navigationBarTopConstraint, navigationBarContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), navigationBarContainerView.trailingAnchor.constraint(equalTo: trailingAnchor), - addressBarHeightConstraint, webContainerView.topAnchor.constraint(equalTo: bookmarksBarContainerView.bottomAnchor), webContainerView.bottomAnchor.constraint(equalTo: bottomAnchor), diff --git a/DuckDuckGo/MainWindow/MainViewController.swift b/DuckDuckGo/MainWindow/MainViewController.swift index 843a190735..94f4ac7953 100644 --- a/DuckDuckGo/MainWindow/MainViewController.swift +++ b/DuckDuckGo/MainWindow/MainViewController.swift @@ -40,9 +40,8 @@ final class MainViewController: NSViewController { private var addressBarBookmarkIconVisibilityCancellable: AnyCancellable? private var selectedTabViewModelCancellable: AnyCancellable? + private var tabViewModelCancellables = Set() private var bookmarksBarVisibilityChangedCancellable: AnyCancellable? - private var navigationalCancellables = Set() - private var windowTitleCancellable: AnyCancellable? private var eventMonitorCancellables = Set() private var bookmarksBarIsVisible: Bool { @@ -93,6 +92,7 @@ final class MainViewController: NSViewController { subscribeToMouseTrackingArea() subscribeToSelectedTabViewModel() subscribeToAppSettingsNotifications() + subscribeToFirstResponder() mainView.findInPageContainerView.applyDropShadow() view.registerForDraggedTypes([.URL, .fileURL]) @@ -102,6 +102,7 @@ final class MainViewController: NSViewController { super.viewDidAppear() mainView.setMouseAboveWebViewTrackingAreaEnabled(true) registerForBookmarkBarPromptNotifications() + adjustFirstResponder() } var bookmarkBarPromptObserver: Any? @@ -140,7 +141,7 @@ final class MainViewController: NSViewController { updateBookmarksBarViewVisibility(visible: bookmarksBarVisible) } - updateDividerColor() + updateDividerColor(isShowingHomePage: tabCollectionViewModel.selectedTabViewModel?.tab.content == .newtab) } override func viewDidLayout() { @@ -241,12 +242,11 @@ final class MainViewController: NSViewController { mainView.layoutSubtreeIfNeeded() mainView.updateTrackingAreas() - updateDividerColor() + updateDividerColor(isShowingHomePage: tabCollectionViewModel.selectedTabViewModel?.tab.content == .newtab) } - private func updateDividerColor() { + private func updateDividerColor(isShowingHomePage isHomePage: Bool) { NSAppearance.withAppAppearance { - let isHomePage = tabCollectionViewModel.selectedTabViewModel?.tab.content == .newtab let backgroundColor: NSColor = (bookmarksBarIsVisible || isHomePage) ? .bookmarkBarBackground : .addressBarSolidSeparatorColor mainView.divider.backgroundColor = backgroundColor } @@ -261,28 +261,26 @@ final class MainViewController: NSViewController { } private func subscribeToSelectedTabViewModel() { - selectedTabViewModelCancellable = tabCollectionViewModel.$selectedTabViewModel.receive(on: DispatchQueue.main).sink { [weak self] _ in - self?.navigationalCancellables = [] - self?.subscribeToCanGoBackForward() - self?.subscribeToFindInPage() - self?.subscribeToTabContent() - self?.adjustFirstResponder() - self?.subscribeToTitleChange() + selectedTabViewModelCancellable = tabCollectionViewModel.$selectedTabViewModel.sink { [weak self] tabViewModel in + guard let self, let tabViewModel else { return } + + tabViewModelCancellables.removeAll(keepingCapacity: true) + subscribeToCanGoBackForward(of: tabViewModel) + subscribeToFindInPage(of: tabViewModel) + subscribeToTitleChange(of: tabViewModel) + subscribeToTabContent(of: tabViewModel) } } - private func subscribeToTitleChange() { + private func subscribeToTitleChange(of selectedTabViewModel: TabViewModel?) { guard let window = self.view.window else { return } - windowTitleCancellable = tabCollectionViewModel.$selectedTabViewModel - .compactMap { tabViewModel in - tabViewModel?.$title - } - .switchToLatest() + selectedTabViewModel?.$title .map { $0.truncated(length: MainMenu.Constants.maxTitleLength) } .receive(on: DispatchQueue.main) .assign(to: \.title, onWeaklyHeld: window) + .store(in: &tabViewModelCancellables) } private func subscribeToAppSettingsNotifications() { @@ -294,28 +292,36 @@ final class MainViewController: NSViewController { } private func resizeNavigationBarForHomePage(_ homePage: Bool, animated: Bool) { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.1 - - let nonHomePageHeight: CGFloat = isInPopUpWindow ? 42 : 48 + updateDividerColor(isShowingHomePage: homePage) + navigationBarViewController.resizeAddressBar(for: homePage ? .homePage : (isInPopUpWindow ? .popUpWindow : .default), animated: animated) + } - let height = animated ? mainView.addressBarHeightConstraint.animator() : mainView.addressBarHeightConstraint - height?.constant = homePage ? 52 : nonHomePageHeight + private var lastTabContent = Tab.TabContent.none + private func subscribeToTabContent(of selectedTabViewModel: TabViewModel?) { + selectedTabViewModel?.tab.$content + .sink { [weak self, weak selectedTabViewModel] content in + guard let self, let selectedTabViewModel else { return } + defer { lastTabContent = content } - updateDividerColor() - navigationBarViewController.resizeAddressBarForHomePage(homePage, animated: animated) - } + resizeNavigationBarForHomePage(content == .newtab, animated: content == .newtab && lastTabContent != .newtab) + updateBookmarksBar(content) + adjustFirstResponder(selectedTabViewModel: selectedTabViewModel, tabContent: content) + } + .store(in: &self.tabViewModelCancellables) } - var lastTabContent: Tab.TabContent? - private func subscribeToTabContent() { - tabCollectionViewModel.selectedTabViewModel?.tab.$content.receive(on: DispatchQueue.main).sink(receiveValue: { [weak self] content in - guard let self = self else { return } - self.resizeNavigationBarForHomePage(content == .newtab, animated: content == .newtab && self.lastTabContent != .newtab) - self.updateBookmarksBar(content) - self.lastTabContent = content - self.adjustFirstResponderOnContentChange(content: content) - }).store(in: &self.navigationalCancellables) + private func subscribeToFirstResponder() { + NotificationCenter.default.addObserver(self, + selector: #selector(firstReponderDidChange(_:)), + name: .firstResponder, + object: nil) + + } + @objc private func firstReponderDidChange(_ notification: Notification) { + // when window first responder is reset (to the window): activate Tab Content View + if view.window?.firstResponder === view.window { + browserTabViewController.adjustFirstResponder() + } } private func updateBookmarksBar(_ content: Tab.TabContent, _ prefs: AppearancePreferences = AppearancePreferences.shared) { @@ -326,29 +332,29 @@ final class MainViewController: NSViewController { } } - private func subscribeToFindInPage() { - tabCollectionViewModel.selectedTabViewModel?.findInPage? + private func subscribeToFindInPage(of selectedTabViewModel: TabViewModel?) { + selectedTabViewModel?.findInPage? .$isVisible .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.updateFindInPage() } - .store(in: &self.navigationalCancellables) + .store(in: &self.tabViewModelCancellables) } - private func subscribeToCanGoBackForward() { - tabCollectionViewModel.selectedTabViewModel?.$canGoBack.receive(on: DispatchQueue.main).sink { [weak self] _ in + private func subscribeToCanGoBackForward(of selectedTabViewModel: TabViewModel) { + selectedTabViewModel.$canGoBack.receive(on: DispatchQueue.main).sink { [weak self] _ in self?.updateBackMenuItem() - }.store(in: &self.navigationalCancellables) - tabCollectionViewModel.selectedTabViewModel?.$canGoForward.receive(on: DispatchQueue.main).sink { [weak self] _ in + }.store(in: &self.tabViewModelCancellables) + selectedTabViewModel.$canGoForward.receive(on: DispatchQueue.main).sink { [weak self] _ in self?.updateForwardMenuItem() - }.store(in: &self.navigationalCancellables) - tabCollectionViewModel.selectedTabViewModel?.$canReload.receive(on: DispatchQueue.main).sink { [weak self] _ in + }.store(in: &self.tabViewModelCancellables) + selectedTabViewModel.$canReload.receive(on: DispatchQueue.main).sink { [weak self] _ in self?.updateReloadMenuItem() - }.store(in: &self.navigationalCancellables) - tabCollectionViewModel.selectedTabViewModel?.$isLoading.receive(on: DispatchQueue.main).sink { [weak self] _ in + }.store(in: &self.tabViewModelCancellables) + selectedTabViewModel.$isLoading.receive(on: DispatchQueue.main).sink { [weak self] _ in self?.updateStopMenuItem() - }.store(in: &self.navigationalCancellables) + }.store(in: &self.tabViewModelCancellables) } private func updateFindInPage() { @@ -411,39 +417,22 @@ final class MainViewController: NSViewController { // MARK: - First responder - func adjustFirstResponder() { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - os_log("MainViewController: No tab view model selected", type: .error) + func adjustFirstResponder(selectedTabViewModel: TabViewModel? = nil, tabContent: Tab.TabContent? = nil) { + guard let selectedTabViewModel = selectedTabViewModel ?? tabCollectionViewModel.selectedTabViewModel else { + assertionFailure("No tab view model selected") return } + let tabContent = tabContent ?? selectedTabViewModel.tab.content - switch selectedTabViewModel.tab.content { - case .newtab: + if case .newtab = tabContent { navigationBarViewController.addressBarViewController?.addressBarTextField.makeMeFirstResponder() - case .onboarding: - self.view.makeMeFirstResponder() - case .url, .subscription: - browserTabViewController.makeWebViewFirstResponder() - case .settings: - browserTabViewController.preferencesViewController?.view.makeMeFirstResponder() - case .bookmarks: - browserTabViewController.bookmarksViewController?.view.makeMeFirstResponder() - case .none: - shouldAdjustFirstResponderOnContentChange = true - case .dataBrokerProtection: - browserTabViewController.preferencesViewController?.view.makeMeFirstResponder() - } - } - - var shouldAdjustFirstResponderOnContentChange = false - func adjustFirstResponderOnContentChange(content: Tab.TabContent) { - guard shouldAdjustFirstResponderOnContentChange, content != .none else { - return + } else { + // ignore published tab switch: BrowserTabViewController + // adjusts first responder itself + guard selectedTabViewModel === tabCollectionViewModel.selectedTabViewModel else { return } + browserTabViewController.adjustFirstResponder(tabContent: tabContent) } - - shouldAdjustFirstResponderOnContentChange = false - adjustFirstResponder() } } diff --git a/DuckDuckGo/MainWindow/MainWindowController.swift b/DuckDuckGo/MainWindow/MainWindowController.swift index 06b35517fe..fd42a89970 100644 --- a/DuckDuckGo/MainWindow/MainWindowController.swift +++ b/DuckDuckGo/MainWindow/MainWindowController.swift @@ -314,10 +314,7 @@ fileprivate extension MainMenu { fileprivate extension NavigationBarViewController { var controlsForUserPrevention: [NSControl?] { - return [goBackButton, - goForwardButton, - refreshOrStopButton, - optionsButton, + return [optionsButton, bookmarkListButton, passwordManagementButton, addressBarViewController?.addressBarTextField, diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 691fef0c0f..d1e50ed4a9 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -107,7 +107,7 @@ extension AppDelegate { // MARK: - Window @objc func reopenAllWindowsFromLastSession(_ sender: Any?) { - stateRestorationManager.restoreLastSessionState(interactive: true) + _=stateRestorationManager.restoreLastSessionState(interactive: true) } // MARK: - Help diff --git a/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift b/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift index b5846ee9ce..8b565aec74 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift @@ -144,6 +144,7 @@ final class AddressBarButtonsViewController: NSViewController { @Published private(set) var buttonsWidth: CGFloat = 0 private var tabCollectionViewModel: TabCollectionViewModel + private var tabViewModel: TabViewModel? private var bookmarkManager: BookmarkManager = LocalBookmarkManager.shared var controllerMode: AddressBarViewController.Mode? { @@ -171,10 +172,8 @@ final class AddressBarButtonsViewController: NSViewController { private var selectedTabViewModelCancellable: AnyCancellable? private var urlCancellable: AnyCancellable? - private var trackerInfoCancellable: AnyCancellable? private var bookmarkListCancellable: AnyCancellable? private var privacyDashboadPendingUpdatesCancellable: AnyCancellable? - private var trackersAnimationViewStatusCancellable: AnyCancellable? private var effectiveAppearanceCancellable: AnyCancellable? private var permissionsCancellables = Set() private var trackerAnimationTriggerCancellable: AnyCancellable? @@ -220,7 +219,7 @@ final class AddressBarButtonsViewController: NSViewController { buttonsContainer: buttonsContainer, and: notificationAnimationView) } else { - buttonsBadgeAnimator.queuedAnimation = NavigationBarBadgeAnimator.QueueData(selectedTab: tabCollectionViewModel.selectedTab, + buttonsBadgeAnimator.queuedAnimation = NavigationBarBadgeAnimator.QueueData(selectedTab: tabViewModel?.tab, animationType: type) } } @@ -229,7 +228,7 @@ final class AddressBarButtonsViewController: NSViewController { if let queuedNotification = buttonsBadgeAnimator.queuedAnimation { // Add small time gap in between animations if badge animation was queued DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - if self.tabCollectionViewModel.selectedTab == queuedNotification.selectedTab { + if self.tabViewModel?.tab == queuedNotification.selectedTab { self.showBadgeNotification(queuedNotification.animationType) } else { self.buttonsBadgeAnimator.queuedAnimation = nil @@ -283,11 +282,10 @@ final class AddressBarButtonsViewController: NSViewController { bookmarkButton.setAccessibilityIdentifier("Bookmarks Button") let hasEmptyAddressBar = textFieldValue?.isEmpty ?? true var showBookmarkButton: Bool { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel, - selectedTabViewModel.canBeBookmarked else { return false } + guard let tabViewModel, tabViewModel.canBeBookmarked else { return false } var isUrlBookmarked = false - if let url = selectedTabViewModel.tab.content.url, + if let url = tabViewModel.tab.content.url, bookmarkManager.isUrlBookmarked(url: url) { isUrlBookmarked = true } @@ -371,12 +369,12 @@ final class AddressBarButtonsViewController: NSViewController { } func openPrivacyDashboard() { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel, - let privacyDashboardViewController = privacyDashboardPopover.viewController else { + guard let tabViewModel, + let privacyDashboardViewController = privacyDashboardPopover.viewController else { return } - privacyDashboardViewController.updateTabViewModel(selectedTabViewModel) + privacyDashboardViewController.updateTabViewModel(tabViewModel) let positioningViewInWindow = privacyDashboardPositioningView.convert(privacyDashboardPositioningView.bounds, to: view.window?.contentView) privacyDashboardPopover.setPreferredMaxHeight(positioningViewInWindow.origin.y) @@ -385,12 +383,11 @@ final class AddressBarButtonsViewController: NSViewController { privacyEntryPointButton.state = .on - privacyInfoCancellable?.cancel() - privacyInfoCancellable = selectedTabViewModel.tab.privacyInfoPublisher + privacyInfoCancellable = tabViewModel.tab.privacyInfoPublisher .dropFirst() .receive(on: DispatchQueue.main) - .sink { [weak privacyDashboardPopover, weak selectedTabViewModel] _ in - guard privacyDashboardPopover?.isShown == true, let tabViewModel = selectedTabViewModel else { return } + .sink { [weak privacyDashboardPopover, weak tabViewModel] _ in + guard privacyDashboardPopover?.isShown == true, let tabViewModel else { return } privacyDashboardViewController.updateTabViewModel(tabViewModel) } } @@ -398,11 +395,6 @@ final class AddressBarButtonsViewController: NSViewController { func updateButtons() { stopAnimationsAfterFocus() - if tabCollectionViewModel.selectedTabViewModel == nil { - os_log("%s: Selected tab view model is nil", type: .error, className) - return - } - clearButton.isHidden = !(isTextFieldEditorFirstResponder && !(textFieldValue?.isEmpty ?? true)) updatePrivacyEntryPointButton() @@ -412,22 +404,22 @@ final class AddressBarButtonsViewController: NSViewController { } @IBAction func cameraButtonAction(_ sender: NSButton) { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - os_log("%s: Selected tab view model is nil", type: .error, className) + guard let tabViewModel else { + assertionFailure("No selectedTabViewModel") return } - if case .requested(let query) = selectedTabViewModel.usedPermissions.camera { + if case .requested(let query) = tabViewModel.usedPermissions.camera { openPermissionAuthorizationPopover(for: query) return } var permissions = Permissions() - permissions.camera = selectedTabViewModel.usedPermissions.camera + permissions.camera = tabViewModel.usedPermissions.camera if microphoneButton.isHidden { - permissions.microphone = selectedTabViewModel.usedPermissions.microphone + permissions.microphone = tabViewModel.usedPermissions.microphone } - let url = selectedTabViewModel.tab.content.url ?? .empty + let url = tabViewModel.tab.content.url ?? .empty let domain = url.isFileURL ? .localhost : (url.host ?? "") PermissionContextMenu(permissions: permissions.map { ($0, $1) }, domain: domain, delegate: self) @@ -435,8 +427,8 @@ final class AddressBarButtonsViewController: NSViewController { } @IBAction func microphoneButtonAction(_ sender: NSButton) { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel, - let state = selectedTabViewModel.usedPermissions.microphone + guard let tabViewModel, + let state = tabViewModel.usedPermissions.microphone else { os_log("%s: Selected tab view model is nil or no microphone state", type: .error, className) return @@ -446,7 +438,7 @@ final class AddressBarButtonsViewController: NSViewController { return } - let url = selectedTabViewModel.tab.content.url ?? .empty + let url = tabViewModel.tab.content.url ?? .empty let domain = url.isFileURL ? .localhost : (url.host ?? "") PermissionContextMenu(permissions: [(.microphone, state)], domain: domain, delegate: self) @@ -454,8 +446,8 @@ final class AddressBarButtonsViewController: NSViewController { } @IBAction func geolocationButtonAction(_ sender: NSButton) { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel, - let state = selectedTabViewModel.usedPermissions.geolocation + guard let tabViewModel, + let state = tabViewModel.usedPermissions.geolocation else { os_log("%s: Selected tab view model is nil or no geolocation state", type: .error, className) return @@ -465,7 +457,7 @@ final class AddressBarButtonsViewController: NSViewController { return } - let url = selectedTabViewModel.tab.content.url ?? .empty + let url = tabViewModel.tab.content.url ?? .empty let domain = url.isFileURL ? .localhost : (url.host ?? "") PermissionContextMenu(permissions: [(.geolocation, state)], domain: domain, delegate: self) @@ -473,8 +465,8 @@ final class AddressBarButtonsViewController: NSViewController { } @IBAction func popupsButtonAction(_ sender: NSButton) { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel, - let state = selectedTabViewModel.usedPermissions.popups + guard let tabViewModel, + let state = tabViewModel.usedPermissions.popups else { os_log("%s: Selected tab view model is nil or no popups state", type: .error, className) return @@ -484,12 +476,12 @@ final class AddressBarButtonsViewController: NSViewController { let domain: String if case .requested(let query) = state { domain = query.domain - permissions = selectedTabViewModel.tab.permissions.authorizationQueries.reduce(into: .init()) { + permissions = tabViewModel.tab.permissions.authorizationQueries.reduce(into: .init()) { guard $1.permissions.contains(.popups) else { return } $0.append( (.popups, .requested($1)) ) } } else { - let url = selectedTabViewModel.tab.content.url ?? .empty + let url = tabViewModel.tab.content.url ?? .empty domain = url.isFileURL ? .localhost : (url.host ?? "") permissions = [(.popups, state)] } @@ -498,8 +490,8 @@ final class AddressBarButtonsViewController: NSViewController { } @IBAction func externalSchemeButtonAction(_ sender: NSButton) { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel, - let (permissionType, state) = selectedTabViewModel.usedPermissions.first(where: { $0.key.isExternalScheme }) + guard let tabViewModel, + let (permissionType, state) = tabViewModel.usedPermissions.first(where: { $0.key.isExternalScheme }) else { os_log("%s: Selected tab view model is nil or no externalScheme state", type: .error, className) return @@ -513,7 +505,7 @@ final class AddressBarButtonsViewController: NSViewController { } permissions = [(permissionType, state)] - let url = selectedTabViewModel.tab.content.url ?? .empty + let url = tabViewModel.tab.content.url ?? .empty let domain = url.isFileURL ? .localhost : (url.host ?? "") PermissionContextMenu(permissions: permissions, domain: domain, delegate: self) @@ -602,72 +594,68 @@ final class AddressBarButtonsViewController: NSViewController { } private func subscribeToSelectedTabViewModel() { - selectedTabViewModelCancellable = tabCollectionViewModel.$selectedTabViewModel.receive(on: DispatchQueue.main).sink { [weak self] _ in - self?.stopAnimations() - self?.subscribeToUrl() - self?.subscribeToPermissions() - self?.subscribeToPrivacyEntryPointIconUpdateTrigger() - self?.subscribeToTrackerAnimationTrigger() - self?.closePrivacyDashboard() - self?.updatePrivacyEntryPointIcon() + selectedTabViewModelCancellable = tabCollectionViewModel.$selectedTabViewModel.sink { [weak self] tabViewModel in + guard let self, let tabViewModel else { return } + + stopAnimations() + closePrivacyDashboard() + + self.tabViewModel = tabViewModel + subscribeToUrl() + subscribeToPermissions() + subscribeToPrivacyEntryPointIconUpdateTrigger() + subscribeToTrackerAnimationTrigger() + + updatePrivacyEntryPointIcon() } } private func subscribeToUrl() { - urlCancellable?.cancel() - - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - updateBookmarkButtonImage() - updateButtons() + guard let tabViewModel else { + urlCancellable = nil return } - - urlCancellable = selectedTabViewModel.tab.$content - .combineLatest(selectedTabViewModel.tab.$error) - .receive(on: DispatchQueue.main) + urlCancellable = tabViewModel.tab.$content + .combineLatest(tabViewModel.tab.$error) .sink { [weak self] _ in - self?.stopAnimations() - self?.updateBookmarkButtonImage() - self?.updateButtons() + guard let self else { return } + + stopAnimations() + updateBookmarkButtonImage() + updateButtons() } } private func subscribeToPermissions() { - permissionsCancellables = [] - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - return - } + permissionsCancellables.removeAll(keepingCapacity: true) - selectedTabViewModel.$usedPermissions.dropFirst().receive(on: DispatchQueue.main).sink { [weak self] _ in + tabViewModel?.$usedPermissions.dropFirst().sink { [weak self] _ in self?.updatePermissionButtons() }.store(in: &permissionsCancellables) - selectedTabViewModel.$permissionAuthorizationQuery.dropFirst().receive(on: DispatchQueue.main).sink { [weak self] _ in + tabViewModel?.$permissionAuthorizationQuery.dropFirst().sink { [weak self] _ in self?.updatePermissionButtons() }.store(in: &permissionsCancellables) } private func subscribeToTrackerAnimationTrigger() { - trackerAnimationTriggerCancellable?.cancel() - - trackerAnimationTriggerCancellable = tabCollectionViewModel.selectedTabViewModel?.trackersAnimationTriggerPublisher + trackerAnimationTriggerCancellable = tabViewModel?.trackersAnimationTriggerPublisher .sink { [weak self] _ in self?.animateTrackers() - } + } } private func subscribeToPrivacyEntryPointIconUpdateTrigger() { - privacyEntryPointIconUpdateCancellable?.cancel() - - privacyEntryPointIconUpdateCancellable = tabCollectionViewModel.selectedTabViewModel?.privacyEntryPointIconUpdateTrigger + privacyEntryPointIconUpdateCancellable = tabViewModel?.privacyEntryPointIconUpdateTrigger .sink { [weak self] _ in self?.updatePrivacyEntryPointIcon() - } + } } private func subscribeToBookmarkList() { bookmarkListCancellable = bookmarkManager.listPublisher.receive(on: DispatchQueue.main).sink { [weak self] _ in - self?.updateBookmarkButtonImage() - self?.updateBookmarkButtonVisibility() + guard let self else { return } + updateBookmarkButtonImage() + updateBookmarkButtonVisibility() } } @@ -700,31 +688,31 @@ final class AddressBarButtonsViewController: NSViewController { private func updatePermissionButtons() { permissionButtons.isHidden = isTextFieldEditorFirstResponder || isAnyTrackerAnimationPlaying - || (tabCollectionViewModel.selectedTabViewModel?.isShowingErrorPage ?? true) + || (tabViewModel?.isShowingErrorPage ?? true) defer { showOrHidePermissionPopoverIfNeeded() } - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { return } + guard let tabViewModel else { return } - geolocationButton.buttonState = selectedTabViewModel.usedPermissions.geolocation + geolocationButton.buttonState = tabViewModel.usedPermissions.geolocation - let (camera, microphone) = PermissionState?.combineCamera(selectedTabViewModel.usedPermissions.camera, - withMicrophone: selectedTabViewModel.usedPermissions.microphone) + let (camera, microphone) = PermissionState?.combineCamera(tabViewModel.usedPermissions.camera, + withMicrophone: tabViewModel.usedPermissions.microphone) cameraButton.buttonState = camera microphoneButton.buttonState = microphone - popupsButton.buttonState = selectedTabViewModel.usedPermissions.popups?.isRequested == true // show only when there're popups blocked - ? selectedTabViewModel.usedPermissions.popups + popupsButton.buttonState = tabViewModel.usedPermissions.popups?.isRequested == true // show only when there're popups blocked + ? tabViewModel.usedPermissions.popups : nil - externalSchemeButton.buttonState = selectedTabViewModel.usedPermissions.externalScheme + externalSchemeButton.buttonState = tabViewModel.usedPermissions.externalScheme } private func showOrHidePermissionPopoverIfNeeded() { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { return } + guard let tabViewModel else { return } - for permission in selectedTabViewModel.usedPermissions.keys { - guard case .requested(let query) = selectedTabViewModel.usedPermissions[permission] else { continue } + for permission in tabViewModel.usedPermissions.keys { + guard case .requested(let query) = tabViewModel.usedPermissions[permission] else { continue } let permissionAuthorizationPopover = permissionAuthorizationPopoverCreatingIfNeeded() guard !permissionAuthorizationPopover.isShown else { if permissionAuthorizationPopover.viewController.query === query { return } @@ -741,7 +729,7 @@ final class AddressBarButtonsViewController: NSViewController { } private func updateBookmarkButtonImage(isUrlBookmarked: Bool = false) { - if let url = tabCollectionViewModel.selectedTabViewModel?.tab.content.url, + if let url = tabViewModel?.tab.content.url, isUrlBookmarked || bookmarkManager.isUrlBookmarked(url: url) { bookmarkButton.image = Self.bookmarkFilledImage bookmarkButton.mouseOverTintColor = NSColor.bookmarkFilledTint @@ -755,14 +743,13 @@ final class AddressBarButtonsViewController: NSViewController { } private func updateImageButton() { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { return } - + guard let tabViewModel else { return } // Image button switch controllerMode { - case .browsing where selectedTabViewModel.isShowingErrorPage: + case .browsing where tabViewModel.isShowingErrorPage: imageButton.image = Self.webImage case .browsing: - imageButton.image = selectedTabViewModel.favicon + imageButton.image = tabViewModel.favicon case .editing(isUrl: true): imageButton.image = Self.webImage case .editing(isUrl: false): @@ -774,41 +761,34 @@ final class AddressBarButtonsViewController: NSViewController { } private func updatePrivacyEntryPointButton() { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - return - } + guard let tabViewModel else { return } - let urlScheme = selectedTabViewModel.tab.content.url?.scheme + let urlScheme = tabViewModel.tab.content.url?.scheme let isHypertextUrl = urlScheme == "http" || urlScheme == "https" let isEditingMode = controllerMode?.isEditing ?? false let isTextFieldValueText = textFieldValue?.isText ?? false - let isLocalUrl = selectedTabViewModel.tab.content.url?.isLocalURL ?? false + let isLocalUrl = tabViewModel.tab.content.url?.isLocalURL ?? false // Privacy entry point button privacyEntryPointButton.isHidden = isEditingMode || isTextFieldEditorFirstResponder || !isHypertextUrl - || selectedTabViewModel.isShowingErrorPage + || tabViewModel.isShowingErrorPage || isTextFieldValueText || isLocalUrl imageButtonWrapper.isHidden = view.window?.isPopUpWindow == true - || !privacyEntryPointButton.isHidden - || isAnyTrackerAnimationPlaying + || !privacyEntryPointButton.isHidden + || isAnyTrackerAnimationPlaying } private func updatePrivacyEntryPointIcon() { guard NSApp.runType.requiresEnvironment else { return } privacyEntryPointButton.image = nil - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - return - } - - guard !isAnyShieldAnimationPlaying else { - return - } + guard let tabViewModel else { return } + guard !isAnyShieldAnimationPlaying else { return } - switch selectedTabViewModel.tab.content { + switch tabViewModel.tab.content { case .url(let url, _, _): guard let host = url.host else { break } @@ -837,9 +817,9 @@ final class AddressBarButtonsViewController: NSViewController { private func animateTrackers() { guard !privacyEntryPointButton.isHidden, - let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { return } + let tabViewModel else { return } - switch selectedTabViewModel.tab.content { + switch tabViewModel.tab.content { case .url(let url, _, _): // Don't play the shield animation if mouse is over guard !privacyEntryPointButton.isAnimationViewVisible else { @@ -861,7 +841,7 @@ final class AddressBarButtonsViewController: NSViewController { return } - if let trackerInfo = selectedTabViewModel.tab.privacyInfo?.trackerInfo { + if let trackerInfo = tabViewModel.tab.privacyInfo?.trackerInfo { let lastTrackerImages = PrivacyIconViewModel.trackerImages(from: trackerInfo) trackerAnimationImageProvider.lastTrackerImages = lastTrackerImages @@ -933,8 +913,8 @@ final class AddressBarButtonsViewController: NSViewController { } private func bookmarkForCurrentUrl(setFavorite: Bool, accessPoint: Pixel.Event.AccessPoint) -> (bookmark: Bookmark?, isNew: Bool) { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel, - let url = selectedTabViewModel.tab.content.url else { + guard let tabViewModel, + let url = tabViewModel.tab.content.url else { assertionFailure("No URL for bookmarking") return (nil, false) } @@ -949,7 +929,7 @@ final class AddressBarButtonsViewController: NSViewController { } let bookmark = bookmarkManager.makeBookmark(for: url, - title: selectedTabViewModel.title, + title: tabViewModel.title, isFavorite: setFavorite) updateBookmarkButtonImage(isUrlBookmarked: bookmark != nil) @@ -984,13 +964,13 @@ final class AddressBarButtonsViewController: NSViewController { extension AddressBarButtonsViewController: PermissionContextMenuDelegate { func permissionContextMenu(_ menu: PermissionContextMenu, mutePermissions permissions: [PermissionType]) { - tabCollectionViewModel.selectedTabViewModel?.tab.permissions.set(permissions, muted: true) + tabViewModel?.tab.permissions.set(permissions, muted: true) } func permissionContextMenu(_ menu: PermissionContextMenu, unmutePermissions permissions: [PermissionType]) { - tabCollectionViewModel.selectedTabViewModel?.tab.permissions.set(permissions, muted: false) + tabViewModel?.tab.permissions.set(permissions, muted: false) } func permissionContextMenu(_ menu: PermissionContextMenu, allowPermissionQuery query: PermissionAuthorizationQuery) { - tabCollectionViewModel.selectedTabViewModel?.tab.permissions.allow(query) + tabViewModel?.tab.permissions.allow(query) } func permissionContextMenu(_ menu: PermissionContextMenu, alwaysAllowPermission permission: PermissionType) { PermissionManager.shared.setPermission(.allow, forDomain: menu.domain, permissionType: permission) @@ -1002,7 +982,7 @@ extension AddressBarButtonsViewController: PermissionContextMenuDelegate { PermissionManager.shared.setPermission(.ask, forDomain: menu.domain, permissionType: permission) } func permissionContextMenuReloadPage(_ menu: PermissionContextMenu) { - tabCollectionViewModel.selectedTabViewModel?.reload() + tabViewModel?.reload() } } diff --git a/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift b/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift index 39f964117c..bd1c0d4f03 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift @@ -47,6 +47,10 @@ final class AddressBarTextField: NSTextField { tabCollectionViewModel.isBurner } + var isFirstResponder: Bool { + window?.firstResponder == currentEditor() + } + private var suggestionResultCancellable: AnyCancellable? private var selectedSuggestionViewModelCancellable: AnyCancellable? private var selectedTabViewModelCancellable: AnyCancellable? @@ -104,41 +108,39 @@ final class AddressBarTextField: NSTextField { private func subscribeToSelectedTabViewModel() { selectedTabViewModelCancellable = tabCollectionViewModel.$selectedTabViewModel - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.restoreValueIfPossible() - self?.subscribeToAddressBarString() - self?.subscribeToContentType() + .compactMap { $0 } + .sink { [weak self] selectedTabViewModel in + guard let self else { return } + hideSuggestionWindow() + subscribeToAddressBarString(selectedTabViewModel: selectedTabViewModel) + subscribeToContentType(selectedTabViewModel: selectedTabViewModel) } } - private func subscribeToContentType() { - contentTypeCancellable = tabCollectionViewModel.selectedTabViewModel?.tab.$content - .receive(on: DispatchQueue.main) + private func subscribeToContentType(selectedTabViewModel: TabViewModel) { + contentTypeCancellable = selectedTabViewModel.tab.$content .sink { [weak self] contentType in self?.font = .systemFont(ofSize: contentType == .newtab ? 15 : 13) } } - private func subscribeToAddressBarString() { - addressBarStringCancellable?.cancel() - - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - clearValue() - return - } - addressBarStringCancellable = selectedTabViewModel.$addressBarString.dropFirst().receive(on: DispatchQueue.main).sink { [weak self] _ in - self?.updateValue() - } + private func subscribeToAddressBarString(selectedTabViewModel: TabViewModel) { + addressBarStringCancellable = selectedTabViewModel.$addressBarString + .dropFirst() + .sink { [weak self, weak selectedTabViewModel] addressBarString in + guard let self, let selectedTabViewModel else { return } + updateValueIfNeeded(selectedTabViewModel: selectedTabViewModel, addressBarString: addressBarString) + } + restoreValueIfPossible(newSelectedTabViewModel: selectedTabViewModel) } // MARK: - Value - @Published private(set) var value: Value = .text("") { + @Published private(set) var value: Value = .text("", userTyped: false) { didSet { guard value != oldValue else { return } - saveValue(oldValue: oldValue) + saveUndoValue(oldValue: oldValue) updateAttributedStringValue() if let editor, case .suggestion(let suggestion) = value { @@ -191,9 +193,7 @@ final class AddressBarTextField: NSTextField { } } - private func saveValue(oldValue: Value) { - tabCollectionViewModel.selectedTabViewModel?.lastAddressBarTextFieldValue = value - + private func saveUndoValue(oldValue: Value) { guard let undoManager else { return } // disable recording undo Value when iterating through suggestions if oldValue.isSuggestion && value.isSuggestion { return } @@ -209,20 +209,23 @@ final class AddressBarTextField: NSTextField { } } - private func restoreValueIfPossible() { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - clearValue() - return + private func restoreValueIfPossible(newSelectedTabViewModel: TabViewModel) { + // save current (possibly modified) value into the old TabViewModel when selecting another Tab + if let oldSelectedTabViewModel = tabCollectionViewModel.selectedTabViewModel { + guard oldSelectedTabViewModel !== newSelectedTabViewModel else { + updateValue(selectedTabViewModel: newSelectedTabViewModel, addressBarString: nil) + return + } + oldSelectedTabViewModel.lastAddressBarTextFieldValue = value } - - let lastAddressBarTextFieldValue = selectedTabViewModel.lastAddressBarTextFieldValue + let lastAddressBarTextFieldValue = newSelectedTabViewModel.lastAddressBarTextFieldValue switch lastAddressBarTextFieldValue { - case .text(let text): + case .text(let text, userTyped: let userTyped): if !text.isEmpty { - restoreValue(.text(text)) + restoreValue(.text(text, userTyped: userTyped)) } else { - updateValue() + updateValue(selectedTabViewModel: newSelectedTabViewModel, addressBarString: nil) } case .suggestion(let suggestionViewModel): let suggestion = suggestionViewModel.suggestion @@ -230,14 +233,14 @@ final class AddressBarTextField: NSTextField { case .website, .bookmark, .historyEntry: restoreValue(Value(stringValue: suggestionViewModel.autocompletionString, userTyped: true)) case .phrase(phrase: let phase): - restoreValue(Value.text(phase)) - default: - updateValue() + restoreValue(Value.text(phase, userTyped: false)) + case .unknown: + updateValue(selectedTabViewModel: newSelectedTabViewModel, addressBarString: nil) } case .url(urlString: let urlString, url: _, userTyped: true): restoreValue(Value(stringValue: urlString, userTyped: true)) - default: - updateValue() + case .url, .none: + updateValue(selectedTabViewModel: newSelectedTabViewModel, addressBarString: nil) } } @@ -247,17 +250,35 @@ final class AddressBarTextField: NSTextField { clearUndoManager() } - private func updateValue() { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { return } + // don‘t update Value when the address bar is being edited + private func updateValueIfNeeded(selectedTabViewModel: TabViewModel?, addressBarString: String) { + var shouldUpdateValue: Bool { + switch self.value { + case .suggestion(let suggestionViewModel): + switch suggestionViewModel.suggestion { + case .phrase, .website, .bookmark, .historyEntry: return false + case .unknown: return true + } + case .text(_, userTyped: true), .url(_, _, userTyped: true): return false + case .text, .url: return true + } + } + if !self.isFirstResponder || shouldUpdateValue { + updateValue(selectedTabViewModel: selectedTabViewModel, addressBarString: addressBarString) + } + } + + private func updateValue(selectedTabViewModel: TabViewModel?, addressBarString: String?) { + guard let selectedTabViewModel = selectedTabViewModel ?? tabCollectionViewModel.selectedTabViewModel else { return } - let addressBarString = selectedTabViewModel.addressBarString + let addressBarString = addressBarString ?? selectedTabViewModel.addressBarString let isSearch = selectedTabViewModel.tab.content.url?.isDuckDuckGoSearch ?? false self.value = Value(stringValue: addressBarString, userTyped: false, isSearch: isSearch) clearUndoManager() } func clearValue() { - self.value = .text("") + self.value = .text("", userTyped: false) suggestionContainerViewModel?.clearSelection() suggestionContainerViewModel?.clearUserStringValue() hideSuggestionWindow() @@ -290,8 +311,15 @@ final class AddressBarTextField: NSTextField { return } + // reset to actual value + let oldValue = value clearValue() - updateValue() + updateValue(selectedTabViewModel: nil, addressBarString: nil) + + if oldValue == value { + // resign first responder if nothing has changed + self.window?.makeFirstResponder(nil) + } } private func updateTabUrlWithUrl(_ providedUrl: URL, userEnteredValue: String, suggestion: Suggestion?) { @@ -318,16 +346,14 @@ final class AddressBarTextField: NSTextField { #if SUBSCRIPTION if providedUrl.isChild(of: URL.subscriptionBaseURL) || providedUrl.isChild(of: URL.identityTheftRestoration) { - selectedTabViewModel.updateAddressBarStrings() + self.updateValue(selectedTabViewModel: nil, addressBarString: nil) // reset self.window?.makeFirstResponder(nil) return } #endif - selectedTabViewModel.tab.setUrl(providedUrl, source: .userEntered(userEnteredValue)) - selectedTabViewModel.updateAddressBarStrings() - self.window?.makeFirstResponder(nil) + selectedTabViewModel.tab.setUrl(providedUrl, source: .userEntered(userEnteredValue)) } private func updateTabUrl(suggestion: Suggestion?) { @@ -511,7 +537,7 @@ final class AddressBarTextField: NSTextField { return } - guard !suggestionWindow.isVisible, window.firstResponder == currentEditor() else { return } + guard !suggestionWindow.isVisible, isFirstResponder else { return } window.addChildWindow(suggestionWindow, ordered: .above) layoutSuggestionWindow() @@ -640,7 +666,7 @@ extension AddressBarTextField { extension AddressBarTextField { enum Value: Equatable { - case text(_ text: String) + case text(_ text: String, userTyped: Bool) case url(urlString: String, url: URL, userTyped: Bool) case suggestion(_ suggestionViewModel: SuggestionViewModel) @@ -654,13 +680,13 @@ extension AddressBarTextField { } self = .url(urlString: stringValue, url: url, userTyped: userTyped) } else { - self = .text(stringValue) + self = .text(stringValue, userTyped: userTyped) } } var string: String { switch self { - case .text(let text): + case .text(let text, _): return text case .url(urlString: let urlString, url: _, userTyped: _): return urlString @@ -682,7 +708,7 @@ extension AddressBarTextField { var isEmpty: Bool { switch self { - case .text(let text): + case .text(let text, _): return text.isEmpty case .url(urlString: let urlString, url: _, userTyped: _): return urlString.isEmpty @@ -731,7 +757,7 @@ extension AddressBarTextField { enum Suffix { init?(value: Value) { - if case .text("") = value { + if case .text("", _) = value { return nil } @@ -835,7 +861,7 @@ extension AddressBarTextField: NSTextFieldDelegate { // if user continues typing letters from displayed Suggestion // don't blink and keep the Suggestion displayed if case .userAppendingTextToTheEnd = currentTextDidChangeEvent, - let suggestion = autocompleteSuggestionBeingTypedOverByUser(with: stringValueWithoutSuffix) { + let suggestion = autocompleteSuggestionBeingTypedOverByUser(with: stringValueWithoutSuffix) { self.value = .suggestion(SuggestionViewModel(isHomePage: isHomePage, suggestion: suggestion.suggestion, userStringValue: stringValueWithoutSuffix)) } else { @@ -864,12 +890,12 @@ extension AddressBarTextField: NSTextFieldDelegate { } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { - if NSApp.isReturnOrEnterPressed { + if commandSelector == #selector(insertNewline) + || commandSelector == #selector(insertNewlineIgnoringFieldEditor) + || commandSelector == Selector(("noop:")) && NSApp.isReturnOrEnterPressed { self.addressBarEnterPressed() return true - } - - if commandSelector == #selector(NSResponder.insertTab(_:)) { + } else if commandSelector == #selector(NSResponder.insertTab(_:)) { window?.makeFirstResponder(nextKeyView) return false @@ -1029,7 +1055,7 @@ extension AddressBarTextField: NSTextViewDelegate { // filter out menu items with action from `selectorsToRemove` or containing submenu items with action from the list menu.items = menu.items.filter { menuItem in menuItem.action.map { action in Self.selectorsToRemove.contains(action) } != true - && Self.selectorsToRemove.isDisjoint(with: menuItem.submenu?.items.compactMap(\.action) ?? []) + && Self.selectorsToRemove.isDisjoint(with: menuItem.submenu?.items.compactMap(\.action) ?? []) } } diff --git a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift index e60a26b90f..d128440bc9 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift @@ -36,6 +36,7 @@ final class AddressBarViewController: NSViewController { private(set) var addressBarButtonsViewController: AddressBarButtonsViewController? private let tabCollectionViewModel: TabCollectionViewModel + private var tabViewModel: TabViewModel? private let suggestionContainerViewModel: SuggestionContainerViewModel private let isBurner: Bool @@ -58,6 +59,7 @@ final class AddressBarViewController: NSViewController { didSet { updateView() self.addressBarButtonsViewController?.isTextFieldEditorFirstResponder = isFirstResponder + self.clickPoint = nil // reset click point if the address bar activated during click } } @@ -82,7 +84,7 @@ final class AddressBarViewController: NSViewController { init?(coder: NSCoder, tabCollectionViewModel: TabCollectionViewModel, isBurner: Bool) { self.tabCollectionViewModel = tabCollectionViewModel self.suggestionContainerViewModel = SuggestionContainerViewModel( - isHomePage: tabCollectionViewModel.selectedTabViewModel?.tab.content == .newtab, + isHomePage: tabViewModel?.tab.content == .newtab, isBurner: isBurner, suggestionContainer: SuggestionContainer()) self.isBurner = isBurner @@ -96,17 +98,15 @@ final class AddressBarViewController: NSViewController { view.wantsLayer = true view.layer?.masksToBounds = false + addressBarTextField.placeholderString = UserText.addressBarPlaceholder + updateView() // only activate active text field leading constraint on its appearance to avoid constraint conflicts activeTextFieldMinXConstraint.isActive = false addressBarTextField.tabCollectionViewModel = tabCollectionViewModel - subscribeToSelectedTabViewModel() - subscribeToAddressBarValue() - registerForMouseEnteredAndExitedEvents() } override func viewWillAppear() { - if view.window?.isPopUpWindow == true { addressBarTextField.isHidden = true inactiveBackgroundView.isHidden = true @@ -139,6 +139,9 @@ final class AddressBarViewController: NSViewController { object: nil) addMouseMonitors() } + subscribeToSelectedTabViewModel() + subscribeToAddressBarValue() + registerForMouseEnteredAndExitedEvents() subscribeToButtonsWidth() subscribeForShadowViewUpdates() } @@ -159,14 +162,14 @@ final class AddressBarViewController: NSViewController { func escapeKeyDown() -> Bool { guard isFirstResponder else { return false } - guard !mode.isEditing else { + if mode.isEditing { addressBarTextField.escapeKeyDown() return true } // If the webview doesn't have content it doesn't handle becoming the first responder properly - if tabCollectionViewModel.selectedTabViewModel?.tab.webView.url != nil { - tabCollectionViewModel.selectedTabViewModel?.tab.webView.makeMeFirstResponder() + if tabViewModel?.tab.webView.url != nil { + tabViewModel?.tab.webView.makeMeFirstResponder() } else { view.superview?.becomeFirstResponder() } @@ -175,8 +178,7 @@ final class AddressBarViewController: NSViewController { } @IBSegueAction func createAddressBarButtonsViewController(_ coder: NSCoder) -> AddressBarButtonsViewController? { - let controller = AddressBarButtonsViewController(coder: coder, - tabCollectionViewModel: tabCollectionViewModel) + let controller = AddressBarButtonsViewController(coder: coder, tabCollectionViewModel: tabCollectionViewModel) self.addressBarButtonsViewController = controller controller?.delegate = self @@ -187,10 +189,10 @@ final class AddressBarViewController: NSViewController { private func subscribeToSelectedTabViewModel() { tabCollectionViewModel.$selectedTabViewModel - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in + .sink { [weak self] tabViewModel in guard let self else { return } + self.tabViewModel = tabViewModel tabViewModelCancellables.removeAll() subscribeToTabContent() @@ -216,39 +218,50 @@ final class AddressBarViewController: NSViewController { } private func subscribeToTabContent() { - tabCollectionViewModel.selectedTabViewModel?.tab.$content - .receive(on: DispatchQueue.main) + tabViewModel?.tab.$content .map { $0 == .newtab } .assign(to: \.isHomePage, onWeaklyHeld: self) .store(in: &tabViewModelCancellables) } private func subscribeToPassiveAddressBarString() { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { + guard let tabViewModel else { passiveTextField.stringValue = "" return } - selectedTabViewModel.$passiveAddressBarString + tabViewModel.$passiveAddressBarString .receive(on: DispatchQueue.main) .assign(to: \.stringValue, onWeaklyHeld: passiveTextField) .store(in: &tabViewModelCancellables) } private func subscribeToProgressEvents() { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { + guard let tabViewModel else { progressIndicator.hide(animated: false) return } - if selectedTabViewModel.isLoading { - progressIndicator.show(progress: selectedTabViewModel.progress, startTime: selectedTabViewModel.loadingStartTime) + func shouldShowLoadingIndicator(for tabViewModel: TabViewModel, isLoading: Bool, error: Error?) -> Bool { + if isLoading, + let url = tabViewModel.tab.content.url, + [.http, .https].contains(url.navigationalScheme), + url.isDuckDuckGoSearch == false, + error == nil { + return true + } else { + return false + } + } + + if shouldShowLoadingIndicator(for: tabViewModel, isLoading: tabViewModel.isLoading, error: tabViewModel.tab.error) { + progressIndicator.show(progress: tabViewModel.progress, startTime: tabViewModel.loadingStartTime) } else { progressIndicator.hide(animated: false) } - selectedTabViewModel.$progress + tabViewModel.$progress .sink { [weak self] value in - guard selectedTabViewModel.isLoading, + guard tabViewModel.isLoading, let progressIndicator = self?.progressIndicator, progressIndicator.isShown else { return } @@ -257,18 +270,13 @@ final class AddressBarViewController: NSViewController { } .store(in: &tabViewModelCancellables) - selectedTabViewModel.$isLoading.combineLatest(selectedTabViewModel.tab.$error) + tabViewModel.$isLoading.combineLatest(tabViewModel.tab.$error) .debounce(for: 0.1, scheduler: RunLoop.main) .sink { [weak self] isLoading, error in guard let progressIndicator = self?.progressIndicator else { return } - if isLoading, - let url = selectedTabViewModel.tab.content.url, - [.http, .https].contains(url.navigationalScheme), - url.isDuckDuckGoSearch == false, - error == nil { - - progressIndicator.show(progress: selectedTabViewModel.progress, startTime: selectedTabViewModel.loadingStartTime) + if shouldShowLoadingIndicator(for: tabViewModel, isLoading: isLoading, error: error) { + progressIndicator.show(progress: tabViewModel.progress, startTime: tabViewModel.loadingStartTime) } else if progressIndicator.isShown { progressIndicator.finishAndHide() @@ -319,6 +327,8 @@ final class AddressBarViewController: NSViewController { activeBackgroundView.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.8).cgColor activeOuterBorderView.layer?.backgroundColor = accentColor.withAlphaComponent(0.2).cgColor activeBackgroundView.layer?.borderColor = accentColor.withAlphaComponent(0.8).cgColor + + addressBarTextField.placeholderString = tabViewModel?.tab.content == .newtab ? UserText.addressBarPlaceholder : "" } private func updateShadowViewPresence(_ isFirstResponder: Bool) { diff --git a/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard b/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard index e691b100b1..6ba355699c 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard +++ b/DuckDuckGo/NavigationBar/View/NavigationBar.storyboard @@ -10,9 +10,8 @@ - + - @@ -321,6 +320,7 @@ + @@ -345,6 +345,7 @@ + diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index c2facfea75..b2b012f632 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -64,6 +64,7 @@ final class NavigationBarViewController: NSViewController { @IBOutlet var navigationBarButtonsLeadingConstraint: NSLayoutConstraint! @IBOutlet var addressBarTopConstraint: NSLayoutConstraint! @IBOutlet var addressBarBottomConstraint: NSLayoutConstraint! + @IBOutlet var addressBarHeightConstraint: NSLayoutConstraint! @IBOutlet var buttonsTopConstraint: NSLayoutConstraint! @IBOutlet var logoWidthConstraint: NSLayoutConstraint! @@ -568,36 +569,99 @@ final class NavigationBarViewController: NSViewController { }) } - var daxFadeInAnimation: DispatchWorkItem? - func resizeAddressBarForHomePage(_ homePage: Bool, animated: Bool) { + enum AddressBarSizeClass { + case `default` + case homePage + case popUpWindow + + fileprivate var height: CGFloat { + switch self { + case .homePage: 52 + case .popUpWindow: 42 + case .default: 48 + } + } + + fileprivate var topPadding: CGFloat { + switch self { + case .homePage: 16 + case .popUpWindow: 0 + case .default: 6 + } + } + + fileprivate var bottomPadding: CGFloat { + switch self { + case .homePage: 2 + case .popUpWindow: 0 + case .default: 6 + } + } + + fileprivate var logoWidth: CGFloat { + switch self { + case .homePage: 44 + case .popUpWindow, .default: 0 + } + } + + fileprivate var isLogoVisible: Bool { + switch self { + case .homePage: true + case .popUpWindow, .default: false + } + } + } + + private var daxFadeInAnimation: DispatchWorkItem? + private var heightChangeAnimation: DispatchWorkItem? + func resizeAddressBar(for sizeClass: AddressBarSizeClass, animated: Bool) { daxFadeInAnimation?.cancel() + heightChangeAnimation?.cancel() + + daxLogo.alphaValue = !sizeClass.isLogoVisible ? 1 : 0 // initial value to animate from - let verticalPadding: CGFloat = view.window?.isPopUpWindow == true ? 0 : 6 + let performResize = { [weak self] in + guard let self else { return } - let barTop = animated ? addressBarTopConstraint.animator() : addressBarTopConstraint - barTop?.constant = homePage ? 16 : verticalPadding + let height: NSLayoutConstraint = animated ? addressBarHeightConstraint.animator() : addressBarHeightConstraint + height.constant = sizeClass.height - let bottom = animated ? addressBarBottomConstraint.animator() : addressBarBottomConstraint - bottom?.constant = homePage ? 2 : verticalPadding + let barTop: NSLayoutConstraint = animated ? addressBarTopConstraint.animator() : addressBarTopConstraint + barTop.constant = sizeClass.topPadding - let logoWidth = animated ? logoWidthConstraint.animator() : logoWidthConstraint - logoWidth?.constant = homePage ? 44 : 0 + let bottom: NSLayoutConstraint = animated ? addressBarBottomConstraint.animator() : addressBarBottomConstraint + bottom.constant = sizeClass.bottomPadding - daxLogo.alphaValue = homePage ? 0 : 1 // initial value to animate from + let logoWidth: NSLayoutConstraint = animated ? logoWidthConstraint.animator() : logoWidthConstraint + logoWidth.constant = sizeClass.logoWidth + } + let heightChange: DispatchWorkItem if animated { + heightChange = DispatchWorkItem { + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.1 + performResize() + } + } let fadeIn = DispatchWorkItem { [weak self] in + guard let self else { return } NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.2 - self?.daxLogo.animator().alphaValue = homePage ? 1 : 0 + self.daxLogo.alphaValue = sizeClass.isLogoVisible ? 1 : 0 } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: fadeIn) self.daxFadeInAnimation = fadeIn } else { - daxLogo.alphaValue = homePage ? 1 : 0 + daxLogo.alphaValue = sizeClass.isLogoVisible ? 1 : 0 + heightChange = DispatchWorkItem { + performResize() + } } - + DispatchQueue.main.async(execute: heightChange) + self.heightChangeAnimation = heightChange } private func subscribeToDownloads() { diff --git a/DuckDuckGo/Tab/Model/Tab.swift b/DuckDuckGo/Tab/Model/Tab.swift index 6c7605161c..a8dc0f6c89 100644 --- a/DuckDuckGo/Tab/Model/Tab.swift +++ b/DuckDuckGo/Tab/Model/Tab.swift @@ -611,7 +611,7 @@ protocol NewWindowPolicyDecisionMaker { let burnerMode: BurnerMode - @Published private(set) var content: TabContent { + @PublishedAfter private(set) var content: TabContent { didSet { if !content.displaysContentInWebView && oldValue.displaysContentInWebView { webView.stopAllMedia(shouldStopLoading: false) @@ -680,17 +680,16 @@ protocol NewWindowPolicyDecisionMaker { } return } - var title = webView.title?.trimmingWhitespace() - if title?.isEmpty ?? true { - title = webView.url?.host?.droppingWwwPrefix() - } + let title = webView.title?.trimmingWhitespace() if title != self.title { self.title = title } if let wkBackForwardListItem = webView.backForwardList.currentItem, - content.urlForWebView == wkBackForwardListItem.url { + content.urlForWebView == wkBackForwardListItem.url, + !webView.isLoading, + title?.isEmpty == false { wkBackForwardListItem.tabTitle = title } } @@ -834,7 +833,15 @@ protocol NewWindowPolicyDecisionMaker { } userInteractionDialog = nil - return webView.navigator()?.goBack(withExpectedNavigationType: .backForward(distance: -1)) + let navigation = webView.navigator()?.goBack(withExpectedNavigationType: .backForward(distance: -1)) + // update TabContent source to .historyEntry on navigation + navigation?.appendResponder(willStart: { [weak self] navigation in + guard let self, + case .url(let url, credential: let credential, .webViewUpdated) = self.content, + url == navigation.url else { return } + self.content = .url(url, credential: credential, source: .historyEntry) + }) + return navigation } @MainActor @@ -843,7 +850,15 @@ protocol NewWindowPolicyDecisionMaker { guard canGoForward else { return nil } userInteractionDialog = nil - return webView.navigator()?.goForward(withExpectedNavigationType: .backForward(distance: 1)) + let navigation = webView.navigator()?.goForward(withExpectedNavigationType: .backForward(distance: 1)) + // update TabContent source to .historyEntry on navigation + navigation?.appendResponder(willStart: { [weak self] navigation in + guard let self, + case .url(let url, credential: let credential, _) = self.content, + url == navigation.url else { return } + self.content = .url(url, credential: credential, source: .historyEntry) + }) + return navigation } @MainActor @@ -890,8 +905,16 @@ protocol NewWindowPolicyDecisionMaker { return nil } - return webView.navigator()?.go(to: backForwardNavigation.item, - withExpectedNavigationType: .backForward(distance: backForwardNavigation.distance)) + let navigation = webView.navigator()?.go(to: backForwardNavigation.item, + withExpectedNavigationType: .backForward(distance: backForwardNavigation.distance)) + // update TabContent source to .historyEntry on navigation + navigation?.appendResponder(willStart: { [weak self] navigation in + guard let self, + case .url(let url, credential: let credential, _) = self.content, + url == navigation.url else { return } + self.content = .url(url, credential: credential, source: .historyEntry) + }) + return navigation } func openHomePage() { @@ -899,9 +922,9 @@ protocol NewWindowPolicyDecisionMaker { if startupPreferences.launchToCustomHomePage, let customURL = URL(string: startupPreferences.formattedCustomHomePageURL) { - webView.load(URLRequest(url: customURL)) + setContent(.url(customURL, credential: nil, source: .ui)) } else { - webView.load(URLRequest(url: .newtab)) + setContent(.newtab) } } @@ -925,12 +948,13 @@ protocol NewWindowPolicyDecisionMaker { // 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())')") { + self.content = .url(failingUrl, credential: nil, source: .reload) webView.load(URLRequest(url: redirectUrl)) return nil } + self.content = content.forceReload() if webView.url == nil, content.isUrl { - self.content = content.forceReload() // load from cache or interactionStateData when called by lazy loader return reloadIfNeeded(shouldLoadInBackground: true) } else { @@ -1303,6 +1327,7 @@ extension Tab/*: NavigationResponder*/ { // to be moved to Tab+Navigation.swift func didStart(_ navigation: Navigation) { webViewDidStartNavigationPublisher.send() delegate?.tabDidStartNavigation(self) + permissions.tabDidStartNavigation() userInteractionDialog = nil // Unnecessary assignment triggers publishing diff --git a/DuckDuckGo/Tab/View/BrowserTabView.swift b/DuckDuckGo/Tab/View/BrowserTabView.swift index 06ab7c7782..e9dedaac0b 100644 --- a/DuckDuckGo/Tab/View/BrowserTabView.swift +++ b/DuckDuckGo/Tab/View/BrowserTabView.swift @@ -20,6 +20,19 @@ import AppKit final class BrowserTabView: ColorView { + // Returns correct subview for the rendering of snapshots + func findContentSubview(containsHostingView: Bool) -> NSView? { + var content = subviews.last + + if containsHostingView { + content = content?.subviews.first + + assert(content?.className.contains("NSHostingView") == true) + } + + return content + } + // MARK: NSDraggingDestination override func draggingEntered(_ draggingInfo: NSDraggingInfo) -> NSDragOperation { diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index 8970e60ea5..fffd82a5ac 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -16,15 +16,17 @@ // limitations under the License. // +import BrowserServicesKit import Cocoa -import WebKit import Combine import Common import SwiftUI -import BrowserServicesKit +import WebKit +// swiftlint:disable:next type_body_length final class BrowserTabViewController: NSViewController { + private lazy var browserTabView = BrowserTabView(frame: .zero, backgroundColor: .browserTabBackground) 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) @@ -38,10 +40,8 @@ final class BrowserTabViewController: NSViewController { private let tabCollectionViewModel: TabCollectionViewModel private let bookmarkManager: BookmarkManager - private var tabContentCancellable: AnyCancellable? - private var userDialogsCancellable: AnyCancellable? + private var tabViewModelCancellables = Set() private var activeUserDialogCancellable: Cancellable? - private var hoverLinkCancellable: AnyCancellable? private var pinnedTabsDelegatesCancellable: AnyCancellable? private var keyWindowSelectedTabCancellable: AnyCancellable? private var cancellables = Set() @@ -51,7 +51,7 @@ final class BrowserTabViewController: NSViewController { private var hoverLabelWorkItem: DispatchWorkItem? - private var transientTabContentViewController: NSViewController? + private(set) var transientTabContentViewController: NSViewController? required init?(coder: NSCoder) { fatalError("BrowserTabViewController: Bad initializer") @@ -65,7 +65,7 @@ final class BrowserTabViewController: NSViewController { } override func loadView() { - view = BrowserTabView(frame: .zero, backgroundColor: .browserTabBackground) + view = browserTabView homePageView.translatesAutoresizingMaskIntoConstraints = false view.addAndLayout(homePageView) @@ -186,7 +186,7 @@ final class BrowserTabViewController: NSViewController { guard WindowControllersManager.shared.lastKeyMainWindowController === self.view.window?.windowController, let previouslySelectedTab else { return } - if let activeTab = tabCollectionViewModel.selectedTabViewModel?.tab, + if let activeTab = tabViewModel?.tab, let url = activeTab.url, EmailUrls().isDuckDuckGoEmailProtection(url: url) { @@ -206,7 +206,7 @@ final class BrowserTabViewController: NSViewController { @objc private func onCloseDataBrokerProtection(_ notification: Notification) { - guard let activeTab = tabCollectionViewModel.selectedTabViewModel?.tab, + guard let activeTab = tabViewModel?.tab, view.window?.isKeyWindow == true else { return } self.closeTab(activeTab) @@ -227,7 +227,7 @@ final class BrowserTabViewController: NSViewController { #if SUBSCRIPTION @objc private func onCloseSubscriptionPage(_ notification: Notification) { - guard let activeTab = tabCollectionViewModel.selectedTabViewModel?.tab else { return } + guard let activeTab = tabViewModel?.tab else { return } self.closeTab(activeTab) if let previouslySelectedTab = self.previouslySelectedTab { @@ -247,9 +247,13 @@ final class BrowserTabViewController: NSViewController { generateNativePreviewIfNeeded() self.tabViewModel = selectedTabViewModel self.showTabContent(of: selectedTabViewModel) + + self.tabViewModelCancellables.removeAll(keepingCapacity: true) self.subscribeToTabContent(of: selectedTabViewModel) self.subscribeToHoveredLink(of: selectedTabViewModel) self.subscribeToUserDialogs(of: selectedTabViewModel) + + self.adjustFirstResponder(force: true) } .store(in: &cancellables) } @@ -352,19 +356,15 @@ final class BrowserTabViewController: NSViewController { let webViewContainer = webViewContainer displayWebView(of: tabViewModel) - tabViewModel.updateAddressBarStrings() + if let oldWebView = oldWebView, let webViewContainer = webViewContainer, oldWebView !== webView { removeWebViewFromHierarchy(webView: oldWebView, container: webViewContainer) } - - if setFirstResponderAfterAdding { - setFirstResponderAfterAdding = false - makeWebViewFirstResponder() - } + adjustFirstResponderAfterAddingContentViewIfNeeded() } private func subscribeToTabContent(of tabViewModel: TabViewModel?) { - tabContentCancellable = tabViewModel?.tab.$content + tabViewModel?.tab.$content .dropFirst() .removeDuplicates(by: { old, new in // no need to call showTabContent if webView stays in place and only its URL changes @@ -397,13 +397,13 @@ final class BrowserTabViewController: NSViewController { guard let tabViewModel else { return } self?.showTabContent(of: tabViewModel) } + .store(in: &tabViewModelCancellables) } private func subscribeToUserDialogs(of tabViewModel: TabViewModel?) { - userDialogsCancellable = nil guard let tabViewModel else { return } - userDialogsCancellable = Publishers.CombineLatest( + Publishers.CombineLatest( tabViewModel.tab.$userInteractionDialog, tabViewModel.tab.downloads?.savePanelDialogPublisher ?? Just(nil).eraseToAnyPublisher() ) @@ -411,12 +411,13 @@ final class BrowserTabViewController: NSViewController { .sink { [weak self] dialog in self?.show(dialog) } + .store(in: &tabViewModelCancellables) } func subscribeToHoveredLink(of tabViewModel: TabViewModel?) { - hoverLinkCancellable = tabViewModel?.tab.hoveredLinkPublisher.sink { [weak self] in + tabViewModel?.tab.hoveredLinkPublisher.sink { [weak self] in self?.scheduleHoverLabelUpdatesForUrl($0) - } + }.store(in: &tabViewModelCancellables) #if DEBUG if case .xcPreviews = NSApp.runType { self.scheduleHoverLabelUpdatesForUrl(.duckDuckGo) @@ -424,29 +425,93 @@ final class BrowserTabViewController: NSViewController { #endif } - func makeWebViewFirstResponder() { - if let webView = self.webView { - webView.makeMeFirstResponder() - } else { - setFirstResponderAfterAdding = true - view.window?.makeFirstResponder(nil) + private func shouldMakeContentViewFirstResponder(for tabContent: Tab.TabContent) -> Bool { + // always steal focus when first responder is not a text field + guard view.window?.firstResponder is NSText else { + return true + } + + switch tabContent { + case .newtab: + return false + case .url(_, _, source: .webViewUpdated): + // prevent Address Bar deactivation when the WebView is restoring state or updates url on redirect + return false + + case .url(_, _, source: .pendingStateRestoration), + .url(_, _, source: .loadedByStateRestoration), + .url(_, _, source: .userEntered), + .url(_, _, source: .historyEntry), + .url(_, _, source: .bookmark), + .url(_, _, source: .ui), + .url(_, _, source: .link), + .url(_, _, source: .appOpenUrl), + .url(_, _, source: .reload): + return true + + case .settings, .bookmarks, .dataBrokerProtection, .subscription, .onboarding: + return true + + case .none: + return false } } - private var setFirstResponderAfterAdding = false + func adjustFirstResponder(force: Bool = false, tabContent: Tab.TabContent? = nil) { + viewToMakeFirstResponderAfterAdding = nil + guard let window = view.window, window.isVisible, + let tabContent = tabContent ?? tabViewModel?.tab.content, + force || shouldMakeContentViewFirstResponder(for: tabContent) else { return } - private func setFirstResponderIfNeeded() { - guard let webView else { - setFirstResponderAfterAdding = true + let getView: (() -> NSView?)? + switch tabContent { + case .newtab: + // don‘t steal focus from the address bar at .newtab page return + case .onboarding: + getView = { [weak self] in self?.transientTabContentViewController?.view } + case .url, .subscription: + getView = { [weak self] in self?.webView } + case .settings: + getView = { [weak self] in self?.preferencesViewController?.view } + case .bookmarks: + getView = { [weak self] in self?.bookmarksViewController?.view } + case .dataBrokerProtection: + getView = { [weak self] in self?.dataBrokerProtectionHomeViewController?.view } + case .none: + getView = nil } - guard webView.url != nil else { + + var contentView = getView?() + if let getView, contentView == nil || contentView?.window !== window { + // if contentView in wrong window or not created yet - activate after adding + viewToMakeFirstResponderAfterAdding = getView + contentView = nil + } + + guard window.firstResponder !== contentView ?? window else { return } + window.makeFirstResponder(contentView) + } + + private var viewToMakeFirstResponderAfterAdding: (() -> NSView?)? + private func adjustFirstResponderAfterAddingContentViewIfNeeded() { + guard let window = view.window, + let contentView = viewToMakeFirstResponderAfterAdding?() else { return } + + guard contentView.window === window else { + os_log("BrowserTabViewController: Content view window is \(contentView.window?.description ?? "") but expected: \(window)", type: .error) return } + viewToMakeFirstResponderAfterAdding = nil - DispatchQueue.main.async { [weak self] in - self?.makeWebViewFirstResponder() + // if the Address Bar was activated after the initial adjustFirstResponder call - + // don‘t steal focus from the Address Bar + guard window.firstResponder === window else { + self.viewToMakeFirstResponderAfterAdding = nil + return } + + window.makeFirstResponder(contentView) } func openNewTab(with content: Tab.TabContent) { @@ -500,6 +565,9 @@ final class BrowserTabViewController: NSViewController { return } scheduleHoverLabelUpdatesForUrl(nil) + defer { + adjustFirstResponderAfterAddingContentViewIfNeeded() + } switch tabViewModel?.tab.content { case .bookmarks: @@ -554,10 +622,12 @@ final class BrowserTabViewController: NSViewController { let isPinnedTab = tabCollectionViewModel.pinnedTabsCollection?.tabs.contains(tabViewModel.tab) == true let isKeyWindow = view.window?.isKeyWindow == true - let tabIsNotOnScreen = tabViewModel.tab.webView.tabContentView.superview == nil - let isDifferentTabDisplayed = webView != tabViewModel.tab.webView + let tabIsNotOnScreen = webView?.tabContentView.superview == nil + let isDifferentTabDisplayed = webView !== tabViewModel.tab.webView - return isDifferentTabDisplayed || tabIsNotOnScreen || (isPinnedTab && isKeyWindow) + return isDifferentTabDisplayed + || tabIsNotOnScreen + || (isPinnedTab && isKeyWindow && webView?.tabContentView.window !== view.window) } func generateNativePreviewIfNeeded() { @@ -575,7 +645,7 @@ final class BrowserTabViewController: NSViewController { containsHostingView = false } - guard let viewForRendering = view.findContentSubview(containsHostingView: containsHostingView) else { + guard let viewForRendering = browserTabView.findContentSubview(containsHostingView: containsHostingView) else { assertionFailure("No view for rendering of the snapshot") return } @@ -715,16 +785,14 @@ extension BrowserTabViewController: TabDelegate { } func tabPageDOMLoaded(_ tab: Tab) { - if tabViewModel?.tab == tab { + if tabViewModel?.tab === tab { tabViewModel?.isLoading = false } } func tabDidStartNavigation(_ tab: Tab) { - setFirstResponderIfNeeded() - guard let tabViewModel = tabViewModel else { return } + guard let tabViewModel, tabViewModel.tab === tab else { return } - tab.permissions.tabDidStartNavigation() if !tabViewModel.isLoading, tabViewModel.tab.webView.isLoading { tabViewModel.isLoading = true @@ -1094,36 +1162,24 @@ extension BrowserTabViewController { private func hideWebViewSnapshotIfNeeded() { if webViewSnapshot != nil { - DispatchQueue.main.async { - let isWebViewFirstResponder = self.view.window?.firstResponder === self.view.window - // check this because if address bar was the first responder, we don't want to mess with it - if isWebViewFirstResponder { - self.setFirstResponderAfterAdding = true + DispatchQueue.main.async { [weak self, tabViewModel] in + guard let self, + self.tabViewModel === tabViewModel else { return } + + // only make web view first responder after replacing the + // snapshot if the address bar is not the first responder + if view.window?.firstResponder === view.window { + viewToMakeFirstResponderAfterAdding = { [weak self] in + self?.webView + } } - self.showTabContent(of: self.tabViewModel) - self.webViewSnapshot?.removeFromSuperview() + showTabContent(of: tabViewModel) + webViewSnapshot?.removeFromSuperview() } } } } -fileprivate extension NSView { - - // Returns correct subview for the rendering of snapshots - func findContentSubview(containsHostingView: Bool) -> NSView? { - var content = subviews.last - - if containsHostingView { - content = content?.subviews.first - - assert(content?.className.contains("NSHostingView") == true) - } - - return content - } - -} - @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 91803f30ac..20adafb0dc 100644 --- a/DuckDuckGo/Tab/ViewModel/TabViewModel.swift +++ b/DuckDuckGo/Tab/ViewModel/TabViewModel.swift @@ -59,7 +59,6 @@ final class TabViewModel { @Published private(set) var addressBarString: String = "" @Published private(set) var passiveAddressBarString: String = "" var lastAddressBarTextFieldValue: AddressBarTextField.Value? - private(set) var addressBarHasUpdated: Bool = false @Published private(set) var title: String = UserText.tabHomeTitle @Published private(set) var favicon: NSImage? @@ -94,29 +93,52 @@ final class TabViewModel { tab.$loadingProgress .assign(to: \.progress, onWeaklyHeld: self) .store(in: &cancellables) + if case .url(_, credential: _, source: .pendingStateRestoration) = tab.content { + updateAddressBarStrings() + } } private func subscribeToUrl() { + enum Event { + case instant + case didCommit + } tab.$content - .receive(on: DispatchQueue.main) - .sink { [weak self] content in - self?.waitToUpdateAddressBar(content) + .map { [tab] content -> AnyPublisher in + switch content { + case .url(_, _, source: .webViewUpdated), + .url(_, _, source: .link): + + // Update the address bar only after the tab did commit navigation to prevent Address Bar Spoofing + return tab.webViewDidCommitNavigationPublisher.map { .didCommit }.eraseToAnyPublisher() + + case .url(_, _, source: .pendingStateRestoration), + .url(_, _, source: .loadedByStateRestoration), + .url(_, _, source: .userEntered), + .url(_, _, source: .historyEntry), + .url(_, _, source: .bookmark), + .url(_, _, source: .ui), + .url(_, _, source: .appOpenUrl), + .url(_, _, source: .reload), + .newtab, + .settings, + .bookmarks, + .onboarding, + .none, + .dataBrokerProtection, + .subscription: + // Update the address bar instantly for built-in content types or user-initiated navigations + return Just( .instant ).eraseToAnyPublisher() + } } - .store(in: &cancellables) - } - - private func waitToUpdateAddressBar(_ content: Published.Publisher.Output) { - self.addressBarHasUpdated = false - tab.$loadingProgress - .receive(on: DispatchQueue.main) - .sink { [weak self] progress in - // Update the address bar only after the tab has reached 10% loading - // to prevent Address Bar Spoofing - if progress > 0.1 && !(self?.addressBarHasUpdated ?? true) { - self?.updateAddressBarStrings() - self?.updateCanBeBookmarked() - self?.updateFavicon() - self?.addressBarHasUpdated = true + .switchToLatest() + .sink { [weak self] event in + guard let self else { return } + + updateAddressBarStrings() + if case .didCommit = event { + updateCanBeBookmarked() + updateFavicon() } } .store(in: &cancellables) @@ -162,10 +184,13 @@ final class TabViewModel { } private func subscribeToTabError() { - tab.$error.sink { [weak self] _ in - self?.updateTitle() - self?.updateFavicon() - }.store(in: &cancellables) + tab.$error + .map { $0 != nil } + .removeDuplicates() + .sink { [weak self] _ in + self?.updateTitle() + self?.updateFavicon() + }.store(in: &cancellables) } private func subscribeToPermissions() { @@ -205,7 +230,7 @@ final class TabViewModel { return tabURL?.root } - func updateAddressBarStrings() { + private func updateAddressBarStrings() { guard tab.content.isUrl, let url = tabURL else { addressBarString = "" passiveAddressBarString = "" diff --git a/IntegrationTests/Tab/AddressBarTests.swift b/IntegrationTests/Tab/AddressBarTests.swift new file mode 100644 index 0000000000..6494b2c3d5 --- /dev/null +++ b/IntegrationTests/Tab/AddressBarTests.swift @@ -0,0 +1,783 @@ +// +// AddressBarTests.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 Carbon +import Combine +import Foundation +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +@available(macOS 12.0, *) +@MainActor +class AddressBarTests: XCTestCase { + + var window: MainWindow! + + var mainViewController: MainViewController { + (window.contentViewController as! MainViewController) + } + + var tabViewModel: TabViewModel { + mainViewController.browserTabViewController.tabViewModel! + } + + var isAddressBarFirstResponder: Bool { + mainViewController.navigationBarViewController.addressBarViewController!.addressBarButtonsViewController!.isTextFieldEditorFirstResponder + } + + var addressBarValue: String { + mainViewController.navigationBarViewController.addressBarViewController!.addressBarButtonsViewController!.textFieldValue?.string ?? "" + } + + var addressBarTextField: AddressBarTextField { + mainViewController.navigationBarViewController.addressBarViewController!.addressBarTextField + } + + var webViewConfiguration: WKWebViewConfiguration! + var schemeHandler: TestSchemeHandler! + static let testHtml = "Titletest" + + @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) + + schemeHandler.middleware = [{ _ in + return .ok(.html(Self.testHtml)) + }] + + StartupPreferences.shared.customHomePageURL = URL.duckDuckGo.absoluteString + StartupPreferences.shared.launchToCustomHomePage = false + + WindowControllersManager.shared.pinnedTabsManager.setUp(with: .init()) + } + + @MainActor + override func tearDown() async throws { + window?.close() + window = nil + + webViewConfiguration = nil + schemeHandler = nil + WKWebView.customHandlerSchemes = [] + + NSError.disableSwizzledDescription = false + StartupPreferences.shared.launchToCustomHomePage = false + } + + let asciiToCGEventMap: [String: UInt16] = [ + "a": 0, "b": 11, "c": 8, "d": 2, "e": 14, "f": 3, "g": 5, "h": 4, "i": 34, "j": 38, "k": 40, + "l": 37, "m": 46, "n": 45, "o": 31, "p": 35, "q": 12, "r": 15, "s": 1, "t": 17, "u": 32, "v": 9, + "w": 13, "x": 7, "y": 16, "z": 6, "0": 29, "1": 18, "2": 19, "3": 20, "4": 21, "5": 23, "6": 22, + "7": 26, "8": 28, "9": 25, "-": 27, ":": UInt16(kVK_ANSI_Semicolon), "\r": 36, + "/": 44, ".": 47, "\u{1b}": UInt16(kVK_Escape), + ] + + func type(_ value: String, global: Bool = false) { + for character in value { + let str = String(character) + let code = asciiToCGEventMap[str]! + + let keyDown = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: str, charactersIgnoringModifiers: str, isARepeat: false, keyCode: code)! + let keyUp = NSEvent.keyEvent(with: .keyUp, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: str, charactersIgnoringModifiers: str, isARepeat: false, keyCode: code)! + + if global { + NSApp.sendEvent(keyDown) + NSApp.sendEvent(keyUp) + } else { + window.sendEvent(keyDown) + window.sendEvent(keyUp) + } + } + } + + func click(_ view: NSView) { + let point = view.convert(view.bounds.center, to: nil) + let mouseDown = NSEvent.mouseEvent(with: .leftMouseDown, location: point, modifierFlags: [], timestamp: 0, windowNumber: view.window?.windowNumber ?? 0, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)! + let mouseUp = NSEvent.mouseEvent(with: .leftMouseUp, location: point, modifierFlags: [], timestamp: 0, windowNumber: view.window?.windowNumber ?? 0, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)! + + window.sendEvent(mouseDown) + window.sendEvent(mouseUp) + } + + // MARK: - Tests + + func testWhenUserStartsTypingOnNewTabPageLoad_userInputIsNotReset() async throws { + // open Tab with newtab page + let tab = Tab(content: .newtab) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + + var isNavigationFinished = false + var c: AnyCancellable! + c = tab.webViewDidFinishNavigationPublisher.timeout(5).sink { completion in + if case .failure(let error) = completion { + XCTFail("\(error)") + } + isNavigationFinished = true + } receiveValue: { _ in + isNavigationFinished = true + c.cancel() + } + + window = WindowsManager.openNewWindow(with: viewModel)! + + // start typing quickly before the browser window appears; + // validate when the new tab is displayed, all the entered text is there and not being selected or reset + var currentCharIdx = Character("a").unicodeScalars.first!.value + var resultingString = "" + func typeNext() { + let str = String(UnicodeScalar(UInt8(currentCharIdx))) + type(str) + currentCharIdx += 1 + if currentCharIdx > Character("z").unicodeScalars.first!.value { + currentCharIdx = Character("a").unicodeScalars.first!.value + } + resultingString += str + } + + while !isNavigationFinished { + typeNext() + try await Task.sleep(interval: 0.01) + } + withExtendedLifetime(c) {} + + // send some more characters after navigation finishes + for _ in 0..<5 { + typeNext() + try await Task.sleep(interval: 0.01) + } + + XCTAssertEqual(addressBarValue, resultingString) + + } + + func testWhenSwitchingBetweenTabs_addressBarFocusStateIsCorrect() async throws { + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [ + Tab(content: .newtab), + Tab(content: .settings(pane: .about)), + Tab(content: .url(.duckDuckGo, credential: nil, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration), + Tab(content: .bookmarks), + Tab(content: .newtab), + Tab(content: .url(.duckDuckGo, credential: nil, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration), + Tab(content: .url(.duckDuckGo, credential: nil, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration), + Tab(content: .newtab), + Tab(content: .newtab), + Tab(content: .url(.duckDuckGo, credential: nil, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration), + ])) + AppearancePreferences.shared.showFullURL = true + window = WindowsManager.openNewWindow(with: viewModel)! + + // Switch between loaded tabs and home tab/settings/bookmarks, validate the Address Bar gets activated on the New Tab; Validate privacy entry button/icon is correct + for (idx, tab) in viewModel.tabs.enumerated() { + viewModel.select(tab: tab) + try await Task.sleep(interval: 0.01) + if tab.content == .newtab { + XCTAssertTrue(isAddressBarFirstResponder, "\(idx)") + XCTAssertEqual(addressBarValue, "", "\(idx)") + } else { + XCTAssertFalse(isAddressBarFirstResponder, "\(idx)") + XCTAssertEqual(addressBarValue, tab.content.isUrl ? tab.content.url!.absoluteString : "", "\(idx)") + } + } + } + + func testWhenRestoringToOnboarding_addressBarIsNotActive() async throws { + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [Tab(content: .onboarding)])) + window = WindowsManager.openNewWindow(with: viewModel)! + XCTAssertEqual(window.firstResponder, mainViewController.browserTabViewController.transientTabContentViewController!.view) + } + + func testWhenRestoringToSettings_addressBarIsNotActive() async throws { + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [Tab(content: .settings(pane: .appearance))])) + window = WindowsManager.openNewWindow(with: viewModel)! + XCTAssertEqual(window.firstResponder, mainViewController.browserTabViewController.preferencesViewController!.view) + } + + func testWhenRestoringToBookmarks_addressBarIsNotActive() async throws { + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [Tab(content: .bookmarks)])) + window = WindowsManager.openNewWindow(with: viewModel)! + XCTAssertEqual(window.firstResponder, mainViewController.browserTabViewController.bookmarksViewController!.view) + } + + func testWhenRestoringToURL_addressBarIsNotActive() async throws { + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .loadedByStateRestoration), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenRestoringToNewTab_addressBarIsActive() async throws { + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [Tab(content: .newtab)])) + window = WindowsManager.openNewWindow(with: viewModel)! + XCTAssertTrue(isAddressBarFirstResponder) + } + + func testWhenOpeningNewTab_addressBarIsActivated() async throws { + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .loadedByStateRestoration), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + XCTAssertEqual(window.firstResponder, tab.webView) + + viewModel.append(tab: Tab(content: .newtab), selected: true) + try await Task.sleep(interval: 0.01) + XCTAssertTrue(isAddressBarFirstResponder) + + viewModel.append(tab: Tab(content: .newtab), selected: true) + try await Task.sleep(interval: 0.01) + XCTAssertTrue(isAddressBarFirstResponder) + + viewModel.remove(at: .unpinned(2)) + try await Task.sleep(interval: 0.01) + XCTAssertTrue(isAddressBarFirstResponder) + + viewModel.remove(at: .unpinned(1)) + try await Task.sleep(interval: 0.01) + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenSwitchingBetweenTabsWithTypedValue_typedValueIsPreserved() async throws { + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [ + Tab(content: .newtab), + Tab(content: .settings(pane: .about)), + Tab(content: .url(.duckDuckGo, credential: nil, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration), + Tab(content: .bookmarks), + Tab(content: .newtab), + Tab(content: .url(.duckDuckGo, credential: nil, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration), + Tab(content: .url(.duckDuckGo, credential: nil, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration), + Tab(content: .newtab), + Tab(content: .newtab), + Tab(content: .url(.duckDuckGo, credential: nil, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration), + ])) + window = WindowsManager.openNewWindow(with: viewModel)! + + // Enter something, switch to another tab, enter something, return back, validate the input is preserved, return to tab 2, validate its input is preserved + for (idx, tab) in viewModel.tabs.enumerated() { + viewModel.select(tab: tab) + try await Task.sleep(interval: 0.01) + + if !isAddressBarFirstResponder { + _=window.makeFirstResponder(addressBarTextField) + } + + type("tab-\(idx)") + } + for (idx, tab) in viewModel.tabs.enumerated() { + viewModel.select(tab: tab) + try await Task.sleep(interval: 0.01) + XCTAssertEqual(addressBarValue, "tab-\(idx)") + if tab.content == .newtab { + XCTAssertTrue(isAddressBarFirstResponder, "\(idx)") + } else { + XCTAssertFalse(isAddressBarFirstResponder, "\(idx)") + } + } + } + + func testWhenSwitchingBetweenURLTabs_addressBarIsDeactivated() async throws { + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [ + Tab(content: .url(.duckDuckGo, credential: nil, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration), + Tab(content: .url(.duckDuckGo, credential: nil, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration), + ])) + window = WindowsManager.openNewWindow(with: viewModel)! + + // open 2 tabs, navigate both somewhere, activate the address bar, switch to another tab - validate the address bar is deactivated + XCTAssertEqual(window.firstResponder, viewModel.tabs[0].webView) + _=window.makeFirstResponder(addressBarTextField) + + viewModel.select(at: .unpinned(1)) + try await Task.sleep(interval: 0.01) + XCTAssertEqual(window.firstResponder, viewModel.tabs[1].webView) + + _=window.makeFirstResponder(addressBarTextField) + viewModel.select(at: .unpinned(0)) + XCTAssertEqual(window.firstResponder, viewModel.tabs[0].webView) + } + + func testWhenDeactivatingAddressBar_webViewShouldBecomeFirstResponder() async throws { + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .pendingStateRestoration), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + + XCTAssertEqual(window.firstResponder, viewModel.tabs[0].webView) + _=window.makeFirstResponder(addressBarTextField) + try await Task.sleep(interval: 0.01) + + type("\u{1b}", global: true) // escape + + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenGoingBack_addressBarIsDeactivated() async throws { + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .loadedByStateRestoration), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + + try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + + try await tab.setContent(.url(.makeSearchUrl(from: "cats")!, credential: nil, source: .bookmark))?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + + _=window.makeFirstResponder(addressBarTextField) + try await Task.sleep(interval: 0.01) + try await tab.goBack()?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + + _=window.makeFirstResponder(addressBarTextField) + try await Task.sleep(interval: 0.01) + try await tab.goForward()?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + + _=window.makeFirstResponder(addressBarTextField) + try await Task.sleep(interval: 0.01) + try await tab.goBack()?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenGoingBackToNewtabPage_addressBarIsActivated() async throws { + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + + try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + XCTAssertTrue(isAddressBarFirstResponder) + + try await tab.setContent(.url(.makeSearchUrl(from: "cats")!, credential: nil, source: .bookmark))?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + + try await tab.goBack()?.result.get() + XCTAssertTrue(isAddressBarFirstResponder) + + // text field value shouldn‘t change to a url before it resigns first responder + var observer: Any? = addressBarTextField.observe(\.stringValue) { addressBarTextField, _ in + DispatchQueue.main.async { + if addressBarTextField.isFirstResponder { + XCTAssertEqual(addressBarTextField.stringValue, "") + } + } + } + + try await tab.goForward()?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + withExtendedLifetime(observer) {} + observer = nil + + _=window.makeFirstResponder(addressBarTextField) + try await Task.sleep(interval: 0.01) + try await tab.goBack()?.result.get() + XCTAssertTrue(isAddressBarFirstResponder) + + try await tab.goForward()?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenGoingBackToNewtabPageFromSettings_addressBarIsActivated() async throws { + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + + try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + + try await tab.setContent(.settings(pane: .general))?.result.get() + XCTAssertEqual(window.firstResponder, mainViewController.browserTabViewController.preferencesViewController!.view) + + try await tab.goBack()?.result.get() + XCTAssertTrue(isAddressBarFirstResponder) + + try await tab.goForward()?.result.get() + XCTAssertEqual(window.firstResponder, mainViewController.browserTabViewController.preferencesViewController!.view) + + _=window.makeFirstResponder(addressBarTextField) + try await Task.sleep(interval: 0.01) + try await tab.goBack()?.result.get() + XCTAssertTrue(isAddressBarFirstResponder) + + try await tab.goForward()?.result.get() + XCTAssertEqual(window.firstResponder, mainViewController.browserTabViewController.preferencesViewController!.view) + } + + func testWhenGoingBackToNewtabPageFromBookmarks_addressBarIsActivated() async throws { + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + + try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + + try await tab.setContent(.bookmarks)?.result.get() + XCTAssertEqual(window.firstResponder, mainViewController.browserTabViewController.bookmarksViewController!.view) + + try await tab.goBack()?.result.get() + XCTAssertTrue(isAddressBarFirstResponder) + + try await tab.goForward()?.result.get() + XCTAssertEqual(window.firstResponder, mainViewController.browserTabViewController.bookmarksViewController!.view) + + _=window.makeFirstResponder(addressBarTextField) + try await Task.sleep(interval: 0.01) + try await tab.goBack()?.result.get() + XCTAssertTrue(isAddressBarFirstResponder) + + try await tab.goForward()?.result.get() + XCTAssertEqual(window.firstResponder, mainViewController.browserTabViewController.bookmarksViewController!.view) + } + + func testWhenTabReloaded_addressBarIsDeactivated() async throws { + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .webViewUpdated), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + + try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + XCTAssertEqual(window.firstResponder, tab.webView) + + try await tab.setContent(.url(.makeSearchUrl(from: "cats")!, credential: nil, source: .bookmark))?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + + _=window.makeFirstResponder(addressBarTextField) + try await Task.sleep(interval: 0.01) + try await tab.reload()?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenReloadingFailingPage_addressBarIsDeactivated() async throws { + // first navigation should fail + schemeHandler.middleware = [{ _ in + return .failure(URLError(.notConnectedToInternet)) + }] + + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .webViewUpdated), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + + try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + XCTAssertEqual(window.firstResponder, tab.webView) + + // activate address bar reload the page + schemeHandler.middleware = [{ _ in + return .ok(.html(Self.testHtml)) + }] + _=window.makeFirstResponder(addressBarTextField) + try await Task.sleep(interval: 0.01) + try await tab.reload()?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenTabReloadedBySubmittingSameAddressAndAddressIsActivated_addressBarIsKeptActiveOnPageLoad() async throws { + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .userEntered("")), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + _=try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + XCTAssertEqual(window.firstResponder, tab.webView) + + // activate address bar, trigger reload by sending Return key and re-activate the address bar - it should be kept active + let didFinishNavigation = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + _=window.makeFirstResponder(addressBarTextField) + type("\r") + try await Task.sleep(interval: 0.01) + _=window.makeFirstResponder(addressBarTextField) + type("some-text") + + try await Task.sleep(interval: 0.01) + try await didFinishNavigation.value + XCTAssertTrue(isAddressBarFirstResponder) + XCTAssertEqual(addressBarValue, "some-text") + } + + func testWhenEditingSerpURL_serpIconIsDisplayed() async throws { + let tab = Tab(content: .url(.makeSearchUrl(from: "catz")!, credential: nil, source: .userEntered("catz")), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + _=try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + + _=window.makeFirstResponder(addressBarTextField) + +// try await Task.sleep(interval: 60.01) + } + + func testWhenOpeningBookmark_addressBarIsDeactivated() async throws { + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .webViewUpdated), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + XCTAssertEqual(window.firstResponder, tab.webView) + + _=window.makeFirstResponder(addressBarTextField) + type("some-text") + + try await tab.setContent(.url(.makeSearchUrl(from: "cats")!, credential: nil, source: .bookmark))?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenOpeningHistoryEntry_addressBarIsDeactivated() async throws { + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .webViewUpdated), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + XCTAssertEqual(window.firstResponder, tab.webView) + + _=window.makeFirstResponder(addressBarTextField) + type("some-text") + + try await tab.setContent(.url(.makeSearchUrl(from: "cats")!, credential: nil, source: .historyEntry))?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenOpeningURLfromUI_addressBarIsDeactivated() async throws { + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .webViewUpdated), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + XCTAssertEqual(window.firstResponder, tab.webView) + + _=window.makeFirstResponder(addressBarTextField) + type("some-text") + + try await tab.setContent(.url(.makeSearchUrl(from: "cats")!, credential: nil, source: .ui))?.result.get() + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenHomePageIsOpened_addressBarIsDeactivated() async throws { + StartupPreferences.shared.launchToCustomHomePage = true + + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .webViewUpdated), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + + try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + XCTAssertEqual(window.firstResponder, tab.webView) + _=window.makeFirstResponder(addressBarTextField) + try await Task.sleep(interval: 0.01) + + let didFinishNavigation = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + tab.openHomePage() + + try await didFinishNavigation.value + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenAddressSubmitted_addressBarIsDeactivated() async throws { + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + _=try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + + let didFinishNavigation = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + type(URL.duckDuckGo.absoluteString + "\r") + + try await didFinishNavigation.value + XCTAssertEqual(window.firstResponder, tab.webView) + } + + func testWhenAddressSubmittedAndAddressBarIsReactivated_addressBarIsKeptActiveOnPageLoad() async throws { + let tab = Tab(content: .newtab, webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + _=try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + + let didFinishNavigation = tab.webViewDidFinishNavigationPublisher.timeout(50).first().promise() + type(URL.duckDuckGo.absoluteString + "\r") + + try await Task.sleep(interval: 0.01) + _=window.makeFirstResponder(addressBarTextField) + type("some-text") + + try await Task.sleep(interval: 0.01) + try await didFinishNavigation.value + XCTAssertTrue(isAddressBarFirstResponder) + XCTAssertEqual(addressBarValue, "some-text") + } + + func testWhenPageRedirected_addressBarStaysActivePreservingUserInput() async throws { + let expectation = expectation(description: "request sent") + schemeHandler.middleware = [{ request in + if request.url == .duckDuckGo { + expectation.fulfill() + return .ok(.html(""" + + + page 1 + + """)) + } else { + return .ok(.html(""" + + redirected page + + """)) + } + }] + + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .userEntered("")), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + XCTAssertEqual(addressBarValue, URL.duckDuckGo.absoluteString) + let page1loadedPromise = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + // start typing in the address bar while loading; page is redirected to another page on load - address bar should preserve user input and stay active + await fulfillment(of: [expectation], timeout: 5) + _=window.makeFirstResponder(addressBarTextField) + type("replacement-url") + _=try await page1loadedPromise.value + + // await for 2nd navigation to finish + _=try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + + XCTAssertTrue(isAddressBarFirstResponder) + XCTAssertEqual(addressBarValue, "replacement-url") + } + + func testWhenPageRedirectedWhenAddressBarIsInactive_addressBarShouldReset() async throws { + AppearancePreferences.shared.showFullURL = true + + let expectation = expectation(description: "request sent") + schemeHandler.middleware = [{ request in + if request.url == .duckDuckGo { + expectation.fulfill() + return .ok(.html(""" + + + page 1 + + """)) + } else { + return .ok(.html(""" + + redirected page + + """)) + } + }] + + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .userEntered("")), webViewConfiguration: webViewConfiguration) + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [tab])) + window = WindowsManager.openNewWindow(with: viewModel)! + let page1loadedPromise = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + + // start typing in the address bar while loading and deactivate the address bar + // page is redirected to another page on load - address bar should reset user input + await fulfillment(of: [expectation], timeout: 5) + _=window.makeFirstResponder(addressBarTextField) + type("replacement-url") + click(tab.webView) + _=try await page1loadedPromise.value + + // await for 2nd navigation to finish + _=try await tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise().value + + XCTAssertEqual(window.firstResponder, tab.webView) + XCTAssertEqual(addressBarValue, "https://redirected.com/") + } + + func testWhenActivatingWindowWithPinnedTabOpen_webViewBecomesFirstResponder() async throws { + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .userEntered("")), webViewConfiguration: webViewConfiguration) + WindowControllersManager.shared.pinnedTabsManager.setUp(with: TabCollection(tabs: [tab])) + + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [Tab(content: .newtab)])) + let tabLoadedPromise = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + window = WindowsManager.openNewWindow(with: viewModel)! + viewModel.select(at: .pinned(0)) + _=try await tabLoadedPromise.value + + XCTAssertEqual(window.firstResponder, tab.webView) + + let viewModel2 = TabCollectionViewModel(tabCollection: TabCollection(tabs: [Tab(content: .newtab)])) + let window2 = WindowsManager.openNewWindow(with: viewModel2)! + defer { + window2.close() + } + + // when activaing a Pinned Tab in another window its Web View should become the first responder + viewModel2.select(at: .pinned(0)) + try await Task.sleep(interval: 0.01) + XCTAssertEqual(window.firstResponder, window) + XCTAssertEqual(window2.firstResponder, tab.webView) + + window.makeKeyAndOrderFront(nil) + try await Task.sleep(interval: 0.01) + + XCTAssertEqual(window.firstResponder, tab.webView) + XCTAssertEqual(window2.firstResponder, window2) + } + + func testWhenActivatingWindowWithPinnedTabWhenAddressBarIsActive_addressBarIsKeptActive() async throws { + let tab = Tab(content: .url(.duckDuckGo, credential: nil, source: .userEntered("")), webViewConfiguration: webViewConfiguration) + WindowControllersManager.shared.pinnedTabsManager.setUp(with: TabCollection(tabs: [tab])) + + let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: [Tab(content: .newtab)])) + let tabLoadedPromise = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() + window = WindowsManager.openNewWindow(with: viewModel)! + viewModel.select(at: .pinned(0)) + _=try await tabLoadedPromise.value + + XCTAssertEqual(window.firstResponder, tab.webView) + + let viewModel2 = TabCollectionViewModel(tabCollection: TabCollection(tabs: [Tab(content: .newtab)])) + let window2 = WindowsManager.openNewWindow(with: viewModel2)! + defer { + window2.close() + } + + viewModel2.select(at: .pinned(0)) + try await Task.sleep(interval: 0.01) + XCTAssertEqual(window.firstResponder, window) + XCTAssertEqual(window2.firstResponder, tab.webView) + + // when activaing a Pinned Tab in another window when its Address Bar is active, it should be kept active + _=window.makeFirstResponder(addressBarTextField) + window.makeKeyAndOrderFront(nil) + try await Task.sleep(interval: 0.01) + + XCTAssertTrue(isAddressBarFirstResponder) + XCTAssertEqual(window2.firstResponder, window2) + } + +} + +protocol MainActorPerformer { + func perform(_ closure: @MainActor () -> Void) +} +struct OnMainActor: MainActorPerformer { + private init() {} + + static func instance() -> MainActorPerformer { OnMainActor() } + + @MainActor(unsafe) + func perform(_ closure: @MainActor () -> Void) { + closure() + } +} diff --git a/IntegrationTests/Tab/ErrorPageTests.swift b/IntegrationTests/Tab/ErrorPageTests.swift index 3b8e4134ad..47b1641089 100644 --- a/IntegrationTests/Tab/ErrorPageTests.swift +++ b/IntegrationTests/Tab/ErrorPageTests.swift @@ -466,7 +466,7 @@ class ErrorPageTests: XCTestCase { XCTAssertEqual(errorDescr?.trimmingWhitespace(), NSError.connectionLost.localizedDescription) XCTAssertEqual(tab.currentHistoryItem?.url, .test) - XCTAssertEqual(tab.currentHistoryItem?.title, URL.test.host) + XCTAssertEqual(tab.currentHistoryItem?.title, Self.pageTitle) XCTAssertEqual(tab.backHistoryItems.count, 1) XCTAssertEqual(tab.backHistoryItems.first?.url, .newtab, "url") @@ -608,7 +608,7 @@ class ErrorPageTests: XCTestCase { XCTAssertEqual(errorDescr?.trimmingWhitespace(), NSError.connectionLost.localizedDescription) XCTAssertEqual(tab.currentHistoryItem?.url, .test) - XCTAssertEqual(tab.currentHistoryItem?.title, URL.test.host) + XCTAssertEqual(tab.currentHistoryItem?.title, Self.pageTitle) XCTAssertEqual(tab.backHistoryItems.count, 1) XCTAssertEqual(tab.backHistoryItems.first?.url, .newtab, "url") @@ -809,24 +809,28 @@ class ErrorPageTests: XCTestCase { func testPinnedTabDoesNotNavigateAway() async throws { schemeHandler.middleware = [{ _ in - .ok(.html(Self.testHtml)) + return .ok(.html(Self.testHtml)) }] let tab = Tab(content: .url(.alternative, source: .ui), webViewConfiguration: webViewConfiguration) + let eNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() let manager = PinnedTabsManager() manager.pin(tab) let viewModel = TabCollectionViewModel(tabCollection: TabCollection(tabs: []), pinnedTabsManager: manager) window = WindowsManager.openNewWindow(with: viewModel)! viewModel.select(at: .pinned(0)) + let webViewShownPromise = tab.webView.publisher(for: \.superview).compactMap { $0 }.timeout(5).first().promise() // wait for tab to load - let eNavigationFinished = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() _=try await eNavigationFinished.value + _=try await webViewShownPromise.value // refresh: fail + let failureExpectation = expectation(description: "request failed") schemeHandler.middleware = [{ _ in - .failure(NSError.noConnection) + failureExpectation.fulfill() + return .failure(NSError.noConnection) }] let eNavigationFailed = tab.$error.compactMap { $0 }.timeout(5).first().promise() let eNavigationFinished2 = tab.webViewDidFinishNavigationPublisher.timeout(5).first().promise() @@ -834,6 +838,7 @@ class ErrorPageTests: XCTestCase { tab.reload() _=try await eNavigationFailed.value _=try await eNavigationFinished2.value + await fulfillment(of: [failureExpectation], timeout: 5) XCTAssertNotNil(tab.error) diff --git a/UITests/Info.plist b/UITests/Info.plist deleted file mode 100644 index 64d65ca495..0000000000 --- a/UITests/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/UnitTests/Tab/ViewModel/TabViewModelTests.swift b/UnitTests/Tab/ViewModel/TabViewModelTests.swift index 9d14bf585a..29c334cd45 100644 --- a/UnitTests/Tab/ViewModel/TabViewModelTests.swift +++ b/UnitTests/Tab/ViewModel/TabViewModelTests.swift @@ -237,7 +237,7 @@ extension TabViewModel { } func simulateLoadingCompletion() { - self.updateAddressBarStrings() + self.tab.webViewDidCommitNavigationPublisher.send(()) } }