From aa4445ba2d89a71c712d334e1698a97304cfe892 Mon Sep 17 00:00:00 2001 From: Carson Ramsden Date: Mon, 3 Feb 2025 10:19:54 -0700 Subject: [PATCH] Refactor FXIOS-11147 [Tabs] Remove Legacy Tab Manager Part 1 (#24436) * V1 - remove legacy tab manager and move existing functionality into tab manager * reorder * fix linting error * clean up * Rename TabManagerDelegate and move out of Legacy code * clean up todo --- firefox-ios/Client.xcodeproj/project.pbxproj | 8 +- .../Browser/ClipboardBarDisplayHandler.swift | 2 +- .../SettingsContentViewController.swift | 2 +- .../Legacy/LegacyTabManager.swift | 1066 ----------------- .../Client/TabManagement/TabManager.swift | 21 - .../TabManagement/TabManagerDelegate.swift | 54 + .../TabManagerImplementation.swift | 828 ++++++++++++- .../ClientTests/Mocks/MockTabManager.swift | 4 - .../TabManagement/TabManagerTests.swift | 56 +- 9 files changed, 860 insertions(+), 1181 deletions(-) delete mode 100644 firefox-ios/Client/TabManagement/Legacy/LegacyTabManager.swift create mode 100644 firefox-ios/Client/TabManagement/TabManagerDelegate.swift diff --git a/firefox-ios/Client.xcodeproj/project.pbxproj b/firefox-ios/Client.xcodeproj/project.pbxproj index 4aa4a735c511..c9057c8e2758 100644 --- a/firefox-ios/Client.xcodeproj/project.pbxproj +++ b/firefox-ios/Client.xcodeproj/project.pbxproj @@ -566,7 +566,7 @@ 5A475E9129DB8AA7009C13FD /* MockDiskImageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A475E9029DB8AA7009C13FD /* MockDiskImageStore.swift */; }; 5A47CFF52860FB8900B2B7BF /* AppLaunchUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A47CFF42860FB8900B2B7BF /* AppLaunchUtil.swift */; }; 5A5AB980296CA03500485E06 /* SiteImageView in Frameworks */ = {isa = PBXBuildFile; productRef = 5A5AB97F296CA03500485E06 /* SiteImageView */; }; - 5A64225129CB506500EEC3E5 /* LegacyTabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A64225029CB506500EEC3E5 /* LegacyTabManager.swift */; }; + 5A64225129CB506500EEC3E5 /* TabManagerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A64225029CB506500EEC3E5 /* TabManagerDelegate.swift */; }; 5A679E4B2B239FAE004F2B0D /* TabPeekViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A679E4A2B239FAE004F2B0D /* TabPeekViewController.swift */; }; 5A68F0AB2AF2E5E00089AC62 /* TabDataStore in Frameworks */ = {isa = PBXBuildFile; productRef = 5A68F0AA2AF2E5E00089AC62 /* TabDataStore */; }; 5A70EF0E295DFCCF00790249 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 5A70EF0D295DFCCF00790249 /* Common */; }; @@ -7141,7 +7141,7 @@ 5A475E8C29DB888E009C13FD /* MockTabDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTabDataStore.swift; sourceTree = ""; }; 5A475E9029DB8AA7009C13FD /* MockDiskImageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDiskImageStore.swift; sourceTree = ""; }; 5A47CFF42860FB8900B2B7BF /* AppLaunchUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLaunchUtil.swift; sourceTree = ""; }; - 5A64225029CB506500EEC3E5 /* LegacyTabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyTabManager.swift; sourceTree = ""; }; + 5A64225029CB506500EEC3E5 /* TabManagerDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManagerDelegate.swift; sourceTree = ""; }; 5A679E4A2B239FAE004F2B0D /* TabPeekViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPeekViewController.swift; sourceTree = ""; }; 5A70EF18295E2E1600790249 /* DependencyHelperMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyHelperMock.swift; sourceTree = ""; }; 5A70EF1C295E3C3500790249 /* TestSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSetup.swift; sourceTree = ""; }; @@ -11216,7 +11216,6 @@ 8A03309428C2653600286539 /* LegacyTabFileManager.swift */, 215B457E27D7FD4B00E5E800 /* LegacyTabGroupData.swift */, 215B458127DA420400E5E800 /* LegacyTabMetadataManager.swift */, - 5A64225029CB506500EEC3E5 /* LegacyTabManager.swift */, ); path = Legacy; sourceTree = ""; @@ -11835,6 +11834,7 @@ children = ( 5A9FFB3629C0F99C001793A0 /* Legacy */, D3A994961A3686BD008AD1AC /* Tab.swift */, + 5A64225029CB506500EEC3E5 /* TabManagerDelegate.swift */, C82CDD45233E8996002E2743 /* Tab+ChangeUserAgent.swift */, 39455F761FC83F430088A22C /* TabEventHandler.swift */, 39F819C51FD70F5D009E31E4 /* GlobalTabEventHandlers.swift */, @@ -16830,7 +16830,7 @@ 2F44FCC71A9E8CF500FD20CC /* SearchSettingsTableViewController.swift in Sources */, 0AFF7F682C78989000265214 /* CertificatesHeaderItem.swift in Sources */, B2999FF32B194A8300F0FEC1 /* FillCreditCardForm.swift in Sources */, - 5A64225129CB506500EEC3E5 /* LegacyTabManager.swift in Sources */, + 5A64225129CB506500EEC3E5 /* TabManagerDelegate.swift in Sources */, 8A93F86029D36EBD004159D9 /* Router.swift in Sources */, C8417D222657F0600010B877 /* LibraryViewModel.swift in Sources */, 219935E92B070F9000E5966F /* TabPanelAction.swift in Sources */, diff --git a/firefox-ios/Client/Frontend/Browser/ClipboardBarDisplayHandler.swift b/firefox-ios/Client/Frontend/Browser/ClipboardBarDisplayHandler.swift index fb137b465a2c..31f3e95a7a7c 100644 --- a/firefox-ios/Client/Frontend/Browser/ClipboardBarDisplayHandler.swift +++ b/firefox-ios/Client/Frontend/Browser/ClipboardBarDisplayHandler.swift @@ -102,7 +102,7 @@ class ClipboardBarDisplayHandler: NSObject { } if let url = URL(string: clipboardURL, invalidCharacters: false), - tabManager?.getTabFor(url) != nil { + tabManager?.getTabForURL(url) != nil { return true } diff --git a/firefox-ios/Client/Frontend/Settings/SettingsContentViewController.swift b/firefox-ios/Client/Frontend/Settings/SettingsContentViewController.swift index 45523344eaec..b59e6fca555f 100644 --- a/firefox-ios/Client/Frontend/Settings/SettingsContentViewController.swift +++ b/firefox-ios/Client/Frontend/Settings/SettingsContentViewController.swift @@ -137,7 +137,7 @@ class SettingsContentViewController: UIViewController, WKNavigationDelegate, The } func makeWebView() -> WKWebView { - let config = LegacyTabManager.makeWebViewConfig(isPrivate: true, prefs: nil) + let config = TabManagerImplementation.makeWebViewConfig(isPrivate: true, prefs: nil) config.preferences.javaScriptCanOpenWindowsAutomatically = false let webView = WKWebView(frame: .zero, configuration: config) diff --git a/firefox-ios/Client/TabManagement/Legacy/LegacyTabManager.swift b/firefox-ios/Client/TabManagement/Legacy/LegacyTabManager.swift deleted file mode 100644 index 67948946dc0a..000000000000 --- a/firefox-ios/Client/TabManagement/Legacy/LegacyTabManager.swift +++ /dev/null @@ -1,1066 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/ - -import Common -import Foundation -import WebKit -import Storage -import Shared - -// MARK: - TabManagerDelegate -protocol TabManagerDelegate: AnyObject { - // Must be called on the main thread - func tabManager(_ tabManager: TabManager, didSelectedTabChange selectedTab: Tab, previousTab: Tab?, isRestoring: Bool) - func tabManager(_ tabManager: TabManager, didAddTab tab: Tab, placeNextToParentTab: Bool, isRestoring: Bool) - func tabManager(_ tabManager: TabManager, didRemoveTab tab: Tab, isRestoring: Bool) - - func tabManagerDidRestoreTabs(_ tabManager: TabManager) - func tabManagerDidAddTabs(_ tabManager: TabManager) - func tabManagerDidRemoveAllTabs(_ tabManager: TabManager, toast: ButtonToast?) - func tabManagerUpdateCount() -} - -extension TabManagerDelegate { - func tabManager(_ tabManager: TabManager, didSelectedTabChange selectedTab: Tab, previousTab: Tab?, isRestoring: Bool) {} - func tabManager(_ tabManager: TabManager, didAddTab tab: Tab, placeNextToParentTab: Bool, isRestoring: Bool) {} - func tabManager(_ tabManager: TabManager, didRemoveTab tab: Tab, isRestoring: Bool) {} - - func tabManagerDidRestoreTabs(_ tabManager: TabManager) {} - func tabManagerDidAddTabs(_ tabManager: TabManager) {} - func tabManagerDidRemoveAllTabs(_ tabManager: TabManager, toast: ButtonToast?) {} - func tabManagerUpdateCount() {} -} - -// MARK: - WeakTabManagerDelegate -// We can't use a WeakList here because this is a protocol. -class WeakTabManagerDelegate: CustomDebugStringConvertible { - weak var value: TabManagerDelegate? - - init(value: TabManagerDelegate) { - self.value = value - } - - func get() -> TabManagerDelegate? { - return value - } - - var debugDescription: String { - let className = String(describing: type(of: self)) - let memAddr = Unmanaged.passUnretained(self).toOpaque() - let valueStr = (value == nil ? "" : "\(value!)") - return "<\(className): \(memAddr)> Value: \(valueStr)" - } -} - -enum SwitchPrivacyModeResult { - case createdNewTab - case usedExistingTab -} - -struct BackupCloseTab { - var tab: Tab - var restorePosition: Int? - var isSelected: Bool -} - -// TabManager must extend NSObjectProtocol in order to implement WKNavigationDelegate -class LegacyTabManager: NSObject, FeatureFlaggable, TabManager, TabEventHandler { - // MARK: - Variables - let profile: Profile - let windowUUID: WindowUUID - var tabEventWindowResponseType: TabEventHandlerWindowResponseType { return .singleWindow(windowUUID) } - var isRestoringTabs = false - var tabRestoreHasFinished = false - var tabs = [Tab]() - var _selectedIndex = -1 - var selectedIndex: Int { return _selectedIndex } - let logger: Logger - var backupCloseTab: BackupCloseTab? - var backupCloseTabs = [Tab]() - - let delaySelectingNewPopupTab: TimeInterval = 0.1 - - var normalTabs: [Tab] { - return tabs.filter { !$0.isPrivate } - } - - var normalActiveTabs: [Tab] { - if isInactiveTabsEnabled { - return normalTabs.filter({ $0.isActive }) - } else { - return normalTabs - } - } - - var inactiveTabs: [Tab] { - return normalTabs.filter({ $0.isInactive }) - } - - var privateTabs: [Tab] { - return tabs.filter { $0.isPrivate } - } - - var isInactiveTabsEnabled: Bool { - return featureFlags.isFeatureEnabled(.inactiveTabs, checking: .buildAndUser) - } - - /// This variable returns all normal tabs, sorted chronologically, excluding any home page tabs. - var recentlyAccessedNormalTabs: [Tab] { - // If inactive tabs are enabled, do not include inactive tabs, as they are not "recently" accessed - var eligibleTabs = isInactiveTabsEnabled ? normalActiveTabs : normalTabs - - eligibleTabs = eligibleTabs.filter { tab in - if tab.lastKnownUrl == nil { - return false - } else if let lastKnownUrl = tab.lastKnownUrl { - if lastKnownUrl.absoluteString.hasPrefix("internal://") { return false } - return true - } - return tab.isURLStartingPage - } - - eligibleTabs = SponsoredContentFilterUtility().filterSponsoredTabs(from: eligibleTabs) - - // sort the tabs chronologically - eligibleTabs = eligibleTabs.sorted { $0.lastExecutedTime > $1.lastExecutedTime } - - return eligibleTabs - } - - var count: Int { - return tabs.count - } - - var selectedTab: Tab? { - if !(0.. Tab? { - if index >= tabs.count { - return nil - } - return tabs[index] - } - - subscript(webView: WKWebView) -> Tab? { - for tab in tabs where tab.webView === webView { - return tab - } - - return nil - } - - // MARK: - Initializer - - init(profile: Profile, - uuid: WindowUUID, - logger: Logger = DefaultLogger.shared - ) { - self.windowUUID = uuid - self.profile = profile - self.navDelegate = TabManagerNavDelegate() - self.logger = logger - - super.init() - - GlobalTabEventHandlers.configure(with: profile) - register(self, forTabEvents: .didSetScreenshot) - - addNavigationDelegate(self) - NotificationCenter.default.addObserver(self, - selector: #selector(blockPopUpDidChange), - name: .BlockPopup, - object: nil) - } - - // MARK: - Delegates - var delegates = [WeakTabManagerDelegate]() - private let navDelegate: TabManagerNavDelegate - - func addDelegate(_ delegate: TabManagerDelegate) { - self.delegates.append(WeakTabManagerDelegate(value: delegate)) - } - - func addNavigationDelegate(_ delegate: WKNavigationDelegate) { - self.navDelegate.insert(delegate) - } - - func removeDelegate(_ delegate: TabManagerDelegate, completion: (() -> Void)? = nil) { - DispatchQueue.main.async { [unowned self] in - for index in 0 ..< self.delegates.count { - let del = self.delegates[index] - if delegate === del.get() || del.get() == nil { - self.delegates.remove(at: index) - return - } - } - completion?() - } - } - - // MARK: - Webview configuration - // A WKWebViewConfiguration used for normal tabs - lazy var configuration: WKWebViewConfiguration = { - return LegacyTabManager.makeWebViewConfig(isPrivate: false, prefs: profile.prefs) - }() - - // A WKWebViewConfiguration used for private mode tabs - lazy var privateConfiguration: WKWebViewConfiguration = { - return LegacyTabManager.makeWebViewConfig(isPrivate: true, prefs: profile.prefs) - }() - - public static func makeWebViewConfig(isPrivate: Bool, prefs: Prefs?) -> WKWebViewConfiguration { - let configuration = WKWebViewConfiguration() - configuration.processPool = WKProcessPool() - let blockPopups = prefs?.boolForKey(PrefsKeys.KeyBlockPopups) ?? true - configuration.preferences.javaScriptCanOpenWindowsAutomatically = !blockPopups - // We do this to go against the configuration of the - // tag to behave the same way as Safari :-( - configuration.ignoresViewportScaleLimits = true - if isPrivate { - configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() - } else { - configuration.websiteDataStore = WKWebsiteDataStore.default() - } - - configuration.setURLSchemeHandler(InternalSchemeHandler(), forURLScheme: InternalURL.scheme) - return configuration - } - - // MARK: Get tabs - func getTabFor(_ url: URL) -> Tab? { - for tab in tabs { - if let webViewUrl = tab.webView?.url, - url.isEqual(webViewUrl) { - return tab - } - } - - return nil - } - - func getTabForURL(_ url: URL) -> Tab? { - return tabs.first(where: { $0.webView?.url == url }) - } - - func getTabForUUID(uuid: TabUUID) -> Tab? { - let filterdTabs = tabs.filter { tab -> Bool in - tab.tabUUID == uuid - } - return filterdTabs.first - } - - // TODO: FXIOS-7596 Remove when moving the TabManager protocol to TabManagerImplementation - func restoreTabs(_ forced: Bool = false) { fatalError("should never be called") } - - // MARK: - Select tab - - // TODO: FXIOS-7596 Remove when moving the TabManager protocol to TabManagerImplementation - func selectTab(_ tab: Tab?, previous: Tab? = nil) { fatalError("should never be called") } - - func getMostRecentHomepageTab() -> Tab? { - let tabsToFilter = selectedTab?.isPrivate ?? false ? privateTabs : normalTabs - let homePageTabs = tabsToFilter.filter { $0.isFxHomeTab } - - return mostRecentTab(inTabs: homePageTabs) - } - - // MARK: - Clear and store - // TODO: FXIOS-7596 Remove when moving the TabManager protocol to TabManagerImplementation - func preserveTabs() { fatalError("should never be called") } - - func shouldClearPrivateTabs() -> Bool { - // FXIOS-9519: By default if no bool value is set we close the private tabs and mark it true - return profile.prefs.boolForKey(PrefsKeys.Settings.closePrivateTabs) ?? true - } - - func cleanupClosedTabs(_ closedTabs: [Tab], previous: Tab?, isPrivate: Bool = false) { - DispatchQueue.main.async { [unowned self] in - // select normal tab if there are no private tabs, we need to do this - // to accommodate for the case when a user dismisses tab tray while - // they are in private mode and there are no tabs - if isPrivate && self.privateTabs.count < 1 && !self.normalTabs.isEmpty { - self.selectTab(mostRecentTab(inTabs: self.normalTabs) ?? self.normalTabs.last, - previous: previous) - } - } - - // perform remaining tab cleanup related to removing wkwebview - // observers which can only happen on main thread in close() call - closedTabs.forEach { tab in - DispatchQueue.main.async { - tab.close() - TabEvent.post(.didClose, for: tab) - } - } - } - - // TODO: FXIOS-7596 Remove when moving the TabManager protocol to TabManagerImplementation - func storeChanges() { fatalError("should never be called") } - func saveSessionData(forTab tab: Tab?) { fatalError("should never be called") } - - private func addTabForRestoration(isPrivate: Bool) -> Tab { - return addTab(flushToDisk: false, zombie: true, isPrivate: isPrivate) - } - - private func checkForSingleTab() { - // Always make sure there is a single normal tab. - if normalTabs.isEmpty { - let tab = addTab() - if selectedTab == nil { selectTab(tab) } - } - } - - /// When all history gets deleted, we use this special way to handle Tab History deletion. To make it appear like - /// the currently open tab also has its history deleted, we close the tab and reload that URL in a new tab. - /// We handle it this way because, as far as I can tell, clearing history REQUIRES we nil the webView. - /// The backForwardList is not directly mutable. When niling out the webView, we should properly close - /// it since it affects KVO. - func clearAllTabsHistory() { - guard let selectedTab = selectedTab, let url = selectedTab.url else { return } - - for tab in tabs where tab !== selectedTab { - tab.clearAndResetTabHistory() - } - let tabToSelect: Tab - if url.isFxHomeUrl { - tabToSelect = addTab(PrivilegedRequest(url: url) as URLRequest, - afterTab: selectedTab, - isPrivate: selectedTab.isPrivate) - } else { - let request = URLRequest(url: url) - tabToSelect = addTab(request, afterTab: selectedTab, isPrivate: selectedTab.isPrivate) - } - selectTab(tabToSelect) - removeTab(selectedTab) - } - - // MARK: - Add tabs - func addTab(_ request: URLRequest?, afterTab: Tab?, isPrivate: Bool) -> Tab { - return addTab(request, - afterTab: afterTab, - flushToDisk: true, - zombie: false, - isPrivate: isPrivate) - } - - @discardableResult - func addTab(_ request: URLRequest? = nil, - afterTab: Tab? = nil, - zombie: Bool = false, - isPrivate: Bool = false - ) -> Tab { - return addTab(request, - afterTab: afterTab, - flushToDisk: true, - zombie: zombie, - isPrivate: isPrivate) - } - - func addTabsForURLs(_ urls: [URL], zombie: Bool, shouldSelectTab: Bool, isPrivate: Bool) { - if urls.isEmpty { - return - } - - var tab: Tab? - for url in urls { - tab = addTab(URLRequest(url: url), flushToDisk: false, zombie: zombie, isPrivate: isPrivate) - } - - if shouldSelectTab { - // Select the most recent. - selectTab(tab) - } - - // Okay now notify that we bulk-loaded so we can adjust counts and animate changes. - delegates.forEach { $0.get()?.tabManagerDidAddTabs(self) } - - // Flush. - storeChanges() - } - - func addTab(_ request: URLRequest? = nil, - afterTab: Tab? = nil, - flushToDisk: Bool, - zombie: Bool, - isPrivate: Bool = false - ) -> Tab { - let tab = Tab(profile: profile, isPrivate: isPrivate, windowUUID: windowUUID) - configureTab(tab, request: request, afterTab: afterTab, flushToDisk: flushToDisk, zombie: zombie) - return tab - } - - func addPopupForParentTab(profile: Profile, parentTab: Tab, configuration: WKWebViewConfiguration) -> Tab { - let popup = Tab(profile: profile, - isPrivate: parentTab.isPrivate, - windowUUID: windowUUID) - // Configure the tab for the child popup webview. In this scenario we need to be sure to pass along - // the specific `configuration` that we are given by the WKUIDelegate callback, since if we do not - // use this configuration WebKit will throw an exception. - configureTab(popup, - request: nil, - afterTab: parentTab, - flushToDisk: true, - zombie: false, - isPopup: true, - requiredConfiguration: configuration) - - // Wait momentarily before selecting the new tab, otherwise the parent tab - // may be unable to set `window.location` on the popup immediately after - // calling `window.open("")`. - DispatchQueue.main.asyncAfter(deadline: .now() + delaySelectingNewPopupTab) { - self.selectTab(popup) - } - - return popup - } - - /// Note: Inserts AND configures the given tab. - func configureTab(_ tab: Tab, - request: URLRequest?, - afterTab parent: Tab? = nil, - flushToDisk: Bool, - zombie: Bool, - isPopup: Bool = false, - requiredConfiguration: WKWebViewConfiguration? = nil - ) { - // If network is not available webView(_:didCommit:) is not going to be called - // We should set request url in order to show url in url bar even no network - tab.url = request?.url - var placeNextToParentTab = false - if parent == nil || parent?.isPrivate != tab.isPrivate { - tabs.append(tab) - } else if let parent = parent, var insertIndex = tabs.firstIndex(of: parent) { - placeNextToParentTab = true - insertIndex += 1 - - tab.parent = parent - tabs.insert(tab, at: insertIndex) - } - - delegates.forEach { - $0.get()?.tabManager(self, - didAddTab: tab, - placeNextToParentTab: placeNextToParentTab, - isRestoring: !tabRestoreHasFinished) - } - - if !zombie { - let configuration: WKWebViewConfiguration - if let required = requiredConfiguration { - configuration = required - } else { - configuration = tab.isPrivate ? self.privateConfiguration : self.configuration - } - tab.createWebview(configuration: configuration) - } - tab.navigationDelegate = self.navDelegate - - if let request = request { - tab.loadRequest(request) - } else if !isPopup { - let newTabChoice = NewTabAccessors.getNewTabPage(profile.prefs) - tab.newTabPageType = newTabChoice - switch newTabChoice { - case .homePage: - // We definitely have a homepage if we've got here - // (so we can safely dereference it). - let url = NewTabHomePageAccessors.getHomePage(profile.prefs)! - tab.loadRequest(URLRequest(url: url)) - case .blankPage: - break - default: - // The common case, where the NewTabPage enum defines - // one of the about:home pages. - if let url = newTabChoice.url { - tab.loadRequest(PrivilegedRequest(url: url) as URLRequest) - tab.url = url - } - } - } - - tab.nightMode = NightModeHelper.isActivated() - tab.noImageMode = NoImageModeHelper.isActivated(profile.prefs) - - if flushToDisk { - storeChanges() - } - } - - // MARK: - Move tabs - func reorderTabs(isPrivate privateMode: Bool, fromIndex visibleFromIndex: Int, toIndex visibleToIndex: Int) { - let currentTabs = privateMode ? privateTabs : normalActiveTabs - - guard visibleFromIndex < currentTabs.count, visibleToIndex < currentTabs.count else { return } - - let fromIndex = tabs.firstIndex(of: currentTabs[visibleFromIndex]) ?? tabs.count - 1 - let toIndex = tabs.firstIndex(of: currentTabs[visibleToIndex]) ?? tabs.count - 1 - - let previouslySelectedTab = selectedTab - - tabs.insert(tabs.remove(at: fromIndex), at: toIndex) - - if let previouslySelectedTab = previouslySelectedTab, - let previousSelectedIndex = tabs.firstIndex(of: previouslySelectedTab) { - _selectedIndex = previousSelectedIndex - } - - storeChanges() - } - - // MARK: - Privacy change - func switchPrivacyMode() -> SwitchPrivacyModeResult { - var result = SwitchPrivacyModeResult.usedExistingTab - guard let selectedTab = selectedTab else { return result } - let nextSelectedTab: Tab? - - if selectedTab.isPrivate { - nextSelectedTab = mostRecentTab(inTabs: normalTabs) - } else if privateTabs.isEmpty { - nextSelectedTab = addTab(isPrivate: true) - result = .createdNewTab - } else { - nextSelectedTab = mostRecentTab(inTabs: privateTabs) - } - - selectTab(nextSelectedTab) - - let notificationObject = [Tab.privateModeKey: nextSelectedTab?.isPrivate ?? true] - NotificationCenter.default.post(name: .TabsPrivacyModeChanged, - object: notificationObject, - userInfo: windowUUID.userInfo) - return result - } - - // Called by other classes to signal that they are entering/exiting private mode - // This is called by TabTrayVC when the private mode button is pressed and BEFORE we've switched to the new mode - // we only want to remove all private tabs when leaving PBM and not when entering. - func willSwitchTabMode(leavingPBM: Bool) { - // Clear every time entering/exiting this mode. - Tab.ChangeUserAgent.privateModeHostList = Set() - } - - // MARK: - Remove tabs - func removeTab(_ tab: Tab, completion: (() -> Void)? = nil) { - guard let index = tabs.firstIndex(where: { $0 === tab }) else { return } - DispatchQueue.main.async { [weak self] in - self?.removeTab(tab, flushToDisk: true) - self?.updateSelectedTabAfterRemovalOf(tab, deletedIndex: index) - completion?() - } - - TelemetryWrapper.recordEvent( - category: .action, - method: .close, - object: .tab, - value: tab.isPrivate ? .privateTab : .normalTab - ) - } - - @MainActor - func removeTab(_ tabUUID: TabUUID) async { - guard let index = tabs.firstIndex(where: { $0.tabUUID == tabUUID }) else { return } - - let tab = tabs[index] - backupCloseTab = BackupCloseTab( - tab: tab, - restorePosition: index, - isSelected: selectedTab?.tabUUID == tab.tabUUID) - - self.removeTab(tab, flushToDisk: true) - self.updateSelectedTabAfterRemovalOf(tab, deletedIndex: index) - - TelemetryWrapper.recordEvent( - category: .action, - method: .close, - object: .tab, - value: tab.isPrivate ? .privateTab : .normalTab - ) - } - - /// Remove a tab, will notify delegate of the tab removal - /// - Parameters: - /// - tab: the tab to remove - /// - flushToDisk: Will store changes if true, and update selected index - private func removeTab(_ tab: Tab, flushToDisk: Bool) { - guard let removalIndex = tabs.firstIndex(where: { $0 === tab }) else { - logger.log("Could not find index of tab to remove", - level: .warning, - category: .tabs, - description: "Tab count: \(count)") - return - } - - // Save the tab's session state before closing it and losing the webView - if flushToDisk { - saveSessionData(forTab: tab) - } - - backupCloseTab = BackupCloseTab(tab: tab, - restorePosition: removalIndex, - isSelected: selectedTab?.tabUUID == tab.tabUUID) - let prevCount = count - tabs.remove(at: removalIndex) - assert(count == prevCount - 1, "Make sure the tab count was actually removed") - if count != prevCount - 1 { - logger.log("Make sure the tab count was actually removed", - level: .warning, - category: .tabs) - } - - if tab.isPrivate && privateTabs.count < 1 { - privateConfiguration = LegacyTabManager.makeWebViewConfig(isPrivate: true, prefs: profile.prefs) - } - - tab.close() - - // Notify of tab removal - ensureMainThread { [unowned self] in - delegates.forEach { $0.get()?.tabManager(self, didRemoveTab: tab, isRestoring: !tabRestoreHasFinished) } - TabEvent.post(.didClose, for: tab) - } - - if flushToDisk { - storeChanges() - } - } - - func removeTabs(_ tabs: [Tab]) { - for tab in tabs { - self.removeTab(tab, flushToDisk: false) - } - storeChanges() - } - - @MainActor - func removeTabs(by urls: [URL]) async { - let urls = Set(urls) - let tabsToRemove = normalTabs.filter { tab in - guard let url = tab.url else { return false } - return urls.contains(url) - } - for tab in tabsToRemove { - await withCheckedContinuation { continuation in - removeTab(tab) { continuation.resume() } - } - } - } - - @MainActor - func removeAllTabs(isPrivateMode: Bool) async { - let currentModeTabs = tabs.filter { $0.isPrivate == isPrivateMode } - var currentSelectedTab: BackupCloseTab? - - // Backup the selected tab in separate variable as the `removeTab` method called below for each tab will - // automatically update tab selection as if there was a single tab removal. - if let tab = selectedTab, tab.isPrivate == isPrivateMode { - currentSelectedTab = BackupCloseTab(tab: tab, - restorePosition: tabs.firstIndex(of: tab), - isSelected: selectedTab?.tabUUID == tab.tabUUID) - } - backupCloseTabs = tabs - - for tab in currentModeTabs { - await self.removeTab(tab.tabUUID) - } - - // Save the tab state that existed prior to removals (preserves original selected tab) - backupCloseTab = currentSelectedTab - - storeChanges() - } - - func undoCloseAllTabs() { - guard !backupCloseTabs.isEmpty else { return } - tabs = backupCloseTabs - storeChanges() - backupCloseTabs = [Tab]() - if backupCloseTab != nil { - selectTab(backupCloseTab?.tab) - backupCloseTab = nil - } - } - - @MainActor - func removeAllInactiveTabs() async { fatalError("should never be called") } - - func getInactiveTabs() -> [Tab] { - return inactiveTabs - } - - @MainActor - func undoCloseInactiveTabs() async { fatalError("should never be called") } - - func backgroundRemoveAllTabs(isPrivate: Bool = false, - didClearTabs: @escaping (_ tabsToRemove: [Tab], - _ isPrivate: Bool, - _ previousTabUUID: TabUUID) -> Void) { - let previousSelectedTabUUID = selectedTab?.tabUUID ?? "" - // moved closing of multiple tabs to background thread - DispatchQueue.global().async { [unowned self] in - let tabsToRemove = isPrivate ? self.privateTabs : self.normalTabs - - if isPrivate && self.privateTabs.count < 1 { - // Bugzilla 1646756: close last private tab clears the WKWebViewConfiguration (#6827) - DispatchQueue.main.async { [unowned self] in - self.privateConfiguration = LegacyTabManager.makeWebViewConfig(isPrivate: true, - prefs: self.profile.prefs) - } - } - - // clear Tabs from the list that we need to remove - self.tabs = self.tabs.filter { !tabsToRemove.contains($0) } - - // update tab manager count - DispatchQueue.main.async { [unowned self] in - self.delegates.forEach { $0.get()?.tabManagerUpdateCount() } - } - - DispatchQueue.main.async { [unowned self] in - // after closing all normal tabs we should add a normal tab - if self.normalTabs.isEmpty { - self.selectTab(self.addTab()) - storeChanges() - } - } - - DispatchQueue.main.async { - didClearTabs(tabsToRemove, isPrivate, previousSelectedTabUUID) - } - } - } - - // MARK: - Snackbars - func expireSnackbars() { - for tab in tabs { - tab.expireSnackbars() - } - } - - // MARK: - Toasts - func makeToastFromRecentlyClosedUrls(_ recentlyClosedTabs: [Tab], - isPrivate: Bool, - previousTabUUID: TabUUID) { - guard !recentlyClosedTabs.isEmpty else { return } - - // Add last 10 tab(s) to recently closed list - // Note: The recently closed tab list is only updated when the undo - // snackbar disappears and does not update if someone taps on undo button - recentlyClosedTabs.suffix(10).forEach { tab in - if let url = tab.lastKnownUrl, - !(InternalURL(url)?.isAboutURL ?? false), - !tab.isPrivate { - profile.recentlyClosedTabs.addTab(url as URL, - title: tab.lastTitle, - lastExecutedTime: tab.lastExecutedTime) - } - } - - // Toast - let viewModel = ButtonToastViewModel( - labelText: String.localizedStringWithFormat( - .TabsTray.CloseTabsToast.Title, - recentlyClosedTabs.count), - buttonText: .TabsTray.CloseTabsToast.Action) - // Passing nil theme because themeManager is not available, - // calling to applyTheme with proper theme before showing - let toast = ButtonToast(viewModel: viewModel, - theme: nil, - completion: { buttonPressed in - // Handles undo to Close tabs - if buttonPressed { - self.undoCloseAllTabsLegacy(recentlyClosedTabs: recentlyClosedTabs, previousTabUUID: previousTabUUID) - } else { - // Finish clean up for recently close tabs - DispatchQueue.global().async { [unowned self] in - let previousTab = tabs.first(where: { $0.tabUUID == previousTabUUID }) - - self.cleanupClosedTabs(recentlyClosedTabs, - previous: previousTab, - isPrivate: isPrivate) - } - } - }) - delegates.forEach { $0.get()?.tabManagerDidRemoveAllTabs(self, toast: toast) } - } - - /// Restore recently closed tabs when tab tray refactor is disabled - func undoCloseAllTabsLegacy(recentlyClosedTabs: [Tab], previousTabUUID: TabUUID, isPrivate: Bool = false) { - self.reAddTabs( - tabsToAdd: recentlyClosedTabs, - previousTabUUID: previousTabUUID, - isPrivate: isPrivate - ) - NotificationCenter.default.post(name: .DidTapUndoCloseAllTabToast, - object: nil, - userInfo: windowUUID.userInfo) - } - - func tabDidSetScreenshot(_ tab: Tab, hasHomeScreenshot: Bool) {} - - // MARK: - Private - @objc - private func blockPopUpDidChange() { - let allowPopups = !(profile.prefs.boolForKey(PrefsKeys.KeyBlockPopups) ?? true) - // Each tab may have its own configuration, so we should tell each of them in turn. - for tab in tabs { - tab.webView?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = allowPopups - } - // The default tab configurations also need to change. - configuration.preferences.javaScriptCanOpenWindowsAutomatically = allowPopups - privateConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = allowPopups - } - - /// After a tab is removed from the `tabs` array, it is necessary to select the previously selected tab. This method - /// will select the previously selected tab or, if that tab was deleted, select the next best alternative or create a - /// new normal active tab. - /// - /// We have to handle 3 different types of tabs: private tabs, normal active tabs, and normal inactive tabs. - /// These tabs are all in the `tabs` array but are differentiated by their respective flags. - /// - /// Once a tab has been removed, we must consider several situations: - /// - A change in the `tabs` array size, which may require us to shift the selected tab index 1 to the left - /// - If the removed tab was selected, we must choose an appropriate next selected tab from among the existing tabs - /// - When we close the last tab of a certain type, we may need to perform additional logic - /// - For example, closing the last private tab should select the most recent active normal tab, if possible - /// - /// - Parameters: - /// - removedTab: The tab that has already been removed from the `tabs` array. - /// - deletedIndex: The index at which `removedTab` has been removed from the `tabs` array. - private func updateSelectedTabAfterRemovalOf(_ removedTab: Tab, deletedIndex: Int) { - // If the currently selected tab has been deleted, try to select the next most reasonable tab. - if deletedIndex == _selectedIndex { - // First, check if the user has closed the last viable tab of the current browsing mode: private or normal. - // If so, handle this gracefully (i.e. close the last private tab should open the most recent normal active tab). - let viableTabs = removedTab.isPrivate - ? privateTabs - : normalActiveTabs // We never want to surface an inactive tab, if inactive tabs enabled - guard !viableTabs.isEmpty else { - // If the selected tab is closed, and is private browsing, try to select a recent normal active tab. For all - // other cases, open a new normal active tab. - if removedTab.isPrivate, - let mostRecentActiveTab = mostRecentTab(inTabs: normalActiveTabs) { - selectTab(mostRecentActiveTab, previous: removedTab) - } else { - selectTab(addTab(), previous: removedTab) - } - return - } - - if let mostRecentViableTab = mostRecentTab(inTabs: viableTabs), mostRecentViableTab == removedTab.parent { - // 1. Try to select the most recently used viable tab, if it's the removed tab's parent. - selectTab(mostRecentViableTab, previous: removedTab) - } else if !removedTab.isNormalAndInactive, - let rightOrLeftTab = findRightOrLeftTab(forRemovedTab: removedTab, withDeletedIndex: deletedIndex) { - // 2. Try to select an array neighbour of the same tab type, except if the removed tab is inactive (unlikely - // edge case). - selectTab(rightOrLeftTab, previous: removedTab) - } else { - // 3. If there are no suitable active tabs to select, create a new normal active tab. - // (Note: It's possible to fall into here when all tabs have become inactive, especially when debugging.) - selectTab(addTab(), previous: removedTab) - } - } else if deletedIndex < _selectedIndex { - // If we delete a tab in the `tabs` array that's ahead of the selected tab, we need to shift our index. - // The selected tab itself hasn't actually changed; reselect it to call code paths related to saving, etc. - if let selectedTab = tabs[safe: _selectedIndex - 1] { - selectTab(selectedTab, previous: selectedTab) - } else { - assertionFailure("This should not happen, we should always be able to get the selected tab again.") - selectTab(addTab()) - } - } - } - - /// Returns a direct neighbouring tab of the same type as the removed tab. - /// - Parameters: - /// - removedTab: A tab that was just removed from the `tabs` array. - /// - deletedIndex: The former index of the removed tab in the `tabs` array. - /// - Returns: Returns the neighbouring tab of the same type as removedTab. Preference given to the tab on the right. - func findRightOrLeftTab(forRemovedTab removedTab: Tab, withDeletedIndex deletedIndex: Int) -> Tab? { - // We know the fomer index of the removed tab in the full `tabs` array. However, if we want to get the closest - // neighbouring tab of the same type, we need to map this index into a subarray containing only tabs of that type. - // - // Example: - // An array with private tabs (P), inactive normal tabs (I), and active normal tabs (A) is as follows. The - // deleted index is 7, indicating normal active tab A3 was previously removed. - // [P1, P2, A1, I1, A2, I2, P3, A3, A4, P4] - // ^ deletedIndex is 7 - // - // We can map this deletedIndex to an index into a filtered subarray containing only normal active tabs. - // To do this, we count the number of normal active tabs in the `tabs` array in the range 0.. Bool { - let startAtHomeManager = StartAtHomeHelper(prefs: profile.prefs, isRestoringTabs: !tabRestoreHasFinished) - - guard !startAtHomeManager.shouldSkipStartHome else { - logger.log("Skipping start at home", level: .debug, category: .tabs) - return false - } - - if startAtHomeManager.shouldStartAtHome() { - let wasLastSessionPrivate = selectedTab?.isPrivate ?? false - let scannableTabs = wasLastSessionPrivate ? privateTabs : normalTabs - let existingHomeTab = startAtHomeManager.scanForExistingHomeTab(in: scannableTabs, - with: profile.prefs) - let tabToSelect = createStartAtHomeTab(withExistingTab: existingHomeTab, - inPrivateMode: wasLastSessionPrivate, - and: profile.prefs) - - logger.log("Start at home triggered with last session private \(wasLastSessionPrivate)", - level: .debug, - category: .tabs) - selectTab(tabToSelect) - return true - } - return false - } - - /// Provides a tab on which to open if the start at home feature is enabled. This tab - /// can be an existing one, or, if no suitable candidate exists, a new one. - /// - /// - Parameters: - /// - existingTab: A `Tab` that is the user's homepage, that is already open - /// - privateMode: Whether the last session was private or not, so that, if there's - /// no homepage open, we open a new tab in the correct state. - /// - profilePreferences: Preferences, stored in the user's `Profile` - /// - Returns: A selectable tab - private func createStartAtHomeTab(withExistingTab existingTab: Tab?, - inPrivateMode privateMode: Bool, - and profilePreferences: Prefs - ) -> Tab? { - let page = NewTabAccessors.getHomePage(profilePreferences) - let customUrl = HomeButtonHomePageAccessors.getHomePage(profilePreferences) - let homeUrl = URL(string: "internal://local/about/home") - - if page == .homePage, let customUrl = customUrl { - return existingTab ?? addTab(URLRequest(url: customUrl), isPrivate: privateMode) - } else if page == .topSites, let homeUrl = homeUrl { - let home = existingTab ?? addTab(isPrivate: privateMode) - home.loadRequest(PrivilegedRequest(url: homeUrl) as URLRequest) - home.url = homeUrl - return home - } - - return selectedTab ?? addTab() - } -} - -// MARK: - WKNavigationDelegate -extension LegacyTabManager: WKNavigationDelegate { - // Note the main frame JSContext (i.e. document, window) is not available yet. - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation?) { - if let tab = self[webView], let blocker = tab.contentBlocker { - blocker.clearPageStats() - } - } - - // The main frame JSContext is available, and DOM parsing has begun. - // Do not execute JS at this point that requires running prior to DOM parsing. - func webView(_ webView: WKWebView, didCommit navigation: WKNavigation?) { - guard let tab = self[webView] else { return } - - if let tpHelper = tab.contentBlocker, !tpHelper.isEnabled { - webView.evaluateJavascriptInDefaultContentWorld("window.__firefox__.TrackingProtectionStats.setEnabled(false, \(UserScriptManager.appIdToken))") - } - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation?) { - // tab restore uses internal pages, so don't call storeChanges unnecessarily on startup - if let url = webView.url { - if InternalURL(url) != nil { - return - } - - if let title = webView.title, selectedTab?.webView == webView { - selectedTab?.lastTitle = title - } - - storeChanges() - } - } - - /// Called when the WKWebView's content process has gone away. If this happens for the currently selected tab - /// then we immediately reload it. - func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { - if let tab = selectedTab, tab.webView == webView { - tab.consecutiveCrashes += 1 - - // Only automatically attempt to reload the crashed - // tab three times before giving up. - if tab.consecutiveCrashes < 3 { - webView.reload() - } else { - tab.consecutiveCrashes = 0 - } - } - } -} diff --git a/firefox-ios/Client/TabManagement/TabManager.swift b/firefox-ios/Client/TabManagement/TabManager.swift index a04e85fa229f..ef68b4f1591f 100644 --- a/firefox-ios/Client/TabManagement/TabManager.swift +++ b/firefox-ios/Client/TabManagement/TabManager.swift @@ -21,9 +21,7 @@ protocol TabManager: AnyObject { var tabs: [Tab] { get } var count: Int { get } var selectedTab: Tab? { get } - var selectedTabUUID: UUID? { get } var backupCloseTab: BackupCloseTab? { get set } - var backupCloseTabs: [Tab] { get set } var normalTabs: [Tab] { get } // Includes active and inactive tabs var normalActiveTabs: [Tab] { get } var inactiveTabs: [Tab] { get } @@ -41,9 +39,7 @@ protocol TabManager: AnyObject { func removeTabs(_ tabs: [Tab]) func undoCloseTab() func getMostRecentHomepageTab() -> Tab? - func getTabFor(_ url: URL) -> Tab? func clearAllTabsHistory() - func willSwitchTabMode(leavingPBM: Bool) func cleanupClosedTabs(_ closedTabs: [Tab], previous: Tab?, isPrivate: Bool) func reorderTabs(isPrivate privateMode: Bool, fromIndex visibleFromIndex: Int, toIndex visibleToIndex: Int) func preserveTabs() @@ -55,21 +51,12 @@ protocol TabManager: AnyObject { @discardableResult func switchPrivacyMode() -> SwitchPrivacyModeResult func addPopupForParentTab(profile: Profile, parentTab: Tab, configuration: WKWebViewConfiguration) -> Tab - func makeToastFromRecentlyClosedUrls(_ recentlyClosedTabs: [Tab], - isPrivate: Bool, - previousTabUUID: TabUUID) - func undoCloseAllTabsLegacy(recentlyClosedTabs: [Tab], previousTabUUID: TabUUID, isPrivate: Bool) - func findRightOrLeftTab(forRemovedTab removedTab: Tab, withDeletedIndex deletedIndex: Int) -> Tab? @discardableResult func addTab(_ request: URLRequest?, afterTab: Tab?, zombie: Bool, isPrivate: Bool) -> Tab - func backgroundRemoveAllTabs(isPrivate: Bool, - didClearTabs: @escaping (_ tabsToRemove: [Tab], - _ isPrivate: Bool, - _ previousTabUUID: String) -> Void) // MARK: TabTray refactor interfaces /// Async Remove tab option using tabUUID. Replaces direct usage of removeTab where the whole Tab is needed @@ -141,14 +128,6 @@ extension TabManager { isPrivate: isPrivate) } - func backgroundRemoveAllTabs(isPrivate: Bool = false, - didClearTabs: @escaping (_ tabsToRemove: [Tab], - _ isPrivate: Bool, - _ previousTabUUID: TabUUID) -> Void) { - backgroundRemoveAllTabs(isPrivate: isPrivate, - didClearTabs: didClearTabs) - } - func addTabsForURLs(_ urls: [URL], zombie: Bool, shouldSelectTab: Bool = true, isPrivate: Bool = false) { addTabsForURLs(urls, zombie: zombie, shouldSelectTab: shouldSelectTab, isPrivate: isPrivate) } diff --git a/firefox-ios/Client/TabManagement/TabManagerDelegate.swift b/firefox-ios/Client/TabManagement/TabManagerDelegate.swift new file mode 100644 index 000000000000..65d1ef2a3f69 --- /dev/null +++ b/firefox-ios/Client/TabManagement/TabManagerDelegate.swift @@ -0,0 +1,54 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Common +import Foundation +import WebKit +import Storage +import Shared + +// MARK: - TabManagerDelegate +protocol TabManagerDelegate: AnyObject { + // Must be called on the main thread + func tabManager(_ tabManager: TabManager, didSelectedTabChange selectedTab: Tab, previousTab: Tab?, isRestoring: Bool) + func tabManager(_ tabManager: TabManager, didAddTab tab: Tab, placeNextToParentTab: Bool, isRestoring: Bool) + func tabManager(_ tabManager: TabManager, didRemoveTab tab: Tab, isRestoring: Bool) + + func tabManagerDidRestoreTabs(_ tabManager: TabManager) + func tabManagerDidAddTabs(_ tabManager: TabManager) + func tabManagerDidRemoveAllTabs(_ tabManager: TabManager, toast: ButtonToast?) + func tabManagerUpdateCount() +} + +extension TabManagerDelegate { + func tabManager(_ tabManager: TabManager, didSelectedTabChange selectedTab: Tab, previousTab: Tab?, isRestoring: Bool) {} + func tabManager(_ tabManager: TabManager, didAddTab tab: Tab, placeNextToParentTab: Bool, isRestoring: Bool) {} + func tabManager(_ tabManager: TabManager, didRemoveTab tab: Tab, isRestoring: Bool) {} + + func tabManagerDidRestoreTabs(_ tabManager: TabManager) {} + func tabManagerDidAddTabs(_ tabManager: TabManager) {} + func tabManagerDidRemoveAllTabs(_ tabManager: TabManager, toast: ButtonToast?) {} + func tabManagerUpdateCount() {} +} + +// MARK: - WeakTabManagerDelegate +// We can't use a WeakList here because this is a protocol. +class WeakTabManagerDelegate: CustomDebugStringConvertible { + weak var value: TabManagerDelegate? + + init(value: TabManagerDelegate) { + self.value = value + } + + func get() -> TabManagerDelegate? { + return value + } + + var debugDescription: String { + let className = String(describing: type(of: self)) + let memAddr = Unmanaged.passUnretained(self).toOpaque() + let valueStr = (value == nil ? "" : "\(value!)") + return "<\(className): \(memAddr)> Value: \(valueStr)" + } +} diff --git a/firefox-ios/Client/TabManagement/TabManagerImplementation.swift b/firefox-ios/Client/TabManagement/TabManagerImplementation.swift index d10b704ef7d0..acd3748bee07 100644 --- a/firefox-ios/Client/TabManagement/TabManagerImplementation.swift +++ b/firefox-ios/Client/TabManagement/TabManagerImplementation.swift @@ -9,25 +9,118 @@ import Common import Shared import WebKit -// This class subclasses the legacy tab manager temporarily so we can -// gradually migrate to the new system -class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsProvider { +enum SwitchPrivacyModeResult { + case createdNewTab + case usedExistingTab +} + +struct BackupCloseTab { + var tab: Tab + var restorePosition: Int? + var isSelected: Bool +} + +class TabManagerImplementation: NSObject, TabManager, FeatureFlaggable, TabEventHandler { + let windowUUID: WindowUUID + let delaySelectingNewPopupTab: TimeInterval = 0.1 + + var tabEventWindowResponseType: TabEventHandlerWindowResponseType { return .singleWindow(windowUUID) } + var isRestoringTabs = false + var backupCloseTab: BackupCloseTab? + var notificationCenter: NotificationProtocol + var tabs = [Tab]() + + var isInactiveTabsEnabled: Bool { + return featureFlags.isFeatureEnabled(.inactiveTabs, checking: .buildAndUser) + } + + var count: Int { + return tabs.count + } + + var selectedTab: Tab? { + if !(0.. $1.lastExecutedTime } + + return eligibleTabs + } + + private let inactiveTabsManager: InactiveTabsManagerProtocol + private let logger: Logger private let tabDataStore: TabDataStore private let tabSessionStore: TabSessionStore private let imageStore: DiskImageStore? private let tabMigration: TabMigrationUtility - private var tabsTelemetry = TabsTelemetry() private let windowManager: WindowManager private let windowIsNew: Bool - var notificationCenter: NotificationProtocol - var inactiveTabsManager: InactiveTabsManagerProtocol + private let profile: Profile + private let navDelegate: TabManagerNavDelegate + private var backupCloseTabs = [Tab]() + private var tabsTelemetry = TabsTelemetry() + private var delegates = [WeakTabManagerDelegate]() + var tabRestoreHasFinished = false + private(set) var selectedIndex: Int = -1 + + private var selectedTabUUID: UUID? { + guard let selectedTab = self.selectedTab, + let uuid = UUID(uuidString: selectedTab.tabUUID) else { + return nil + } - override var normalActiveTabs: [Tab] { - let inactiveTabs = getInactiveTabs() - let activeTabs = tabs.filter { $0.isPrivate == false && !inactiveTabs.contains($0) } - return activeTabs + return uuid } + // MARK: - Webview configuration + // A WKWebViewConfiguration used for normal tabs + private lazy var configuration: WKWebViewConfiguration = { + return TabManagerImplementation.makeWebViewConfig(isPrivate: false, prefs: profile.prefs) + }() + + // A WKWebViewConfiguration used for private mode tabs + private lazy var privateConfiguration: WKWebViewConfiguration = { + return TabManagerImplementation.makeWebViewConfig(isPrivate: true, prefs: profile.prefs) + }() + init(profile: Profile, imageStore: DiskImageStore = AppContainer.shared.resolve(), logger: Logger = DefaultLogger.shared, @@ -37,7 +130,8 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr tabMigration: TabMigrationUtility? = nil, notificationCenter: NotificationProtocol = NotificationCenter.default, inactiveTabsManager: InactiveTabsManagerProtocol = InactiveTabsManager(), - windowManager: WindowManager = AppContainer.shared.resolve()) { + windowManager: WindowManager = AppContainer.shared.resolve() + ) { let dataStore = tabDataStore ?? DefaultTabDataStore(logger: logger, fileManager: DefaultTabFileManager()) self.tabDataStore = dataStore self.tabSessionStore = tabSessionStore @@ -47,16 +141,316 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr self.inactiveTabsManager = inactiveTabsManager self.windowManager = windowManager self.windowIsNew = uuid.isNew - super.init(profile: profile, uuid: uuid.uuid) + self.windowUUID = uuid.uuid + self.profile = profile + self.navDelegate = TabManagerNavDelegate() + self.logger = logger + + super.init() + + GlobalTabEventHandlers.configure(with: profile) + register(self, forTabEvents: .didSetScreenshot) + + addNavigationDelegate(self) + setupNotifications( + forObserver: self, + observing: [ + UIApplication.willResignActiveNotification, + .TabMimeTypeDidSet, + .BlockPopup + ]) + } + + subscript(index: Int) -> Tab? { + if index >= tabs.count { + return nil + } + return tabs[index] + } + + subscript(webView: WKWebView) -> Tab? { + for tab in tabs where tab.webView === webView { + return tab + } + + return nil + } + + static func makeWebViewConfig(isPrivate: Bool, prefs: Prefs?) -> WKWebViewConfiguration { + let configuration = WKWebViewConfiguration() + configuration.processPool = WKProcessPool() + let blockPopups = prefs?.boolForKey(PrefsKeys.KeyBlockPopups) ?? true + configuration.preferences.javaScriptCanOpenWindowsAutomatically = !blockPopups + // We do this to go against the configuration of the + // tag to behave the same way as Safari :-( + configuration.ignoresViewportScaleLimits = true + if isPrivate { + configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent() + } else { + configuration.websiteDataStore = WKWebsiteDataStore.default() + } + + configuration.setURLSchemeHandler(InternalSchemeHandler(), forURLScheme: InternalURL.scheme) + return configuration + } + + // MARK: - Add/Remove Delegate + func removeDelegate(_ delegate: any TabManagerDelegate, completion: (() -> Void)?) { + DispatchQueue.main.async { [unowned self] in + for index in 0 ..< self.delegates.count { + let del = self.delegates[index] + if delegate === del.get() || del.get() == nil { + self.delegates.remove(at: index) + return + } + } + completion?() + } + } + + func addDelegate(_ delegate: TabManagerDelegate) { + self.delegates.append(WeakTabManagerDelegate(value: delegate)) + } + + func addNavigationDelegate(_ delegate: WKNavigationDelegate) { + self.navDelegate.insert(delegate) + } + + // MARK: - Remove Tab + @MainActor + func removeTab(_ tabUUID: TabUUID) async { + guard let index = tabs.firstIndex(where: { $0.tabUUID == tabUUID }) else { return } + + let tab = tabs[index] + backupCloseTab = BackupCloseTab( + tab: tab, + restorePosition: index, + isSelected: selectedTab?.tabUUID == tab.tabUUID) + + self.removeTab(tab, flushToDisk: true) + self.updateSelectedTabAfterRemovalOf(tab, deletedIndex: index) + + TelemetryWrapper.recordEvent( + category: .action, + method: .close, + object: .tab, + value: tab.isPrivate ? .privateTab : .normalTab + ) + } + + func removeTab(_ tab: Tab, completion: (() -> Void)? = nil) { + guard let index = tabs.firstIndex(where: { $0 === tab }) else { return } + DispatchQueue.main.async { [weak self] in + self?.removeTab(tab, flushToDisk: true) + self?.updateSelectedTabAfterRemovalOf(tab, deletedIndex: index) + completion?() + } + + TelemetryWrapper.recordEvent( + category: .action, + method: .close, + object: .tab, + value: tab.isPrivate ? .privateTab : .normalTab + ) + } + + func removeTabs(_ tabs: [Tab]) { + for tab in tabs { + self.removeTab(tab, flushToDisk: false) + } + storeChanges() + } + + @MainActor + func removeTabs(by urls: [URL]) async { + let urls = Set(urls) + let tabsToRemove = normalTabs.filter { tab in + guard let url = tab.url else { return false } + return urls.contains(url) + } + for tab in tabsToRemove { + await withCheckedContinuation { continuation in + removeTab(tab) { continuation.resume() } + } + } + } + + @MainActor + func removeAllTabs(isPrivateMode: Bool) async { + let currentModeTabs = tabs.filter { $0.isPrivate == isPrivateMode } + var currentSelectedTab: BackupCloseTab? + + // Backup the selected tab in separate variable as the `removeTab` method called below for each tab will + // automatically update tab selection as if there was a single tab removal. + if let tab = selectedTab, tab.isPrivate == isPrivateMode { + currentSelectedTab = BackupCloseTab(tab: tab, + restorePosition: tabs.firstIndex(of: tab), + isSelected: selectedTab?.tabUUID == tab.tabUUID) + } + backupCloseTabs = tabs + + for tab in currentModeTabs { + await self.removeTab(tab.tabUUID) + } + + // Save the tab state that existed prior to removals (preserves original selected tab) + backupCloseTab = currentSelectedTab + + storeChanges() + } + + /// Remove a tab, will notify delegate of the tab removal + /// - Parameters: + /// - tab: the tab to remove + /// - flushToDisk: Will store changes if true, and update selected index + private func removeTab(_ tab: Tab, flushToDisk: Bool) { + guard let removalIndex = tabs.firstIndex(where: { $0 === tab }) else { + logger.log("Could not find index of tab to remove", + level: .warning, + category: .tabs, + description: "Tab count: \(count)") + return + } + + // Save the tab's session state before closing it and losing the webView + if flushToDisk { + saveSessionData(forTab: tab) + } + + backupCloseTab = BackupCloseTab(tab: tab, + restorePosition: removalIndex, + isSelected: selectedTab?.tabUUID == tab.tabUUID) + let prevCount = count + tabs.remove(at: removalIndex) + assert(count == prevCount - 1, "Make sure the tab count was actually removed") + if count != prevCount - 1 { + logger.log("Make sure the tab count was actually removed", + level: .warning, + category: .tabs) + } + + tab.close() + + // Notify of tab removal + ensureMainThread { [unowned self] in + delegates.forEach { $0.get()?.tabManager(self, didRemoveTab: tab, isRestoring: !tabRestoreHasFinished) } + TabEvent.post(.didClose, for: tab) + } + + if flushToDisk { + storeChanges() + } + } + + // MARK: - Add Tab + func addTab(_ request: URLRequest?, afterTab: Tab?, isPrivate: Bool) -> Tab { + return addTab(request, + afterTab: afterTab, + flushToDisk: true, + zombie: false, + isPrivate: isPrivate) + } + + @discardableResult + func addTab(_ request: URLRequest? = nil, + afterTab: Tab? = nil, + zombie: Bool = false, + isPrivate: Bool = false + ) -> Tab { + return addTab(request, + afterTab: afterTab, + flushToDisk: true, + zombie: zombie, + isPrivate: isPrivate) + } + + func addTabsForURLs(_ urls: [URL], zombie: Bool, shouldSelectTab: Bool = true, isPrivate: Bool = false) { + if urls.isEmpty { + return + } + + var tab: Tab? + for url in urls { + tab = addTab(URLRequest(url: url), flushToDisk: false, zombie: zombie, isPrivate: isPrivate) + } + + if shouldSelectTab { + // Select the most recent. + selectTab(tab) + } + + // Okay now notify that we bulk-loaded so we can adjust counts and animate changes. + delegates.forEach { $0.get()?.tabManagerDidAddTabs(self) } + + // Flush. + storeChanges() + } - setupNotifications(forObserver: self, - observing: [UIApplication.willResignActiveNotification, - .TabMimeTypeDidSet]) + private func addTab(_ request: URLRequest? = nil, + afterTab: Tab? = nil, + flushToDisk: Bool, + zombie: Bool, + isPrivate: Bool = false + ) -> Tab { + let tab = Tab(profile: profile, isPrivate: isPrivate, windowUUID: windowUUID) + configureTab(tab, request: request, afterTab: afterTab, flushToDisk: flushToDisk, zombie: zombie) + return tab + } + + // MARK: - Get Tab + func getTabForUUID(uuid: TabUUID) -> Tab? { + let filterdTabs = tabs.filter { tab -> Bool in + tab.tabUUID == uuid + } + return filterdTabs.first + } + + func getTabForURL(_ url: URL) -> Tab? { + return tabs.first(where: { $0.webView?.url == url }) + } + + func getMostRecentHomepageTab() -> Tab? { + let tabsToFilter = selectedTab?.isPrivate ?? false ? privateTabs : normalTabs + let homePageTabs = tabsToFilter.filter { $0.isFxHomeTab } + + return mostRecentTab(inTabs: homePageTabs) + } + + // MARK: - Undo Close Tab + func undoCloseTab() { + guard let backupCloseTab = self.backupCloseTab else { return } + + let previouslySelectedTab = selectedTab + if let index = backupCloseTab.restorePosition { + tabs.insert(backupCloseTab.tab, at: index) + } else { + tabs.append(backupCloseTab.tab) + } + + if backupCloseTab.isSelected { + self.selectTab(backupCloseTab.tab) + } else if let tabToSelect = previouslySelectedTab { + self.selectTab(tabToSelect) + } + + delegates.forEach { $0.get()?.tabManagerUpdateCount() } + storeChanges() + } + + func undoCloseAllTabs() { + guard !backupCloseTabs.isEmpty else { return } + tabs = backupCloseTabs + storeChanges() + backupCloseTabs = [Tab]() + if backupCloseTab != nil { + selectTab(backupCloseTab?.tab) + backupCloseTab = nil + } } // MARK: - Restore tabs - override func restoreTabs(_ forced: Bool = false) { + func restoreTabs(_ forced: Bool = false) { guard !isRestoringTabs, forced || tabs.isEmpty else { @@ -97,6 +491,80 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr migrateAndRestore() } + /// Provides a tab on which to open if the start at home feature is enabled. This tab + /// can be an existing one, or, if no suitable candidate exists, a new one. + /// + /// - Parameters: + /// - existingTab: A `Tab` that is the user's homepage, that is already open + /// - privateMode: Whether the last session was private or not, so that, if there's + /// no homepage open, we open a new tab in the correct state. + /// - profilePreferences: Preferences, stored in the user's `Profile` + /// - Returns: A selectable tab + private func createStartAtHomeTab(withExistingTab existingTab: Tab?, + inPrivateMode privateMode: Bool, + and profilePreferences: Prefs + ) -> Tab? { + let page = NewTabAccessors.getHomePage(profilePreferences) + let customUrl = HomeButtonHomePageAccessors.getHomePage(profilePreferences) + let homeUrl = URL(string: "internal://local/about/home") + + if page == .homePage, let customUrl = customUrl { + return existingTab ?? addTab(URLRequest(url: customUrl), isPrivate: privateMode) + } else if page == .topSites, let homeUrl = homeUrl { + let home = existingTab ?? addTab(isPrivate: privateMode) + home.loadRequest(PrivilegedRequest(url: homeUrl) as URLRequest) + home.url = homeUrl + return home + } + + return selectedTab ?? addTab() + } + + private func updateSelectedTabAfterRemovalOf(_ removedTab: Tab, deletedIndex: Int) { + // If the currently selected tab has been deleted, try to select the next most reasonable tab. + if deletedIndex == selectedIndex { + // First, check if the user has closed the last viable tab of the current browsing mode: private or normal. + // If so, handle this gracefully (i.e. close the last private tab should open the most recent normal active tab). + let viableTabs = removedTab.isPrivate + ? privateTabs + : normalActiveTabs // We never want to surface an inactive tab, if inactive tabs enabled + guard !viableTabs.isEmpty else { + // If the selected tab is closed, and is private browsing, try to select a recent normal active tab. For all + // other cases, open a new normal active tab. + if removedTab.isPrivate, + let mostRecentActiveTab = mostRecentTab(inTabs: normalActiveTabs) { + selectTab(mostRecentActiveTab, previous: removedTab) + } else { + selectTab(addTab(), previous: removedTab) + } + return + } + + if let mostRecentViableTab = mostRecentTab(inTabs: viableTabs), mostRecentViableTab == removedTab.parent { + // 1. Try to select the most recently used viable tab, if it's the removed tab's parent. + selectTab(mostRecentViableTab, previous: removedTab) + } else if !removedTab.isNormalAndInactive, + let rightOrLeftTab = findRightOrLeftTab(forRemovedTab: removedTab, withDeletedIndex: deletedIndex) { + // 2. Try to select an array neighbour of the same tab type, except if the removed tab is inactive (unlikely + // edge case). + selectTab(rightOrLeftTab, previous: removedTab) + } else { + // 3. If there are no suitable active tabs to select, create a new normal active tab. + // (Note: It's possible to fall into here when all tabs have become inactive, especially when debugging.) + selectTab(addTab(), previous: removedTab) + } + } else if deletedIndex < selectedIndex { + // If we delete a tab in the `tabs` array that's ahead of the selected tab, we need to shift our index. + // The selected tab itself hasn't actually changed; reselect it to call code paths related to saving, etc. + if let selectedTab = tabs[safe: selectedIndex - 1] { + selectTab(selectedTab, previous: selectedTab) + } else { + assertionFailure("This should not happen, we should always be able to get the selected tab again.") + selectTab(addTab()) + } + } + } + private func migrateAndRestore() { Task { await buildTabRestore(window: await tabMigration.runMigration(for: windowUUID)) @@ -122,6 +590,18 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr } } + @objc + private func blockPopUpDidChange() { + let allowPopups = !(profile.prefs.boolForKey(PrefsKeys.KeyBlockPopups) ?? true) + // Each tab may have its own configuration, so we should tell each of them in turn. + for tab in tabs { + tab.webView?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = allowPopups + } + // The default tab configurations also need to change. + configuration.preferences.javaScriptCanOpenWindowsAutomatically = allowPopups + privateConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = allowPopups + } + private func buildTabRestore(window: WindowData?) async { defer { isRestoringTabs = false @@ -156,6 +636,11 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr } } + private func shouldClearPrivateTabs() -> Bool { + // FXIOS-9519: By default if no bool value is set we close the private tabs and mark it true + return profile.prefs.boolForKey(PrefsKeys.Settings.closePrivateTabs) ?? true + } + /// Creates the webview so needs to live on the main thread @MainActor private func generateTabs(from windowData: WindowData) async { @@ -234,7 +719,7 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr // MARK: - Save tabs - override func preserveTabs() { + func preserveTabs() { // Only preserve tabs after the restore has finished guard tabRestoreHasFinished else { return } @@ -299,14 +784,14 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr } /// storeChanges is called when a web view has finished loading a page, or when a tab is removed, and in other cases. - override func storeChanges() { + func storeChanges() { let windowManager: WindowManager = AppContainer.shared.resolve() windowManager.performMultiWindowAction(.storeTabs) preserveTabs() saveSessionData(forTab: selectedTab) } - override func saveSessionData(forTab tab: Tab?) { + func saveSessionData(forTab tab: Tab?) { guard let tab = tab, let tabSession = tab.webView?.interactionState as? Data, let tabID = UUID(uuidString: tab.tabUUID) @@ -328,7 +813,7 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr /// This function updates the _selectedIndex. /// Note: it is safe to call this with `tab` and `previous` as the same tab, for use in the case /// where the index of the tab has changed (such as after deletion). - override func selectTab(_ tab: Tab?, previous: Tab? = nil) { + func selectTab(_ tab: Tab?, previous: Tab? = nil) { // Fallback everywhere to selectedTab if no previous tab let previous = previous ?? selectedTab @@ -361,7 +846,7 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr removeAllPrivateTabs() } - _selectedIndex = tabs.firstIndex(of: tab) ?? -1 + selectedIndex = tabs.firstIndex(of: tab) ?? -1 preserveTabs() @@ -410,7 +895,7 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr private func removeAllPrivateTabs() { // reset the selectedTabIndex if we are on a private tab because we will be removing it. if selectedTab?.isPrivate ?? false { - _selectedIndex = -1 + selectedIndex = -1 } privateTabs.forEach { tab in tab.close() @@ -448,9 +933,8 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr selectedTab?.lastExecutedTime = Date.now() } - // MARK: - Screenshots - - override func tabDidSetScreenshot(_ tab: Tab, hasHomeScreenshot: Bool) { + // MARK: - TabEventHandler + func tabDidSetScreenshot(_ tab: Tab, hasHomeScreenshot: Bool) { guard tab.screenshot != nil else { // Remove screenshot from image store so we can use favicon // when a screenshot isn't available for the associated tab url @@ -469,7 +953,7 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr } } - func removeScreenshot(tab: Tab) { + private func removeScreenshot(tab: Tab) { Task { await imageStore?.deleteImageForKey(tab.tabUUID) } @@ -493,14 +977,14 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr } // MARK: - Inactive tabs - override func getInactiveTabs() -> [Tab] { + func getInactiveTabs() -> [Tab] { let inactiveTabsEnabled = profile.prefs.boolForKey(PrefsKeys.FeatureFlags.InactiveTabs) guard inactiveTabsEnabled ?? true else { return [] } return inactiveTabsManager.getInactiveTabs(tabs: tabs) } @MainActor - override func removeAllInactiveTabs() async { + func removeAllInactiveTabs() async { let currentModeTabs = getInactiveTabs() backupCloseTabs = currentModeTabs for tab in currentModeTabs { @@ -510,25 +994,240 @@ class TabManagerImplementation: LegacyTabManager, Notifiable, WindowSimpleTabsPr } @MainActor - override func undoCloseInactiveTabs() async { + func undoCloseInactiveTabs() async { tabs.append(contentsOf: backupCloseTabs) storeChanges() backupCloseTabs = [Tab]() } - override func clearAllTabsHistory() { - super.clearAllTabsHistory() + func clearAllTabsHistory() { + guard let selectedTab = selectedTab, let url = selectedTab.url else { return } + + for tab in tabs where tab !== selectedTab { + tab.clearAndResetTabHistory() + } + let tabToSelect: Tab + if url.isFxHomeUrl { + tabToSelect = addTab(PrivilegedRequest(url: url) as URLRequest, + afterTab: selectedTab, + isPrivate: selectedTab.isPrivate) + } else { + let request = URLRequest(url: url) + tabToSelect = addTab(request, afterTab: selectedTab, isPrivate: selectedTab.isPrivate) + } + selectTab(tabToSelect) + removeTab(selectedTab) Task { await tabSessionStore.deleteUnusedTabSessionData(keeping: []) } } - @MainActor - func closeTab(by url: URL) async { + func closeTab(by url: URL) { // Find the tab with the specified URL if let tabToClose = tabs.first(where: { $0.url == url }) { - await self.removeTab(tabToClose.tabUUID) + self.removeTab(tabToClose) + } + } + + func reorderTabs(isPrivate privateMode: Bool, fromIndex visibleFromIndex: Int, toIndex visibleToIndex: Int) { + let currentTabs = privateMode ? privateTabs : normalActiveTabs + + guard visibleFromIndex < currentTabs.count, visibleToIndex < currentTabs.count else { return } + + let fromIndex = tabs.firstIndex(of: currentTabs[visibleFromIndex]) ?? tabs.count - 1 + let toIndex = tabs.firstIndex(of: currentTabs[visibleToIndex]) ?? tabs.count - 1 + + let previouslySelectedTab = selectedTab + + tabs.insert(tabs.remove(at: fromIndex), at: toIndex) + + if let previouslySelectedTab = previouslySelectedTab, + let previousSelectedIndex = tabs.firstIndex(of: previouslySelectedTab) { + selectedIndex = previousSelectedIndex + } + + storeChanges() + } + + func startAtHomeCheck() -> Bool { + let startAtHomeManager = StartAtHomeHelper(prefs: profile.prefs, isRestoringTabs: !tabRestoreHasFinished) + + guard !startAtHomeManager.shouldSkipStartHome else { + logger.log("Skipping start at home", level: .debug, category: .tabs) + return false + } + + if startAtHomeManager.shouldStartAtHome() { + let wasLastSessionPrivate = selectedTab?.isPrivate ?? false + let scannableTabs = wasLastSessionPrivate ? privateTabs : normalTabs + let existingHomeTab = startAtHomeManager.scanForExistingHomeTab(in: scannableTabs, + with: profile.prefs) + let tabToSelect = createStartAtHomeTab(withExistingTab: existingHomeTab, + inPrivateMode: wasLastSessionPrivate, + and: profile.prefs) + + logger.log("Start at home triggered with last session private \(wasLastSessionPrivate)", + level: .debug, + category: .tabs) + selectTab(tabToSelect) + return true + } + return false + } + + func expireSnackbars() { + for tab in tabs { + tab.expireSnackbars() + } + } + + func switchPrivacyMode() -> SwitchPrivacyModeResult { + var result = SwitchPrivacyModeResult.usedExistingTab + guard let selectedTab = selectedTab else { return result } + let nextSelectedTab: Tab? + + if selectedTab.isPrivate { + nextSelectedTab = mostRecentTab(inTabs: normalTabs) + } else if privateTabs.isEmpty { + nextSelectedTab = addTab(isPrivate: true) + result = .createdNewTab + } else { + nextSelectedTab = mostRecentTab(inTabs: privateTabs) + } + + selectTab(nextSelectedTab) + + let notificationObject = [Tab.privateModeKey: nextSelectedTab?.isPrivate ?? true] + NotificationCenter.default.post(name: .TabsPrivacyModeChanged, + object: notificationObject, + userInfo: windowUUID.userInfo) + return result + } + + func addPopupForParentTab(profile: any Profile, parentTab: Tab, configuration: WKWebViewConfiguration) -> Tab { + let popup = Tab(profile: profile, + isPrivate: parentTab.isPrivate, + windowUUID: windowUUID) + // Configure the tab for the child popup webview. In this scenario we need to be sure to pass along + // the specific `configuration` that we are given by the WKUIDelegate callback, since if we do not + // use this configuration WebKit will throw an exception. + configureTab(popup, + request: nil, + afterTab: parentTab, + flushToDisk: true, + zombie: false, + isPopup: true, + requiredConfiguration: configuration) + + // Wait momentarily before selecting the new tab, otherwise the parent tab + // may be unable to set `window.location` on the popup immediately after + // calling `window.open("")`. + DispatchQueue.main.asyncAfter(deadline: .now() + delaySelectingNewPopupTab) { + self.selectTab(popup) + } + + return popup + } + + /// Note: Inserts AND configures the given tab. + func configureTab(_ tab: Tab, + request: URLRequest?, + afterTab parent: Tab? = nil, + flushToDisk: Bool, + zombie: Bool, + isPopup: Bool = false, + requiredConfiguration: WKWebViewConfiguration? = nil + ) { + // If network is not available webView(_:didCommit:) is not going to be called + // We should set request url in order to show url in url bar even no network + tab.url = request?.url + var placeNextToParentTab = false + if parent == nil || parent?.isPrivate != tab.isPrivate { + tabs.append(tab) + } else if let parent = parent, var insertIndex = tabs.firstIndex(of: parent) { + placeNextToParentTab = true + insertIndex += 1 + + tab.parent = parent + tabs.insert(tab, at: insertIndex) + } + + delegates.forEach { + $0.get()?.tabManager(self, + didAddTab: tab, + placeNextToParentTab: placeNextToParentTab, + isRestoring: !tabRestoreHasFinished) + } + + if !zombie { + let configuration: WKWebViewConfiguration + if let required = requiredConfiguration { + configuration = required + } else { + configuration = tab.isPrivate ? privateConfiguration : self.configuration + } + tab.createWebview(configuration: configuration) } + tab.navigationDelegate = self.navDelegate + + if let request = request { + tab.loadRequest(request) + } else if !isPopup { + let newTabChoice = NewTabAccessors.getNewTabPage(profile.prefs) + tab.newTabPageType = newTabChoice + switch newTabChoice { + case .homePage: + // We definitely have a homepage if we've got here + // (so we can safely dereference it). + let url = NewTabHomePageAccessors.getHomePage(profile.prefs)! + tab.loadRequest(URLRequest(url: url)) + case .blankPage: + break + default: + // The common case, where the NewTabPage enum defines + // one of the about:home pages. + if let url = newTabChoice.url { + tab.loadRequest(PrivilegedRequest(url: url) as URLRequest) + tab.url = url + } + } + } + + tab.nightMode = NightModeHelper.isActivated() + tab.noImageMode = NoImageModeHelper.isActivated(profile.prefs) + + if flushToDisk { + storeChanges() + } + } + + func findRightOrLeftTab(forRemovedTab removedTab: Tab, withDeletedIndex deletedIndex: Int) -> Tab? { + // We know the fomer index of the removed tab in the full `tabs` array. However, if we want to get the closest + // neighbouring tab of the same type, we need to map this index into a subarray containing only tabs of that type. + // + // Example: + // An array with private tabs (P), inactive normal tabs (I), and active normal tabs (A) is as follows. The + // deleted index is 7, indicating normal active tab A3 was previously removed. + // [P1, P2, A1, I1, A2, I2, P3, A3, A4, P4] + // ^ deletedIndex is 7 + // + // We can map this deletedIndex to an index into a filtered subarray containing only normal active tabs. + // To do this, we count the number of normal active tabs in the `tabs` array in the range 0.. [TabUUID: SimpleTab] { // FIXME possibly also related FXIOS-10059 TabManagerImplementation's preserveTabs is called with a nil selectedTab let windowData = WindowData(id: windowUUID, diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockTabManager.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockTabManager.swift index bc6d6136f108..fef698f48cc0 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockTabManager.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockTabManager.swift @@ -101,10 +101,6 @@ class MockTabManager: TabManager { func undoCloseTab() {} - func getTabFor(_ url: URL) -> Tab? { - return nil - } - func clearAllTabsHistory() {} func willSwitchTabMode(leavingPBM: Bool) {} diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/TabManagement/TabManagerTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/TabManagement/TabManagerTests.swift index d52fe97883be..0eca04bf5549 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/TabManagement/TabManagerTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/TabManagement/TabManagerTests.swift @@ -152,20 +152,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(mockDiskImageStore.saveImageForKeyCallCount, 1) } - func testRemoveScreenshotWithImage() async throws { - let subject = createSubject() - addTabs(to: subject, count: 5) - guard let tab = subject.tabs.first else { - XCTFail("First tab was expected to be found") - return - } - - tab.setScreenshot(UIImage()) - await subject.removeScreenshot(tab: tab) - try await Task.sleep(nanoseconds: sleepTime) - XCTAssertEqual(mockDiskImageStore.deleteImageForKeyCallCount, 1) - } - func testGetActiveAndInactiveTabs() { let totalTabCount = 3 let subject = createSubject() @@ -210,7 +196,7 @@ class TabManagerTests: XCTestCase { // MARK: - Test findRightOrLeftTab helper - func testFindRightOrLeftTab_forEmptyArray() async throws { + func testFindRightOrLeftTab_forEmptyArray() { // Set up a tab array as follows: // [] Empty // Will pretend to delete a normal active tab at index 0. @@ -226,7 +212,7 @@ class TabManagerTests: XCTestCase { XCTAssertNil(rightOrLeftTab, "Cannot return a tab when the array is empty") } - func testFindRightOrLeftTab_forSingleTabInArray_ofSameType() async throws { + func testFindRightOrLeftTab_forSingleTabInArray_ofSameType() { // Set up a tab array as follows: // [A1] // Will pretend to delete a normal active tab at index 0. @@ -244,7 +230,7 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(rightOrLeftTab, tabManager.tabs[safe: 0], "Should return neighbour of same type, as one exists") } - func testFindRightOrLeftTab_forSingleTabInArray_ofDifferentType() async throws { + func testFindRightOrLeftTab_forSingleTabInArray_ofDifferentType() { // Set up a tab array as follows: // [A1] // Will pretend to delete a private tab at index 0. @@ -261,7 +247,7 @@ class TabManagerTests: XCTestCase { XCTAssertNil(rightOrLeftTab, "Cannot return neighbour tab of same type, as no other private tabs exist") } - func testFindRightOrLeftTab_forDeletedIndexInMiddle_uniformTabTypes() async throws { + func testFindRightOrLeftTab_forDeletedIndexInMiddle_uniformTabTypes() { // Set up a tab array as follows: // [A1, A2, A3, A4, A5, A6, A7] // 0 1 2 3 4 5 6 @@ -280,7 +266,7 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(rightOrLeftTab, tabManager.tabs[safe: 3], "Should pick tab A4 at the same position as deletedIndex") } - func testFindRightOrLeftTab_forDeletedIndexInMiddle_mixedTabTypes() async throws { + func testFindRightOrLeftTab_forDeletedIndexInMiddle_mixedTabTypes() { // Set up a tab array as follows: // [A1, P1, P2, I1, A2, I2, A3, A4, P3] // 0 1 2 3 4 5 6 7 8 @@ -305,7 +291,7 @@ class TabManagerTests: XCTestCase { ) } - func testFindRightOrLeftTab_forDeletedIndexAtStart() async throws { + func testFindRightOrLeftTab_forDeletedIndexAtStart() { // Set up a tab array as follows: // [A1, P1, P2, I1, A2, I2, A3, A4, P3] // 0 1 2 3 4 5 6 7 8 @@ -330,7 +316,7 @@ class TabManagerTests: XCTestCase { ) } - func testFindRightOrLeftTab_forDeletedIndexAtEnd() async throws { + func testFindRightOrLeftTab_forDeletedIndexAtEnd() { // Set up a tab array as follows: // [A1, P1, P2, I1, A2, I2, A3, A4, P3] // 0 1 2 3 4 5 6 7 8 @@ -355,7 +341,7 @@ class TabManagerTests: XCTestCase { ) } - func testFindRightOrLeftTab_prefersRightTabOverLeftTab() async throws { + func testFindRightOrLeftTab_prefersRightTabOverLeftTab() { // Set up a tab array as follows: // [A1, P1, P2, I1, A2, I2, A3, A4, P3] // 0 1 2 3 4 5 6 7 8 @@ -418,7 +404,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, secondNormalActiveTab) XCTAssertEqual(tabManager.selectedIndex, 1) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondNormalActiveTab.tabUUID) // Remove the selected tab tabManager.removeTab(secondNormalActiveTab) @@ -457,7 +442,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, secondNormalActiveTab) XCTAssertEqual(tabManager.selectedIndex, 1) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondNormalActiveTab.tabUUID) // Remove the selected tab tabManager.removeTab(secondNormalActiveTab) @@ -503,7 +487,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, secondNormalActiveTab) XCTAssertEqual(tabManager.selectedIndex, 1) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondNormalActiveTab.tabUUID) // Remove the selected tab tabManager.removeTab(secondNormalActiveTab) @@ -555,7 +538,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, numberPrivateTabs) XCTAssertEqual(tabManager.selectedTab, secondPrivateTab) XCTAssertEqual(tabManager.selectedIndex, 1) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondPrivateTab.tabUUID) // Remove the selected tab tabManager.removeTab(secondPrivateTab) @@ -601,7 +583,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, numberPrivateTabs) XCTAssertEqual(tabManager.selectedTab, secondPrivateTab) XCTAssertEqual(tabManager.selectedIndex, 1) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondPrivateTab.tabUUID) // Remove the selected tab tabManager.removeTab(secondPrivateTab) @@ -647,8 +628,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, numberPrivateTabs) XCTAssertEqual(tabManager.selectedTab, secondPrivateTab) XCTAssertEqual(tabManager.selectedIndex, 1) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondPrivateTab.tabUUID) - // Remove the selected tab tabManager.removeTab(secondPrivateTab) try await Task.sleep(nanoseconds: sleepTime) @@ -704,7 +683,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, secondInactiveTab) XCTAssertEqual(tabManager.selectedIndex, 1) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondInactiveTab.tabUUID) // Remove the selected inactive tab tabManager.removeTab(secondInactiveTab) @@ -759,7 +737,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, numberPrivateTabs) XCTAssertEqual(tabManager.selectedTab, privateTab) XCTAssertEqual(tabManager.selectedIndex, 0) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, privateTab.tabUUID) // Remove the selected single private tab tabManager.removeTab(privateTab) @@ -799,7 +776,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, numberPrivateTabs) XCTAssertEqual(tabManager.selectedTab, privateTab) XCTAssertEqual(tabManager.selectedIndex, 3) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, privateTab.tabUUID) // Remove the only active tab, which is selected tabManager.removeTab(privateTab) @@ -838,7 +814,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, numberPrivateTabs) XCTAssertEqual(tabManager.selectedTab, firstTab) XCTAssertEqual(tabManager.selectedIndex, 0) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, firstTab.tabUUID) // Remove the last selected private tab tabManager.removeTab(firstTab) @@ -881,7 +856,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, numberPrivateTabs) XCTAssertEqual(tabManager.selectedTab, firstTab) XCTAssertEqual(tabManager.selectedIndex, 0) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, firstTab.tabUUID) // Remove the last selected private tab tabManager.removeTab(firstTab) @@ -924,7 +898,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, firstTab) XCTAssertEqual(tabManager.selectedIndex, 0) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, firstTab.tabUUID) // Remove the last tab, which is active and selected tabManager.removeTab(firstTab) @@ -964,7 +937,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, activeTab) XCTAssertEqual(tabManager.selectedIndex, 3) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, activeTab.tabUUID) // Remove the only active tab, which is selected tabManager.removeTab(activeTab) @@ -1007,7 +979,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, firstTab) XCTAssertEqual(tabManager.selectedIndex, 0) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, firstTab.tabUUID) // Remove the last tab, which is inactive and selected tabManager.removeTab(firstTab) @@ -1054,7 +1025,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, thirdNormalActiveTab) XCTAssertEqual(tabManager.selectedIndex, 5) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, thirdNormalActiveTab.tabUUID) // Remove the unselected normal active tab at an index smaller than the selected tab to cause an array shift for the // selected tab @@ -1099,7 +1069,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, firstNormalActiveTab) XCTAssertEqual(tabManager.selectedIndex, 1) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, firstNormalActiveTab.tabUUID) // Remove the unselected normal active tab at an index larger than the selected tab so no array shift is necessary // for the selected tab @@ -1143,7 +1112,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, numberPrivateTabs) XCTAssertEqual(tabManager.selectedTab, secondPrivateTab) XCTAssertEqual(tabManager.selectedIndex, 7) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondPrivateTab.tabUUID) // Remove the unselected private tab at an index smaller than the selected tab to cause an array shift for the // selected tab @@ -1187,7 +1155,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, numberPrivateTabs) XCTAssertEqual(tabManager.selectedTab, firstPrivateTab) XCTAssertEqual(tabManager.selectedIndex, 6) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, firstPrivateTab.tabUUID) // Remove the unselected private tab at an index larger than the selected private tab so no array shift is necessary // for the selected tab @@ -1231,7 +1198,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, numberPrivateTabs) XCTAssertEqual(tabManager.selectedTab, firstPrivateTab) XCTAssertEqual(tabManager.selectedIndex, 6) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, firstPrivateTab.tabUUID) // Remove the unselected inactive normal tab at an index smaller than the selected tab to cause an array shift for // the selected tab @@ -1273,7 +1239,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, secondTab) XCTAssertEqual(tabManager.selectedIndex, 1) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondTab.tabUUID) // [1] First, remove the tab at index 0 tabManager.removeTab(firstTab) @@ -1338,7 +1303,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, secondTab) XCTAssertEqual(tabManager.selectedIndex, 1) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondTab.tabUUID) await tabManager.removeAllInactiveTabs() try await Task.sleep(nanoseconds: sleepTime) @@ -1377,7 +1341,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, 0) XCTAssertEqual(tabManager.selectedTab, secondTab) XCTAssertEqual(tabManager.selectedIndex, 4) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondTab.tabUUID) await tabManager.removeAllInactiveTabs() try await Task.sleep(nanoseconds: sleepTime) @@ -1415,7 +1378,6 @@ class TabManagerTests: XCTestCase { XCTAssertEqual(tabManager.privateTabs.count, numberNormalPrivateTabs) XCTAssertEqual(tabManager.selectedTab, secondPrivateTab) XCTAssertEqual(tabManager.selectedIndex, 4) - XCTAssertEqual(tabManager.selectedTabUUID?.uuidString, secondPrivateTab.tabUUID) await tabManager.removeAllInactiveTabs() try await Task.sleep(nanoseconds: sleepTime) @@ -1446,7 +1408,7 @@ class TabManagerTests: XCTestCase { case privateAny // `private` alone is a reserved compiler keyword } - private func addTabs(to subject: LegacyTabManager, ofType type: TabType = .normalActive, count: Int) { + private func addTabs(to subject: TabManagerImplementation, ofType type: TabType = .normalActive, count: Int) { for i in 0..