Gemini/Gemini-iOS/BrowserWebViewController.swift

274 lines
10 KiB
Swift

//
// BrowserWebViewController.swift
// Gemini-iOS
//
// Created by Shadowfacts on 12/16/20.
//
import UIKit
import BrowserCore
import WebKit
import GeminiProtocol
import GeminiFormat
import GeminiRenderer
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 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 "<span class=\"symbol \(symbolClass)\" aria-hidden=\"true\"></span>"
}
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.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()
}
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 {
if let redirect = URL(string: response.meta) {
self.navigator.changeURL(redirect)
} else {
DispatchQueue.main.async {
self.showError(message: "Invalid redirect URL: '\(response.meta)'")
}
}
} else if response.status.isSuccess,
let text = response.bodyText {
self.renderDocument(GeminiParser.parse(text: text, baseURL: url))
} 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)
}
}
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 = """
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="stylesheet" href="\(Bundle.main.url(forResource: "browser", withExtension: "css")!.absoluteString)">
<style>
\(symbolStyles)
</style>
</head>
<body>
"""
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 = """
</body>
</html>
"""
}
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 {
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" else {
completionHandler(nil)
return
}
let config = UIContextMenuConfiguration(identifier: nil) {
return BrowserWebViewController(navigator: self.navigator, url: url)
} actionProvider: { (_) in
return nil
}
completionHandler(config)
}
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
animator.preferredCommitStyle = .pop
animator.addCompletion {
self.navigator.changeURL(elementInfo.linkURL!)
}
}
}