406 lines
16 KiB
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)
|
|
}
|
|
}
|
|
}
|
|
}
|