Compare commits

...

6 Commits

6 changed files with 84 additions and 16 deletions

View File

@ -128,11 +128,13 @@ class BrowserNavigationController: UIViewController {
private func onNavigate(_ operation: NavigationManager.Operation) { private func onNavigate(_ operation: NavigationManager.Operation) {
let newVC: BrowserWebViewController let newVC: BrowserWebViewController
var postAccessibilityNotification = false
switch operation { switch operation {
case .go: case .go:
backBrowserVCs.append(currentBrowserVC) backBrowserVCs.append(currentBrowserVC)
newVC = BrowserWebViewController(navigator: navigator, url: navigator.currentURL) newVC = BrowserWebViewController(navigator: navigator, url: navigator.currentURL)
postAccessibilityNotification = true
case .reload: case .reload:
currentBrowserVC.reload() currentBrowserVC.reload()
@ -163,6 +165,10 @@ class BrowserNavigationController: UIViewController {
self.toolbarOffset = 0 self.toolbarOffset = 0
} }
if postAccessibilityNotification {
// this moves focus to the nav bar, which isn't ideal, but it's better than nothing
UIAccessibility.post(notification: .screenChanged, argument: nil)
}
} }
private let startEdgeNavigationSwipeDistance: CGFloat = 75 private let startEdgeNavigationSwipeDistance: CGFloat = 75

View File

@ -151,6 +151,7 @@ class BrowserWebViewController: UIViewController {
func reload() { func reload() {
loaded = false loaded = false
loadedFallback = false loadedFallback = false
document = nil
loadDocument() loadDocument()
} }
@ -189,6 +190,10 @@ class BrowserWebViewController: UIViewController {
} else { } else {
self.renderFallback(response: response) self.renderFallback(response: response)
} }
} else if response.status.isInput {
DispatchQueue.main.async {
self.showInputPrompt(response: response)
}
} else { } else {
DispatchQueue.main.async { DispatchQueue.main.async {
self.showError(message: "Unknown error: \(response.header)") self.showError(message: "Unknown error: \(response.header)")
@ -208,6 +213,20 @@ class BrowserWebViewController: UIViewController {
errorMessageLabel.text = message 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) { private func renderDocument(_ doc: Document) {
self.document = doc self.document = doc
@ -240,14 +259,20 @@ class BrowserWebViewController: UIViewController {
self.loadedFallback = true self.loadedFallback = true
// todo: probably shouldn't assume this is UTF-8 if mimeType == "text/plain",
self.webView.load(body, mimeType: mimeType, characterEncodingName: "utf-8", baseURL: self.url) 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 // 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), // (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. // but it's better than the image being obscured.
self.webView.scrollView.contentInset = self.webView.safeAreaInsets self.webView.scrollView.contentInset = self.webView.safeAreaInsets
} }
} }
}
func scrollToLine(index: Int, animated: Bool) { func scrollToLine(index: Int, animated: Bool) {
if animated { if animated {
@ -259,11 +284,21 @@ class BrowserWebViewController: UIViewController {
let y = result * scrollView.zoomScale - scrollView.safeAreaInsets.top let y = result * scrollView.zoomScale - scrollView.safeAreaInsets.top
let maxY = scrollView.contentSize.height - scrollView.bounds.height + scrollView.safeAreaInsets.bottom let maxY = scrollView.contentSize.height - scrollView.bounds.height + scrollView.safeAreaInsets.bottom
let finalOffsetY = min(y, maxY) let finalOffsetY = min(y, maxY)
self.webView.scrollView.setContentOffset(CGPoint(x: 0, y: finalOffsetY), animated: true) 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 { } else {
webView.evaluateJavaScript("document.getElementById('l\(index)').scrollIntoView();") webView.evaluateJavaScript("""
const el = document.getElementById('l\(index)');
el.scrollIntoView();
el.focus();
""")
} }
} }
private static let preamble = """ private static let preamble = """

View File

@ -39,6 +39,7 @@ class NavigationBarView: UIView {
textField = UITextField() textField = UITextField()
textField.text = navigator.displayURL textField.text = navigator.displayURL
textField.borderStyle = .roundedRect textField.borderStyle = .roundedRect
textField.textContentType = .URL
textField.keyboardType = .URL textField.keyboardType = .URL
textField.returnKeyType = .go textField.returnKeyType = .go
textField.autocapitalizationType = .none textField.autocapitalizationType = .none

View File

@ -14,6 +14,11 @@ pre {
tab-size: 4; tab-size: 4;
} }
pre.plaintext {
word-wrap: break-word;
white-space: pre-wrap;
}
h1, h2, h3 { h1, h2, h3 {
line-height: 1; line-height: 1;
font-weight: normal; font-weight: normal;

View File

@ -14,6 +14,8 @@ class GeminiProtocol: NWProtocolFramerImplementation {
private var tempStatusCode: GeminiResponseHeader.StatusCode? private var tempStatusCode: GeminiResponseHeader.StatusCode?
private var tempMeta: String? private var tempMeta: String?
private var lastAttemptedMetaLength: Int?
private var lastFoundCR = false
required init(framer: NWProtocolFramer.Instance) { required init(framer: NWProtocolFramer.Instance) {
} }
@ -45,13 +47,21 @@ class GeminiProtocol: NWProtocolFramerImplementation {
return 3 return 3
} }
var attemptedMetaLength: Int?
if tempMeta == nil { if tempMeta == nil {
let min: Int
// if we previously tried to get the meta but failed (because the <CR><LF> was not found,
// the minimum amount we need before trying to parse is at least 1 or 2 (depending on whether we found the <CR>) bytes more
if let lastAttemptedMetaLength = lastAttemptedMetaLength {
min = lastAttemptedMetaLength + (lastFoundCR ? 1 : 2)
} else {
// Minimum length is 2 bytes, spec does not say meta string is required // Minimum length is 2 bytes, spec does not say meta string is required
_ = framer.parseInput(minimumIncompleteLength: 2, maximumLength: 1024 + 2) { (buffer, isComplete) -> Int in min = 2
}
_ = framer.parseInput(minimumIncompleteLength: min, maximumLength: 1024 + 2) { (buffer, isComplete) -> Int in
guard let buffer = buffer, guard let buffer = buffer,
buffer.count >= 2 else { return 0 } buffer.count >= 2 else { return 0 }
attemptedMetaLength = buffer.count print("got count: \(buffer.count)")
self.lastAttemptedMetaLength = buffer.count
let lastPossibleCRIndex = buffer.index(before: buffer.index(before: buffer.endIndex)) let lastPossibleCRIndex = buffer.index(before: buffer.index(before: buffer.endIndex))
var index = buffer.startIndex var index = buffer.startIndex
@ -66,6 +76,10 @@ class GeminiProtocol: NWProtocolFramerImplementation {
} }
if !found { if !found {
if buffer[index] == 13 {
// if we found <CR>, but not <LF>, save that info so that next time we only wait for 1 more byte instead of 2
self.lastFoundCR = true
}
if buffer.count < 1026 { if buffer.count < 1026 {
return 0 return 0
} else { } else {
@ -78,8 +92,8 @@ class GeminiProtocol: NWProtocolFramerImplementation {
} }
} }
guard let meta = tempMeta else { guard let meta = tempMeta else {
if let attempted = attemptedMetaLength { if let attempted = self.lastAttemptedMetaLength {
return attempted + 1 return attempted + (lastFoundCR ? 1 : 2)
} else { } else {
return 2 return 2
} }

View File

@ -38,10 +38,17 @@ public struct GeminiResponse {
return UTType.types(tag: mimeType, tagClass: .mimeType, conformingTo: nil).first return UTType.types(tag: mimeType, tagClass: .mimeType, conformingTo: nil).first
} }
public var encodingName: String? {
guard let parameters = mimeTypeParameters else {
return nil
}
return parameters["charset"]
}
public var bodyText: String? { public var bodyText: String? {
guard let body = body, let parameters = mimeTypeParameters else { return nil } guard let body = body else { return nil }
let encoding: String.Encoding let encoding: String.Encoding
switch parameters["charset"]?.lowercased() { switch encodingName?.lowercased() {
case nil, "utf-8": case nil, "utf-8":
// The Gemini spec defines UTF-8 to be the default charset. // The Gemini spec defines UTF-8 to be the default charset.
encoding = .utf8 encoding = .utf8