// // 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 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 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) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() 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 "" } 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), ]) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) loadDocument() } func reload() { loaded = false loadedFallback = false 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 { self.renderDocument(GeminiParser.parse(text: text, baseURL: url)) } else { self.renderFallback(response: response) } } else { DispatchQueue.main.async { self.showError(message: "Unknown error: \(response.header)") } } } } task!.resume() } private func showError(message: String) { webView.isHidden = true errorStack.isHidden = false activityIndicator.isHidden = true activityIndicator.stopAnimating() errorMessageLabel.text = message } private func renderDocument(_ doc: Document) { self.document = doc 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 // todo: probably shouldn't assume this is UTF-8 self.webView.load(body, mimeType: mimeType, characterEncodingName: "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) self.webView.scrollView.setContentOffset(CGPoint(x: 0, y: finalOffsetY), animated: true) } } else { webView.evaluateJavaScript("document.getElementById('l\(index)').scrollIntoView();") } } 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 return 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) } } } }