Gemini/Gemini-iOS/BrowserWebViewController.swift

406 lines
16 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
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<AnyCancellable>()
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 "<span class=\"symbol \(symbolClass)\" aria-hidden=\"true\"></span>"
}
} 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 + "<pre class='plaintext'>" + bodyText + "</pre>" + 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 = """
<!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 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)
}
}
}
}