// // BrowserViewController.swift // SBrowser // // Created by James Magahern on 7/21/20. // import Combine import UIKit import UniformTypeIdentifiers class BrowserViewController: UIViewController { let browserView = BrowserView() var tab: Tab { didSet { didChangeTab(tab) } } var webView: WKWebView { tab.webView } internal let tabController = TabController() internal let tabBarViewController: TabBarViewController internal let toolbarController = ToolbarViewController() internal let findOnPageController = FindOnPageViewController() internal let autocompleteViewController = AutocompleteViewController() internal let redirectRules = PersonalRedirectRules() internal var policyManager: ResourcePolicyManager { tabController.policyManager } override var canBecomeFirstResponder: Bool { true } private var titleObservation: NSKeyValueObservation? private var loadingObservation: NSKeyValueObservation? private var backButtonObservation: NSKeyValueObservation? private var forwardButtonObservation: NSKeyValueObservation? private var activeTabObservation: AnyCancellable? private var faviconObservation: AnyCancellable? internal var loadError: Error? internal var commandKeyHeld: Bool = false internal var windowButtonHeld: Bool { get { toolbarController.newTabButton.isTracking } set { toolbarController.newTabButton.cancelTracking(with: nil) } } static let longPressWindowButtonToMakeNewTab: Bool = false private var darkModeEnabled: Bool { get { tab.bridge.darkModeEnabled } set { tab.bridge.darkModeEnabled = newValue toolbarController.darkModeEnabled = newValue } } override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } init() { self.tab = tabController.tabs.first! self.tabBarViewController = TabBarViewController(tabController: tabController) super.init(nibName: nil, bundle: nil) addChild(toolbarController) addChild(findOnPageController) addChild(tabBarViewController) didChangeTab(tab) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { browserView.toolbarView = toolbarController.toolbarView browserView.findOnPageView = findOnPageController.findOnPageView browserView.tabBarView = tabBarViewController.tabBarView // Refresh button toolbarController.urlBar.refreshButton.addAction(UIAction(handler: { [unowned self] action in if self.webView.isLoading { self.webView.stopLoading() } else { self.webView.reload() } }), for: .touchUpInside) // Back button toolbarController.backButton.addAction(UIAction(handler: { [unowned self] _ in self.webView.goBack() }), for: .touchUpInside) // Forward button toolbarController.forwardButton.addAction(UIAction(handler: { [unowned self] _ in self.webView.goForward() }), for: .touchUpInside) // Share button toolbarController.shareButton.addAction(UIAction(handler: { [unowned self, toolbarController] _ in if let url = self.webView.url { let itemProvider = NSItemProvider(item: url as NSURL, typeIdentifier: UTType.url.identifier) let config = UIActivityItemsConfiguration(itemProviders: [ itemProvider ]) config.metadataProvider = { metadataKey in switch metadataKey { case .title: return self.webView.title case .messageBody: return self.webView.title default: return nil } } config.previewProvider = { index, intent, suggestedSize in NSItemProvider(item: self.tab.favicon, typeIdentifier: UTType.image.identifier) } let activityController = UIActivityViewController(activityItemsConfiguration: config) activityController.popoverPresentationController?.sourceView = toolbarController.shareButton self.present(activityController, animated: true, completion: nil) } }), for: .touchUpInside) // Script button toolbarController.scriptControllerIconView.addAction(UIAction(handler: { [unowned self] action in let hostOrigin = self.webView.url?.host ?? "" let loadedScripts = self.tab.allowedScriptOrigins.union(self.tab.blockedScriptOrigins) let scriptViewController = ScriptPolicyViewController(policyManager: self.policyManager, hostOrigin: hostOrigin, loadedScripts: loadedScripts, scriptsAllowedForTab: self.tab.javaScriptEnabled) scriptViewController.delegate = self let navController = UINavigationController(rootViewController: scriptViewController) navController.modalPresentationStyle = .popover navController.popoverPresentationController?.sourceView = self.toolbarController.scriptControllerIconView navController.popoverPresentationController?.delegate = self self.present(navController, animated: true, completion: nil) }), for: .touchUpInside) // Dark mode button toolbarController.darkModeButton.addAction(UIAction(handler: { [unowned self] _ in self.darkModeEnabled = !self.darkModeEnabled }), for: .touchUpInside) // Tabs button toolbarController.windowButton.addAction(UIAction(handler: { [unowned self] _ in let tabPickerController = TabPickerViewController(tabController: self.tabController) tabPickerController.delegate = self tabPickerController.selectedTab = self.tab let navController = UINavigationController(rootViewController: tabPickerController) navController.modalPresentationStyle = .popover navController.popoverPresentationController?.sourceView = self.toolbarController.windowButton navController.popoverPresentationController?.delegate = self self.present(navController, animated: true, completion: nil) }), for: .touchUpInside) let newTabAction = UIAction { [unowned self] action in if let gestureRecognizer = action.sender as? UILongPressGestureRecognizer { if gestureRecognizer.state != .began { return } } // Create new tab let newTab = tabController.createNewTab(url: nil) self.tab = newTab } if Self.longPressWindowButtonToMakeNewTab { // Long press window button to make new tab? let gestureRecognizer = UILongPressGestureRecognizer(action: newTabAction) toolbarController.windowButton.addGestureRecognizer(gestureRecognizer) } // New tab button toolbarController.newTabButton.addAction(newTabAction, for: .touchUpInside) // Error button toolbarController.urlBar.errorButton.addAction(UIAction(handler: { [unowned self] _ in let alert = UIAlertController(title: "Error", message: self.loadError?.localizedDescription, preferredStyle: .actionSheet) alert.popoverPresentationController?.sourceView = self.toolbarController.urlBar.errorButton alert.addAction(UIAlertAction(title: "Reload", style: .destructive, handler: { _ in self.webView.reload() alert.dismiss(animated: true, completion: nil) })) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in alert.dismiss(animated: true, completion: nil) // Clears out the error state toolbarController.urlBar.loadProgress = .complete })) self.present(alert, animated: true, completion: nil) }), for: .touchUpInside) // Cancel button: hide autocomplete if applicable toolbarController.toolbarView.cancelButton.addAction(UIAction(handler: { [unowned self] _ in self.autocompleteViewController.view.isHidden = true }), for: .touchUpInside) // TextField delegate toolbarController.urlBar.textField.delegate = self // Autocomplete controller autocompleteViewController.delegate = self autocompleteViewController.view.isHidden = true // Font size adjust toolbarController.urlBar.documentButton.addAction(UIAction(handler: { [unowned self] _ in let documentControls = DocumentControlViewController(darkModeEnabled: tab.bridge.darkModeEnabled) documentControls.modalPresentationStyle = .popover documentControls.popoverPresentationController?.permittedArrowDirections = [ .down, .up ] documentControls.popoverPresentationController?.sourceView = toolbarController.urlBar.documentButton documentControls.popoverPresentationController?.delegate = self let numberFormatter = NumberFormatter() numberFormatter.numberStyle = .percent let label = documentControls.fontSizeAdjustView.labelView label.text = numberFormatter.string(for: tab.webView._viewScale) // Font size adjust documentControls.fontSizeAdjustView.decreaseSizeButton.addAction(UIAction(handler: { [unowned self] sender in self.decreaseSize(sender) label.text = numberFormatter.string(for: tab.webView._viewScale) }), for: .touchUpInside) documentControls.fontSizeAdjustView.increaseSizeButton.addAction(UIAction(handler: { [unowned self] sender in self.increaseSize(sender) label.text = numberFormatter.string(for: tab.webView._viewScale) }), for: .touchUpInside) // Find on page documentControls.findOnPageControlView.addAction(UIAction(handler: { [unowned self] _ in documentControls.dismiss(animated: true, completion: nil) browserView.setFindOnPageVisible(true, animated: true) }), for: .touchUpInside) // Navigation controls documentControls.navigationControlView.backButton.isEnabled = webView.canGoBack documentControls.navigationControlView.backButton.addAction(UIAction() { [unowned self] _ in webView.goBack() }, for: .touchUpInside) documentControls.observations.append(webView.observe(\.canGoBack, changeHandler: { (_, _) in documentControls.navigationControlView.backButton.isEnabled = webView.canGoBack })) documentControls.navigationControlView.forwardButton.isEnabled = webView.canGoForward documentControls.navigationControlView.forwardButton.addAction(UIAction() { [unowned self] _ in webView.goForward() }, for: .touchUpInside) documentControls.observations.append(webView.observe(\.canGoForward, changeHandler: { (_, _) in documentControls.navigationControlView.forwardButton.isEnabled = webView.canGoForward })) // Reader mode documentControls.readabilityView.addAction(UIAction { [unowned self] _ in tab.bridge.parseDocumentForReaderMode { string in DispatchQueue.main.async { documentControls.dismiss(animated: true, completion: nil) let readableViewController = ReaderViewController(readableHTMLString: string, baseURL: tab.bridge.webView.url) readableViewController.title = tab.bridge.webView.title readableViewController.darkModeEnabled = tab.bridge.darkModeEnabled readableViewController.delegate = self let navigationController = UINavigationController(rootViewController: readableViewController) present(navigationController, animated: true, completion: nil) } } }, for: .touchUpInside) // Archive documentControls.archiveView.addAction(UIAction { [unowned self] _ in guard let currentURL = webView.url else { return } guard let archiveURL = URL(string: "https://archive.today/\(currentURL.absoluteString)") else { return } tab.beginLoadingURL(archiveURL) documentControls.dismiss(animated: true, completion: nil) }, for: .touchUpInside) // Dark mode documentControls.darkModeView.addAction(UIAction { [unowned self] _ in self.darkModeEnabled = !self.darkModeEnabled documentControls.dismiss(animated: true, completion: nil) }, for: .touchUpInside) present(documentControls, animated: true, completion: nil) }), for: .touchUpInside) // Find on page dismiss findOnPageController.findOnPageView.doneButton.addAction(UIAction(handler: { [unowned self] _ in browserView.setFindOnPageVisible(false, animated: true) }), for: .touchUpInside) // Tab controller activeTabObservation = tabController.$activeTabIndex .receive(on: RunLoop.main) .sink(receiveValue: { [unowned self] (activeTab: Int) in if activeTab < tabController.tabs.count { let tab = tabController.tabs[activeTab] if self.tab != tab { self.tab = tab } } // Show tab bar view? browserView.tabBarViewVisible = tabController.tabs.count > 1 }) self.view = browserView } internal func updateLoadProgress(forWebView webView: WKWebView) { if let loadError = loadError { toolbarController.urlBar.loadProgress = .error(error: loadError) } else if webView.estimatedProgress == 1.0 { toolbarController.urlBar.loadProgress = .complete } else { toolbarController.urlBar.loadProgress = .loading(progress: webView.estimatedProgress) } } internal func updateTitleAndURL(forWebView webView: WKWebView) { if webView == browserView.webView { browserView.titlebarView.setTitle(webView.title ?? "") if let urlString = webView.url?.absoluteString { toolbarController.urlBar.textField.text = urlString } else { toolbarController.urlBar.textField.text = "" } } // Figure out which tab this corresponds to let tab = tabController.tabs.first { $0.webView == webView } if let tab = tab, let tabIndex = tabController.tabs.firstIndex(of: tab) { tabBarViewController.tabBarView.reloadTab(atIndex: tabIndex) } } private func didChangeTab(_ tab: Tab) { if let activeIndex = tabController.tabs.firstIndex(of: tab) { tabController.activeTabIndex = activeIndex } tab.delegate = self let webView = tab.webView webView.allowsBackForwardNavigationGestures = true webView.navigationDelegate = self webView.uiDelegate = self // Change webView browserView.webView = webView findOnPageController.webView = webView // Autocomplete view browserView.autocompleteView = autocompleteViewController.view // Color theme browserView.titlebarView.setColorTheme(tab.colorTheme) // Load progress updateLoadProgress(forWebView: webView) loadingObservation = webView.observe(\.estimatedProgress) { [unowned self] (webView, observedChange) in self.updateLoadProgress(forWebView: webView) } // Title observer updateTitleAndURL(forWebView: webView) titleObservation = webView.observe(\.title, changeHandler: { [unowned self] (webView, observedChange) in self.updateTitleAndURL(forWebView: webView) }) // Back/forward observer toolbarController.backButton.isEnabled = webView.canGoBack backButtonObservation = webView.observe(\.canGoBack, changeHandler: { [toolbarController] (webView, observedChange) in toolbarController.backButton.isEnabled = webView.canGoBack }) toolbarController.forwardButton.isEnabled = webView.canGoForward forwardButtonObservation = webView.observe(\.canGoForward, changeHandler: { [toolbarController] (webView, observedChange) in toolbarController.forwardButton.isEnabled = webView.canGoForward }) // Favicon observation faviconObservation = tab.$favicon.receive(on: DispatchQueue.main) .sink { [unowned self] _ in updateTitleAndURL(forWebView: webView) } // Script blocker button updateScriptBlockerButton() // Enforce dark mode setting tab.bridge.darkModeEnabled = toolbarController.darkModeEnabled } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) becomeFirstResponder() } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) // Not sure why this doesn't happen automatically... toolbarController.traitCollectionDidChange(previousTraitCollection) } internal func updateScriptBlockerButton() { var numBlockedScripts: Int = tab.blockedScriptOrigins.count if tab.url != nil, tab.javaScriptEnabled == false { // Because the page is blocked too, notify. numBlockedScripts += 1 } var scriptsAllowedForHost = false if let url = webView.url, let host = url.host, policyManager.allowedOriginsForScriptResources().contains(host) { scriptsAllowedForHost = true } let iconView = toolbarController.scriptControllerIconView iconView.shieldsDown = tab.javaScriptEnabled iconView.someScriptsAllowed = scriptsAllowedForHost iconView.setBlockedScriptsNumber(numBlockedScripts) } public func createNewTab(withURL url: URL?) { let newTab = tabController.createNewTab(url: url) self.tab = newTab } } extension BrowserViewController: UIPopoverPresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { // Forces popovers to present on iPhone return .none } } extension BrowserViewController: ScriptPolicyViewControllerDelegate { func didChangeScriptPolicy() { tab.bridge.policyDataSourceDidChange() webView.reload() } func setScriptsEnabledForTab(_ enabled: Bool) { tab.javaScriptEnabled = enabled toolbarController.scriptControllerIconView.shieldsDown = enabled } } extension BrowserViewController: AutocompleteViewControllerDelegate { func autocompleteController(_: AutocompleteViewController, didSelectHistoryItem item: HistoryItem) { tab.beginLoadingURL(item.url) autocompleteViewController.view.isHidden = true } } extension BrowserViewController: TabDelegate { func didBlockScriptOrigin(_ origin: String, forTab: Tab) { updateScriptBlockerButton() } } extension BrowserViewController: TabPickerViewControllerDelegate { func tabPicker(_ picker: TabPickerViewController, didSelectTab tab: Tab) { self.tab = tab picker.dismiss(animated: true, completion: nil) } func tabPicker(_ picker: TabPickerViewController, willCloseTab tab: Tab) { // Dismiss picker if current tab is closed using the picker if tab == self.tab { picker.dismiss(animated: true, completion: nil) } } } extension BrowserViewController: UITextFieldDelegate { func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { if let text = textField.text { let matches = BrowserHistory.shared.visitedToplevelHistoryItems(matching: text) autocompleteViewController.historyItems = matches autocompleteViewController.view.isHidden = matches.count == 0 } return true } func textFieldShouldReturn(_ textField: UITextField) -> Bool { if let text = textField.text?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) { // Dumb rules for stuff that "looks like" a URL if !text.contains(" "), text.components(separatedBy: ".").count > 1, var url = URL(string: text) { if url.scheme == nil { let urlString = "http://\(text)" if let fixedURL = URL(string: urlString) { url = fixedURL } } tab.beginLoadingURL(url) } else { // Assume google search let queryString = text .replacingOccurrences(of: " ", with: "+") .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! let searchURL = URL(string: "https://google.com/search?q=\(queryString)&gbv=1")! // gbv=1: no JS tab.beginLoadingURL(searchURL) } textField.resignFirstResponder() } return false } override func textFieldShouldEndEditing(_ textField: UITextField) -> Bool { true } func textFieldDidEndEditing(_ textField: UITextField) { autocompleteViewController.view.isHidden = true } } extension BrowserViewController: ReaderViewControllerDelegate { func readerViewController(_ reader: ReaderViewController, didRequestNavigationToURL navigationURL: URL) { tab.beginLoadingURL(navigationURL) } }