// // BrowserWebViewController.swift // Gemini-iOS // // Created by Shadowfacts on 12/16/20. // import UIKit import BrowserCore import WebKit import GeminiProtocol import GeminiFormat import GeminiRenderer import SafariServices import Combine class BrowserWebViewController: UIViewController { let navigator: NavigationManager let url: URL weak var scrollViewDelegate: UIScrollViewDelegate? { didSet { if isViewLoaded { webView.scrollView.delegate = scrollViewDelegate } } } private var task: GeminiDataTask? private let renderer = GeminiHTMLRenderer() private(set) var document: Document? private var loaded = false private var loadedFallback = false private var cancellables = Set() private var errorStack: UIStackView! private var errorMessageLabel: UILabel! private var activityIndicator: UIActivityIndicatorView! private var webView: WKWebView! init(navigator: NavigationManager, url: URL) { self.navigator = navigator self.url = url super.init(nibName: nil, bundle: nil) userActivity = NSUserActivity(geminiURL: url) userActivity!.isEligibleForPrediction = true userActivity!.title = BrowserHelper.urlForDisplay(url) // set the persistent identifier to the url, so that we don't get duplicate shortcuts for the same url // (at least, i think that's how it works) userActivity!.persistentIdentifier = url.absoluteString } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() configureRenderer() view.backgroundColor = .systemBackground webView = WKWebView() webView.backgroundColor = .systemBackground webView.isOpaque = false webView.navigationDelegate = self webView.uiDelegate = self // it is safe to set the delegate of the web view's internal scroll view becuase WebKit takes care of forwarding between its internal delegate and our own webView.scrollView.delegate = scrollViewDelegate // this doesn't default to .default :S webView.scrollView.indicatorStyle = .default webView.scrollView.keyboardDismissMode = .interactive webView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(webView) NSLayoutConstraint.activate([ webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), webView.topAnchor.constraint(equalTo: view.topAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) let errorTitle = UILabel() errorTitle.text = "An error occurred" errorTitle.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .headline).withDesign(.serif)!, size: 0) errorTitle.numberOfLines = 0 errorMessageLabel = UILabel() errorMessageLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body).withDesign(.serif)!, size: 0) errorMessageLabel.numberOfLines = 0 errorStack = UIStackView(arrangedSubviews: [ errorTitle, errorMessageLabel, ]) errorStack.translatesAutoresizingMaskIntoConstraints = false errorStack.axis = .vertical errorStack.alignment = .center errorStack.isHidden = true view.addSubview(errorStack) NSLayoutConstraint.activate([ errorStack.centerYAnchor.constraint(equalTo: view.centerYAnchor), errorStack.leadingAnchor.constraint(equalTo: view.leadingAnchor), errorStack.trailingAnchor.constraint(equalTo: view.trailingAnchor), ]) activityIndicator = UIActivityIndicatorView(style: .large) activityIndicator.translatesAutoresizingMaskIntoConstraints = false activityIndicator.isHidden = true view.addSubview(activityIndicator) NSLayoutConstraint.activate([ activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) Preferences.shared.$showLinkIcons .sink { [weak self] newVal in guard let self = self, let doc = self.document else { return } self.configureRenderer(showLinkIcons: newVal) self.renderDocument(doc) } .store(in: &cancellables) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) loadDocument() } private func configureRenderer(showLinkIcons: Bool = Preferences.shared.showLinkIcons) { if showLinkIcons { let documentURL = self.url renderer.linkPrefix = { (url: URL) -> String in let symbolClass: String if url.scheme == "gemini" { if url.host == documentURL.host { symbolClass = "arrow-right" } else { symbolClass = "link" } } else if url.scheme == "http" || url.scheme == "https" { symbolClass = "safari" } else if url.scheme == "mailto" { symbolClass = "envelope" } else { symbolClass = "arrow-up-left-square" } return "" } } else { renderer.linkPrefix = nil } } func reload() { loaded = false loadedFallback = false document = nil loadDocument() } private func loadDocument() { guard !loaded else { return } webView.isHidden = true errorStack.isHidden = true activityIndicator.isHidden = false activityIndicator.startAnimating() let url = self.url task = try! GeminiDataTask(url: url) { (response) in self.task = nil self.loaded = true switch response { case let .failure(error): DispatchQueue.main.async { self.showError(message: error.localizedDescription) } case let .success(response): if response.status.isRedirect { DispatchQueue.main.async { print("Trying to redirect to: '\(response.meta)'") if let redirect = URL(string: response.meta) { self.navigator.changeURL(redirect) } else { self.showError(message: "Invalid redirect URL: '\(response.meta)'") } } } else if response.status.isSuccess { if response.mimeType == "text/gemini", let text = response.bodyText { let doc = GeminiParser.parse(text: text, baseURL: url) self.renderDocument(doc) } else { self.renderFallback(response: response) } } else if response.status.isInput { DispatchQueue.main.async { self.showInputPrompt(response: response) } } else { DispatchQueue.main.async { self.showError(message: "Unknown error: \(response.header)") } } } } if #available(iOS 15.0, *) { task!.attribution = .user } task!.resume() } private func showError(message: String) { webView.isHidden = true errorStack.isHidden = false activityIndicator.isHidden = true activityIndicator.stopAnimating() errorMessageLabel.text = message } private func showInputPrompt(response: GeminiResponse) { let alert = UIAlertController(title: "Input Requested", message: response.meta, preferredStyle: .alert) alert.addTextField { field in field.isSecureTextEntry = response.status == .sensitiveInput } alert.addAction(UIAlertAction(title: "Submit", style: .default, handler: { _ in guard var components = URLComponents(url: self.navigator.currentURL, resolvingAgainstBaseURL: false) else { return } components.query = alert.textFields!.first!.text self.navigator.changeURL(components.url!) })) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) present(alert, animated: true) } private func renderDocument(_ doc: Document) { self.document = doc if navigator.currentURL == doc.url { navigator.setTitleForCurrentURL(doc.title) } if let title = doc.title { DispatchQueue.main.async { self.userActivity!.title = title } } let html = BrowserWebViewController.preamble + renderer.renderDocumentToHTML(doc) + BrowserWebViewController.postamble DispatchQueue.main.async { self.webView.isHidden = false self.errorStack.isHidden = true self.activityIndicator.isHidden = true self.activityIndicator.stopAnimating() if let title = doc.title { self.navigationItem.title = title } self.webView.loadHTMLString(html, baseURL: Bundle.main.bundleURL) } } private func renderFallback(response: GeminiResponse) { guard let body = response.body, let mimeType = response.mimeType else { self.showError(message: "Unknown error: \(response.header)") return } DispatchQueue.main.async { self.webView.isHidden = false self.errorStack.isHidden = true self.activityIndicator.isHidden = true self.activityIndicator.stopAnimating() self.loadedFallback = true if mimeType == "text/plain", let bodyText = response.bodyText { let html = BrowserWebViewController.preamble + "
" + bodyText + "
" + BrowserWebViewController.postamble self.webView.loadHTMLString(html, baseURL: Bundle.main.bundleURL) } else { self.webView.load(body, mimeType: mimeType, characterEncodingName: response.encodingName ?? "utf-8", baseURL: self.url) // When showing an image, the safe area insets seem to be ignored. This isn't perfect // (there's a little extra space between the bottom of the nav bar and the top of the image), // but it's better than the image being obscured. self.webView.scrollView.contentInset = self.webView.safeAreaInsets } } } func scrollToLine(index: Int, animated: Bool) { if animated { webView.evaluateJavaScript("document.getElementById('l\(index)').getBoundingClientRect().top + window.scrollY") { (result, error) in guard let result = result as? CGFloat else { return } let scrollView = self.webView.scrollView let y = result * scrollView.zoomScale - scrollView.safeAreaInsets.top let maxY = scrollView.contentSize.height - scrollView.bounds.height + scrollView.safeAreaInsets.bottom let finalOffsetY = min(y, maxY) UIView.animate(withDuration: 0.25, delay: 0, options: []) { self.webView.scrollView.setContentOffset(CGPoint(x: 0, y: finalOffsetY), animated: false) } completion: { _ in // calling focus() causes VoiceOver to move to that element self.webView.evaluateJavaScript("document.getElementById('l\(index)').focus();") } } } else { webView.evaluateJavaScript(""" const el = document.getElementById('l\(index)'); el.scrollIntoView(); el.focus(); """) } } private static let preamble = """ """ private static let symbolStyles = SymbolCache.symbols.map { (k, v) in ".symbol.\(k.replacingOccurrences(of: ".", with: "-")) { background-image: url(\"data:image/png;base64,\(v)\"); }" }.joined(separator: "\n") private static let postamble = """ """ } extension BrowserWebViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { let url = navigationAction.request.url! if url.scheme == "file" { decisionHandler(.allow) } else if loadedFallback { decisionHandler(.allow) } else { decisionHandler(.cancel) navigator.changeURL(url) loadDocument() } } } extension BrowserWebViewController: WKUIDelegate { func webView(_ webView: WKWebView, contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) { guard let url = elementInfo.linkURL, url.scheme == "gemini" || url.scheme == "http" || url.scheme == "https" else { completionHandler(nil) return } let config = UIContextMenuConfiguration(identifier: nil) { if url.scheme == "gemini" { return BrowserWebViewController(navigator: self.navigator, url: url) } else { return SFSafariViewController(url: url) } } actionProvider: { (_) in guard #available(iOS 15.0, *), url.scheme == "gemini" else { return nil } return UIMenu(children: [ UIWindowScene.ActivationAction({ (_) in let options = UIWindowScene.ActivationRequestOptions() // automatic presents in the prominent style even when a fullscreen window is the only existing one options.preferredPresentationStyle = .standard return UIWindowScene.ActivationConfiguration(userActivity: NSUserActivity(geminiURL: url), options: options, preview: nil) }) ]) } completionHandler(config) } func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) { animator.preferredCommitStyle = .pop let url = elementInfo.linkURL! animator.addCompletion { if url.scheme == "http" || url.scheme == "https" { self.present(animator.previewViewController!, animated: true) } else { self.navigator.changeURL(url) } } } }