Files
Attractor/App/Browser View/BrowserViewController.swift
James Magahern 6ea9cfa5e0 Fix whitespace
2021-05-25 15:39:10 -07:00

639 lines
26 KiB
Swift

//
// BrowserViewController.swift
// SBrowser
//
// Created by James Magahern on 7/21/20.
//
import Combine
import MessageUI
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 commandKeyHeld: Bool = false
internal var windowButtonHeld: Bool {
get { toolbarController.newTabButton.isTracking }
set { toolbarController.newTabButton.cancelTracking(with: nil) }
}
static let longPressWindowButtonToMakeNewTab: Bool = false
internal var darkModeEnabled: Bool {
get { tab.bridge.darkModeEnabled }
set {
tab.bridge.darkModeEnabled = newValue
toolbarController.darkModeEnabled = newValue
}
}
internal enum PreferredEmailSharingRecipient: String, CaseIterable {
case readLater = "Read Later"
case bookmark = "Bookmark"
case other = "Other…"
}
override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent }
internal var changingFocusToAutocompleteController = false
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] _ in
showShareSheetForCurrentURL(fromViewController: 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 }
}
self.createNewTab(withURL: nil)
}
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.tab.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.delegate = self
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: { (webView, _) 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: { (webView, _) 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)
// Email
documentControls.emailView.addAction(UIAction { [unowned self] _ in
queryPreferredEmailSharingRecipientForCurrentURL(fromViewController: documentControls)
}, for: .touchUpInside)
// Settings
documentControls.settingsView.addAction(UIAction { [unowned self] _ in
documentControls.dismiss(animated: false, completion: nil)
showSettingsWindow()
}, 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 showShareSheetForCurrentURL(fromViewController: UIViewController?) {
guard let url = self.webView.url else { return }
let shareableURL = ShareableURL(
url: url,
title: webView.title ?? url.absoluteString,
favicon: tab.favicon
)
let activityController = UIActivityViewController(activityItems: [ shareableURL ], applicationActivities: nil)
activityController.popoverPresentationController?.sourceView = toolbarController.shareButton
if let fromViewController = fromViewController {
fromViewController.dismiss(animated: false, completion: nil)
}
self.present(activityController, animated: true, completion: nil)
}
internal func queryPreferredEmailSharingRecipientForCurrentURL(fromViewController: UIViewController?) {
if let fromViewController = fromViewController {
fromViewController.dismiss(animated: false, completion: nil)
}
let actionSheet = UIAlertController(title: "Select Preferred Recipient", message: nil, preferredStyle: .actionSheet)
for preferredRecipient in PreferredEmailSharingRecipient.allCases {
actionSheet.addAction(UIAlertAction(title: preferredRecipient.rawValue, style: .default, handler: { [unowned self] action in
actionSheet.dismiss(animated: true, completion: { [unowned self] in
composeEmailForCurrentURL(preferredRecipientSelection: preferredRecipient)
})
}))
}
present(actionSheet, animated: true, completion: nil)
}
internal func composeEmailForCurrentURL(preferredRecipientSelection: PreferredEmailSharingRecipient) {
guard let url = self.webView.url else { return }
let composeController = MFMailComposeViewController()
composeController.setSubject(webView.title ?? url.absoluteString)
composeController.setMessageBody(url.absoluteString, isHTML: false)
composeController.mailComposeDelegate = self
let preferredRecipient: String? = { preferredRecipientSelection in
switch preferredRecipientSelection {
case .readLater:
return "read@buzzert.net"
case .bookmark:
return "bookmarks@buzzert.net"
case .other:
return nil
}
}(preferredRecipientSelection)
if let preferredRecipient = preferredRecipient {
composeController.setToRecipients([ preferredRecipient ])
}
present(composeController, animated: true, completion: nil)
}
internal func showSettingsWindow() {
#if targetEnvironment(macCatalyst)
let userActivity = NSUserActivity(activityType: SessionActivityType.SettingsWindow.rawValue)
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: userActivity, options: .none, errorHandler: nil)
#else
let settingsVC = SettingsViewController()
present(settingsVC, animated: true, completion: nil)
#endif
}
internal func updateLoadProgress(forWebView webView: WKWebView) {
if let loadError = tab.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
// Blur url bar, if applicable
toolbarController.urlBar.textField.resignFirstResponder()
}
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
if url == nil {
self.toolbarController.urlBar.textField.becomeFirstResponder()
}
}
}
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 {
let searchURL = Settings.shared.searchProvider.provider().searchURLWithQuery(text)
tab.beginLoadingURL(searchURL)
}
textField.resignFirstResponder()
}
return false
}
override func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
true
}
func textFieldDidEndEditing(_ textField: UITextField) {
if !changingFocusToAutocompleteController {
autocompleteViewController.view.isHidden = true
}
}
}
extension BrowserViewController: ReaderViewControllerDelegate
{
func readerViewController(_ reader: ReaderViewController, didRequestNavigationToURL navigationURL: URL) {
tab.beginLoadingURL(navigationURL)
}
}
extension BrowserViewController: URLBarDelegate
{
func urlBarRequestedFocusEscape(_ urlBar: URLBar) {
changingFocusToAutocompleteController = true
_ = self.autocompleteViewController.becomeFirstResponder()
changingFocusToAutocompleteController = false
}
}
extension BrowserViewController: MFMailComposeViewControllerDelegate
{
func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true, completion: nil)
}
}