// // BrowserViewController.swift // SBrowser // // Created by James Magahern on 7/21/20. // import Combine import UIKit import UniformTypeIdentifiers class BrowserViewController: UIViewController, WKNavigationDelegate, WKUIDelegate, UITextFieldDelegate, ScriptPolicyViewControllerDelegate, UIPopoverPresentationControllerDelegate, TabDelegate, TabPickerViewControllerDelegate, AutocompleteViewControllerDelegate, ShortcutResponder { let browserView = BrowserView() var tab: Tab { didSet { didChangeTab(tab) } } var webView: WKWebView { tab.webView } private let tabController = TabController() private let tabBarViewController: TabBarViewController private let toolbarController = ToolbarViewController() private let findOnPageController = FindOnPageViewController() private let autocompleteViewController = AutocompleteViewController() private let redirectRules = PersonalRedirectRules() private 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? private var loadError: Error? private var commandKeyHeld: Bool = false 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.tab.bridge.darkModeEnabled = !self.tab.bridge.darkModeEnabled self.toolbarController.darkModeEnabled = self.tab.bridge.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 } 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() 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 })) 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 } private 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) } } private 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) } private func updateCommandKeyState(forPresses presses: Set) { guard let press = presses.first else { return } if let key = press.key { if key.modifierFlags == [.command] { let isDown = press.phase == .began || press.phase == .changed || press.phase == .stationary self.commandKeyHeld = isDown } } } override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { super.pressesBegan(presses, with: event) updateCommandKeyState(forPresses: presses) } override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { super.pressesCancelled(presses, with: event) updateCommandKeyState(forPresses: presses) } override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { super.pressesEnded(presses, with: event) updateCommandKeyState(forPresses: presses) } private 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 } // MARK: UIPopoverPresentationControllerDelegate func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { // Forces popovers to present on iPhone return .none } // MARK: Navigation Delegate func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { loadError = nil // Check to make sure we have connected to the web content process if !tab.bridge.webContentProcessConnected { // This means we started loading a page but the web content process hasn't loaded, which means // scripts are not getting blocked. // If you're ad-hoc signing this, you'll need to disable library validation: // sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation DisableLibraryValidation -bool YES DispatchQueue.main.async { [unowned self] in // Stop loading now webView.stopLoading() // Show an alert let alert = UIAlertController(title: "Web Process Not Loaded", message: "The web content process never contacted the host application", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in alert.dismiss(animated: true, completion: nil) })) present(alert, animated: true, completion: nil) } } // Reset tracking this tab.allowedScriptOrigins.removeAll() tab.blockedScriptOrigins.removeAll() updateScriptBlockerButton() // Blur url bar if applicable toolbarController.urlBar.textField.resignFirstResponder() updateTitleAndURL(forWebView: webView) if let url = webView.url { // Start requesting favicon tab.updateFaviconForURL(url) } } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { toolbarController.urlBar.loadProgress = .complete // Update history if let url = webView.url { let title = webView.title ?? "" BrowserHistory.shared.didNavigate(toURL: url, title: title) } } func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences, decisionHandler: @escaping (WKNavigationActionPolicy, WKWebpagePreferences) -> Void) { // Handle command+click if commandKeyHeld && navigationAction.navigationType == .linkActivated { // Cancel navigation in this tab decisionHandler(.cancel, preferences) // Start navigation in a new tab let tab = tabController.createNewTab(url: navigationAction.request.url) self.tab = tab // Reset this flag. commandKeyHeld = false return } var allowJavaScript = tab.javaScriptEnabled if !allowJavaScript, let host = navigationAction.request.url?.host { // Check origin policy allowJavaScript = policyManager.allowedOriginsForScriptResources().contains(host) } preferences.allowsContentJavaScript = allowJavaScript if let url = navigationAction.request.url, let redirectedURL = redirectRules.redirectedURL(for: url) { tab.beginLoadingURL(redirectedURL) decisionHandler(.cancel, preferences) } else { decisionHandler(.allow, preferences) } } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { self.loadError = error } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { self.loadError = error } // MARK: WKUIDelegate func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { let newTab = tabController.createNewTab(url: nil, webViewConfiguration: configuration) newTab.webView.load(navigationAction.request) self.tab = newTab return newTab.webView } func webView(_ webView: WKWebView, contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) { let menuConfig = UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (menuElements: [UIMenuElement]) -> UIMenu? in let openInNewTab = UIAction(title: "Open in New Tab", image: UIImage(systemName: "plus.app"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [unowned self] _ in let newTab = tabController.createNewTab(url: elementInfo.linkURL) self.tab = newTab } return UIMenu(title: elementInfo.linkURL?.absoluteString ?? "Link", image: nil, identifier: nil, options: .displayInline, children: [ openInNewTab ] + menuElements) } completionHandler(menuConfig) } // MARK: UITextField Delegate 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 } func textFieldDidEndEditing(_ textField: UITextField) { autocompleteViewController.view.isHidden = true } // MARK: Tab Delegate func didBlockScriptOrigin(_ origin: String, forTab: Tab) { updateScriptBlockerButton() } // MARK: Tab Picker Delegate 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) } } // MARK: Script Policy View Controller Delegate func didChangeScriptPolicy() { tab.bridge.policyDataSourceDidChange() webView.reload() } func setScriptsEnabledForTab(_ enabled: Bool) { tab.javaScriptEnabled = enabled toolbarController.scriptControllerIconView.shieldsDown = enabled } // MARK: Autocomplete Controller Delegate func autocompleteController(_: AutocompleteViewController, didSelectHistoryItem item: HistoryItem) { tab.beginLoadingURL(item.url) autocompleteViewController.view.isHidden = true } // MARK: Keyboard shortcuts func focusURLBar(_ sender: Any?) { toolbarController.urlBar.textField.becomeFirstResponder() } func goBack(_ sender: Any?) { tab.webView.goBack() } func goForward(_ sender: Any?) { tab.webView.goForward() } func createTab(_ sender: Any?) { createNewTab(withURL: nil) } func previousTab(_ sender: Any?) { if let tabIndex = tabController.tabs.firstIndex(of: self.tab) { if tabIndex - 1 >= 0 { self.tab = tabController.tabs[tabIndex - 1] } } } func nextTab(_ sender: Any?) { if let tabIndex = tabController.tabs.firstIndex(of: self.tab) { if tabIndex + 1 < tabController.tabs.count { self.tab = tabController.tabs[tabIndex + 1] } } } func closeTab(_ sender: Any?) { if tabController.tabs.count > 1 { tabController.closeTab(self.tab) } else { #if targetEnvironment(macCatalyst) if let originWindowScene = self.view.window?.windowScene { UIApplication.shared.requestSceneSessionDestruction(originWindowScene.session, options: nil) { error in print("Error when requesting scene destruction: " + error.localizedDescription) } } #endif } } func findOnPage(_ sender: Any?) { browserView.setFindOnPageVisible(true, animated: true) findOnPageController.findOnPageView.textField.becomeFirstResponder() } func refresh(_ sender: Any?) { webView.reload() } override func increaseSize(_ sender: Any?) { tab.webView._viewScale += 0.10 } override func decreaseSize(_ sender: Any?) { tab.webView._viewScale -= 0.10 } }