Compare commits

..

18 Commits

Author SHA1 Message Date
Shadowfacts 3055cc339f Bump build number and update changelog 2021-06-18 11:17:21 -04:00
Shadowfacts fccce078b2 Fix VoiceOver focus staying in place after navigating 2021-06-17 23:30:50 -04:00
Shadowfacts 984ecc8879 Change VoiceOver focus after jumping from table of contents
Fixes #6
2021-06-17 23:13:33 -04:00
Shadowfacts 1ce48bc77e Fix connecting to tx.decrypt.fail 2021-06-17 22:58:46 -04:00
Shadowfacts 8c43bc8a44 Fix plain text responses not being visible in dark mode 2021-06-17 22:35:50 -04:00
Shadowfacts de68ecbe4b Don't assume character encoding when loading in fallback mode 2021-06-17 22:20:02 -04:00
Shadowfacts 5d2fb53510 Add input handling on iOS 2021-06-17 22:15:18 -04:00
Shadowfacts 255e5d7ff4 Fix crash when parsing invalid URLs 2021-06-15 23:33:40 -04:00
Shadowfacts dd0dcbde9c Fix crash when clearing URL field and pressing Go 2021-06-15 23:33:29 -04:00
Shadowfacts e5f520cf6f Add homepage preference 2021-06-15 23:21:22 -04:00
Shadowfacts 8183e6986a Add preference to turn off link icons 2021-06-15 22:44:50 -04:00
Shadowfacts 6a8c5b56fe Use AnyObject instead of class for protocol 2021-06-15 22:37:01 -04:00
Shadowfacts 188599dc01 Add more detailed metadata to share sheet 2021-06-15 22:31:32 -04:00
Shadowfacts a07d73cfe1 Update resolved packages 2021-06-15 22:31:32 -04:00
Shadowfacts 4bb2d064d1 GeminiProtocol minor cleanup 2021-06-15 22:31:23 -04:00
Shadowfacts e79cdcdb57 Bump build number 2021-03-25 22:28:53 -04:00
Shadowfacts b6eef7b317 Fix toolbar animation getting stuck partway 2020-12-22 21:17:42 -05:00
Shadowfacts 33e1f0dca6 Add Document.title test 2020-12-21 21:35:51 -05:00
20 changed files with 402 additions and 55 deletions

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
import Combine import Combine
public protocol NavigationManagerDelegate: class { public protocol NavigationManagerDelegate: AnyObject {
func loadNonGeminiURL(_ url: URL) func loadNonGeminiURL(_ url: URL)
} }

View File

@ -1,5 +1,20 @@
# Changelog # Changelog
# 2021.1 (8)
Features/Improvements:
- Add input response handling
- Add link icons preference
- Add homepage preference
Bugfixes:
- Fix crash when pressing Go with empty URL field
- Fix crash when parsing invalid URLs in responses
- Fix plaintext responses not being readable in dark mode
- Fix loading non-UTF-8 plaintext responses
- Fix connecting to certain Gemini servers (e.g., tx.decrypt.fail)
- Move VoiceOver focus after select a ToC entry
- Move VoiceOver to top of screen after selecting a link
# 2021.1 (5) # 2021.1 (5)
This is a major update, as the UI code has been rewritten completely from scratch and is much more pleasant to use! This is a major update, as the UI code has been rewritten completely from scratch and is much more pleasant to use!

View File

@ -0,0 +1,34 @@
//
// ActivityItemSource.swift
// Gemini-iOS
//
// Created by Shadowfacts on 6/15/21.
//
import UIKit
import GeminiFormat
import LinkPresentation
class ActivityItemSource: NSObject, UIActivityItemSource {
let document: Document
init(document: Document) {
self.document = document
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
return document.url
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
return document.url
}
func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
let metadata = LPLinkMetadata()
metadata.url = document.url
metadata.title = document.title
return metadata
}
}

View File

@ -10,7 +10,7 @@ import UIKit
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
static let defaultHomepage = URL(string: "gemini://gemini.circumlunar.space/")!
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
SymbolCache.load() SymbolCache.load()

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
@ -303,7 +309,8 @@ class BrowserNavigationController: UIViewController {
} }
private func showShareSheet(_ source: UIView) { private func showShareSheet(_ source: UIView) {
let vc = UIActivityViewController(activityItems: [navigator.currentURL], applicationActivities: nil) guard let doc = currentBrowserVC.document else { return }
let vc = UIActivityViewController(activityItems: [ActivityItemSource(document: doc)], applicationActivities: [SetHomepageActivity()])
vc.popoverPresentationController?.sourceView = source vc.popoverPresentationController?.sourceView = source
present(vc, animated: true) present(vc, animated: true)
} }
@ -355,7 +362,7 @@ extension BrowserNavigationController: UIScrollViewDelegate {
guard trackingScroll else { return } guard trackingScroll else { return }
trackingScroll = false trackingScroll = false
if velocity.y == 0 { if velocity.y == 0 && (toolbarOffset == 0 || toolbarOffset == 1) {
return return
} }

View File

@ -12,6 +12,7 @@ import GeminiProtocol
import GeminiFormat import GeminiFormat
import GeminiRenderer import GeminiRenderer
import SafariServices import SafariServices
import Combine
class BrowserWebViewController: UIViewController { class BrowserWebViewController: UIViewController {
@ -32,6 +33,8 @@ class BrowserWebViewController: UIViewController {
private var loaded = false private var loaded = false
private var loadedFallback = false private var loadedFallback = false
private var cancellables = Set<AnyCancellable>()
private var errorStack: UIStackView! private var errorStack: UIStackView!
private var errorMessageLabel: UILabel! private var errorMessageLabel: UILabel!
private var activityIndicator: UIActivityIndicatorView! private var activityIndicator: UIActivityIndicatorView!
@ -51,25 +54,7 @@ class BrowserWebViewController: UIViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
let documentURL = self.url configureRenderer()
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 view.backgroundColor = .systemBackground
@ -122,6 +107,14 @@ class BrowserWebViewController: UIViewController {
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), 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) { override func viewWillAppear(_ animated: Bool) {
@ -130,9 +123,35 @@ class BrowserWebViewController: UIViewController {
loadDocument() 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() { func reload() {
loaded = false loaded = false
loadedFallback = false loadedFallback = false
document = nil
loadDocument() loadDocument()
} }
@ -171,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)")
@ -190,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
@ -222,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 {
@ -241,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

@ -0,0 +1,96 @@
//
// HomepagePrefView.swift
// Gemini-iOS
//
// Created by Shadowfacts on 6/15/21.
//
import SwiftUI
struct HomepagePrefView: View {
@State private var url: URL? = Preferences.shared.homepage
@State private var focus = false
var body: some View {
List {
// use a custom binding to allow the state variable to be nil temporarily, and update the pref when not
HomepagePrefTextField(value: Binding(get: {
url
}, set: { (newVal) in
self.url = newVal
if let newVal = newVal {
Preferences.shared.homepage = newVal
}
}), focus: $focus)
}
.navigationBarTitle("Homepage")
.onAppear {
focus = true
}
.onDisappear {
// if the text field was empty when we disappeared, reset to the default homepage
if url == nil {
Preferences.shared.homepage = AppDelegate.defaultHomepage
}
} }
}
struct HomepagePrefTextField: UIViewRepresentable {
@Binding private var value: URL?
@Binding private var focus: Bool
init(value: Binding<URL?>, focus: Binding<Bool>) {
self._value = value
self._focus = focus
}
func makeUIView(context: Context) -> UITextField {
let field = UITextField()
field.addTarget(context.coordinator, action: #selector(Coordinator.textChanged), for: .editingChanged)
field.clearButtonMode = .whileEditing
field.placeholder = AppDelegate.defaultHomepage.absoluteString
// fix for text field expanding horizontally when text grows
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return field
}
func updateUIView(_ uiView: UITextField, context: Context) {
uiView.text = value?.absoluteString
context.coordinator.binding = $value
if focus {
DispatchQueue.main.async {
uiView.becomeFirstResponder()
focus = false
}
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(binding: $value)
}
class Coordinator: NSObject {
var binding: Binding<URL?>
init(binding: Binding<URL?>) {
self.binding = binding
}
@objc func textChanged(_ textField: UITextField) {
if let text = textField.text,
!text.isEmpty {
if let url = URL(string: text) {
binding.wrappedValue = url
}
} else {
binding.wrappedValue = nil
}
}
}
}
struct HomepagePrefView_Previews: PreviewProvider {
static var previews: some View {
HomepagePrefView()
}
}

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
@ -77,7 +78,9 @@ class NavigationBarView: UIView {
@objc private func commitURL() { @objc private func commitURL() {
textField.resignFirstResponder() textField.resignFirstResponder()
if let text = textField.text, var components = URLComponents(string: text) { if let text = textField.text,
!text.isEmpty,
var components = URLComponents(string: text) {
if components.scheme == nil { if components.scheme == nil {
components.scheme = "gemini" components.scheme = "gemini"
} }

View File

@ -34,7 +34,14 @@ class Preferences: Codable, ObservableObject {
required init(from decoder: Decoder) throws { required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
if let stored = try container.decodeIfPresent(URL.self, forKey: .homepage) {
homepage = stored
}
theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme) theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
if let stored = try container.decodeIfPresent(Bool.self, forKey: .showLinkIcons) {
showLinkIcons = stored
}
useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari) useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
useReaderMode = try container.decode(Bool.self, forKey: .useReaderMode) useReaderMode = try container.decode(Bool.self, forKey: .useReaderMode)
@ -43,19 +50,28 @@ class Preferences: Codable, ObservableObject {
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(homepage, forKey: .homepage)
try container.encode(theme, forKey: .theme) try container.encode(theme, forKey: .theme)
try container.encode(showLinkIcons, forKey: .showLinkIcons)
try container.encode(useInAppSafari, forKey: .useInAppSafari) try container.encode(useInAppSafari, forKey: .useInAppSafari)
try container.encode(useReaderMode, forKey: .useReaderMode) try container.encode(useReaderMode, forKey: .useReaderMode)
} }
@Published var homepage = AppDelegate.defaultHomepage
@Published var theme = UIUserInterfaceStyle.unspecified @Published var theme = UIUserInterfaceStyle.unspecified
@Published var showLinkIcons = true
@Published var useInAppSafari = false @Published var useInAppSafari = false
@Published var useReaderMode = false @Published var useReaderMode = false
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case homepage
case theme case theme
case showLinkIcons
case useInAppSafari case useInAppSafari
case useReaderMode case useReaderMode

View File

@ -15,6 +15,8 @@ struct PreferencesView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
List { List {
untitledSection
appearanceSection appearanceSection
safariSection safariSection
@ -23,7 +25,7 @@ struct PreferencesView: View {
.insetOrGroupedListStyle() .insetOrGroupedListStyle()
.navigationBarItems(trailing: doneButton) .navigationBarItems(trailing: doneButton)
} }
.navigationViewStyle(StackNavigationViewStyle()) .navigationViewStyle(.stack)
.onDisappear { .onDisappear {
Preferences.save() Preferences.save()
} }
@ -38,6 +40,15 @@ struct PreferencesView: View {
.hoverEffect(.highlight) .hoverEffect(.highlight)
} }
private var untitledSection: some View {
Section {
NavigationLink(destination: HomepagePrefView()) {
Text("Homepage")
}
}
}
private var appearanceSection: some View { private var appearanceSection: some View {
Section(header: Text("Appearance")) { Section(header: Text("Appearance")) {
Picker(selection: $preferences.theme, label: Text("Theme")) { Picker(selection: $preferences.theme, label: Text("Theme")) {
@ -45,6 +56,8 @@ struct PreferencesView: View {
Text("Always Light").tag(UIUserInterfaceStyle.light) Text("Always Light").tag(UIUserInterfaceStyle.light)
Text("Always Dark").tag(UIUserInterfaceStyle.dark) Text("Always Dark").tag(UIUserInterfaceStyle.dark)
} }
Toggle("Show Link Icons", isOn: $preferences.showLinkIcons)
} }
} }
@ -62,9 +75,9 @@ fileprivate extension View {
@ViewBuilder @ViewBuilder
func insetOrGroupedListStyle() -> some View { func insetOrGroupedListStyle() -> some View {
if #available(iOS 14.0, *) { if #available(iOS 14.0, *) {
self.listStyle(InsetGroupedListStyle()) self.listStyle(.insetGrouped)
} else { } else {
self.listStyle(GroupedListStyle()) self.listStyle(.grouped)
} }
} }
} }

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

@ -32,7 +32,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
if ProcessInfo.processInfo.environment.keys.contains("DEFAULT_URL") { if ProcessInfo.processInfo.environment.keys.contains("DEFAULT_URL") {
initialURL = URL(string: ProcessInfo.processInfo.environment["DEFAULT_URL"]!)! initialURL = URL(string: ProcessInfo.processInfo.environment["DEFAULT_URL"]!)!
} else { } else {
initialURL = URL(string: "gemini://gemini.circumlunar.space/")! initialURL = Preferences.shared.homepage
} }
} }

View File

@ -0,0 +1,38 @@
//
// SetHomepageActivity.swift
// Gemini-iOS
//
// Created by Shadowfacts on 6/15/21.
//
import UIKit
class SetHomepageActivity: UIActivity {
override class var activityCategory: UIActivity.Category {
return .action
}
override var activityTitle: String? {
return "Set as Homepage"
}
override var activityImage: UIImage? {
// large size more closely matches system activity images
return UIImage(systemName: "house", withConfiguration: UIImage.SymbolConfiguration(scale: .large))
}
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
for case let url as URL in activityItems {
return Preferences.shared.homepage != url
}
return false
}
override func prepare(withActivityItems activityItems: [Any]) {
for case let url as URL in activityItems {
Preferences.shared.homepage = url
break
}
}
}

View File

@ -36,6 +36,9 @@
D62664EE24BC0BCE00DF9B88 /* MaybeLazyVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62664ED24BC0BCE00DF9B88 /* MaybeLazyVStack.swift */; }; D62664EE24BC0BCE00DF9B88 /* MaybeLazyVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62664ED24BC0BCE00DF9B88 /* MaybeLazyVStack.swift */; };
D62664F024BC0D7700DF9B88 /* GeminiFormat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D62664A824BBF26A00DF9B88 /* GeminiFormat.framework */; }; D62664F024BC0D7700DF9B88 /* GeminiFormat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D62664A824BBF26A00DF9B88 /* GeminiFormat.framework */; };
D62664FA24BC12BC00DF9B88 /* DocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62664F924BC12BC00DF9B88 /* DocumentTests.swift */; }; D62664FA24BC12BC00DF9B88 /* DocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62664F924BC12BC00DF9B88 /* DocumentTests.swift */; };
D653F40B267996FF004E32B1 /* ActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F40A267996FF004E32B1 /* ActivityItemSource.swift */; };
D653F40D26799F2F004E32B1 /* HomepagePrefView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F40C26799F2F004E32B1 /* HomepagePrefView.swift */; };
D653F40F2679A0AB004E32B1 /* SetHomepageActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D653F40E2679A0AB004E32B1 /* SetHomepageActivity.swift */; };
D664673624BD07F700B0B741 /* RenderingBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673524BD07F700B0B741 /* RenderingBlock.swift */; }; D664673624BD07F700B0B741 /* RenderingBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673524BD07F700B0B741 /* RenderingBlock.swift */; };
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673724BD086F00B0B741 /* RenderingBlockView.swift */; }; D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673724BD086F00B0B741 /* RenderingBlockView.swift */; };
D664673A24BD0B8E00B0B741 /* Fonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673924BD0B8E00B0B741 /* Fonts.swift */; }; D664673A24BD0B8E00B0B741 /* Fonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673924BD0B8E00B0B741 /* Fonts.swift */; };
@ -303,6 +306,9 @@
D62664EB24BC0B4D00DF9B88 /* DocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentView.swift; sourceTree = "<group>"; }; D62664EB24BC0B4D00DF9B88 /* DocumentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentView.swift; sourceTree = "<group>"; };
D62664ED24BC0BCE00DF9B88 /* MaybeLazyVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaybeLazyVStack.swift; sourceTree = "<group>"; }; D62664ED24BC0BCE00DF9B88 /* MaybeLazyVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaybeLazyVStack.swift; sourceTree = "<group>"; };
D62664F924BC12BC00DF9B88 /* DocumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentTests.swift; sourceTree = "<group>"; }; D62664F924BC12BC00DF9B88 /* DocumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentTests.swift; sourceTree = "<group>"; };
D653F40A267996FF004E32B1 /* ActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityItemSource.swift; sourceTree = "<group>"; };
D653F40C26799F2F004E32B1 /* HomepagePrefView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomepagePrefView.swift; sourceTree = "<group>"; };
D653F40E2679A0AB004E32B1 /* SetHomepageActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetHomepageActivity.swift; sourceTree = "<group>"; };
D664673524BD07F700B0B741 /* RenderingBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlock.swift; sourceTree = "<group>"; }; D664673524BD07F700B0B741 /* RenderingBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlock.swift; sourceTree = "<group>"; };
D664673724BD086F00B0B741 /* RenderingBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlockView.swift; sourceTree = "<group>"; }; D664673724BD086F00B0B741 /* RenderingBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlockView.swift; sourceTree = "<group>"; };
D664673924BD0B8E00B0B741 /* Fonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fonts.swift; sourceTree = "<group>"; }; D664673924BD0B8E00B0B741 /* Fonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fonts.swift; sourceTree = "<group>"; };
@ -593,6 +599,9 @@
D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */, D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */,
D691A64D25217C6F00348C4B /* Preferences.swift */, D691A64D25217C6F00348C4B /* Preferences.swift */,
D691A66625217FD800348C4B /* PreferencesView.swift */, D691A66625217FD800348C4B /* PreferencesView.swift */,
D653F40C26799F2F004E32B1 /* HomepagePrefView.swift */,
D653F40A267996FF004E32B1 /* ActivityItemSource.swift */,
D653F40E2679A0AB004E32B1 /* SetHomepageActivity.swift */,
D688F618258AD231003A0A73 /* Resources */, D688F618258AD231003A0A73 /* Resources */,
D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */, D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */,
D6E152AF24BFFDF600FDF9D3 /* LaunchScreen.storyboard */, D6E152AF24BFFDF600FDF9D3 /* LaunchScreen.storyboard */,
@ -1111,11 +1120,14 @@
D688F633258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift in Sources */, D688F633258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift in Sources */,
D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */, D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */,
D6E152A724BFFDF500FDF9D3 /* SceneDelegate.swift in Sources */, D6E152A724BFFDF500FDF9D3 /* SceneDelegate.swift in Sources */,
D653F40B267996FF004E32B1 /* ActivityItemSource.swift in Sources */,
D6BC9AB3258E8E13008652BC /* ToolbarView.swift in Sources */, D6BC9AB3258E8E13008652BC /* ToolbarView.swift in Sources */,
D688F64A258C17F3003A0A73 /* SymbolCache.swift in Sources */, D688F64A258C17F3003A0A73 /* SymbolCache.swift in Sources */,
D653F40F2679A0AB004E32B1 /* SetHomepageActivity.swift in Sources */,
D688F65A258C2256003A0A73 /* BrowserNavigationController.swift in Sources */, D688F65A258C2256003A0A73 /* BrowserNavigationController.swift in Sources */,
D6BC9AD7258FC8B3008652BC /* TableOfContentsView.swift in Sources */, D6BC9AD7258FC8B3008652BC /* TableOfContentsView.swift in Sources */,
D688F663258C2479003A0A73 /* UIViewController+Children.swift in Sources */, D688F663258C2479003A0A73 /* UIViewController+Children.swift in Sources */,
D653F40D26799F2F004E32B1 /* HomepagePrefView.swift in Sources */,
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */, D691A64E25217C6F00348C4B /* Preferences.swift in Sources */,
D6BC9ABC258E9862008652BC /* NavigationBarView.swift in Sources */, D6BC9ABC258E9862008652BC /* NavigationBarView.swift in Sources */,
); );
@ -1744,7 +1756,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5; CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_ASSET_PATHS = "\"Gemini-iOS/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Gemini-iOS/Preview Content\"";
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -1770,7 +1782,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 5; CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_ASSET_PATHS = "\"Gemini-iOS/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Gemini-iOS/Preview Content\"";
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;

View File

@ -2,7 +2,7 @@
"object": { "object": {
"pins": [ "pins": [
{ {
"package": "HTMLEntities", "package": "swift-html-entities",
"repositoryURL": "https://github.com/Kitura/swift-html-entities", "repositoryURL": "https://github.com/Kitura/swift-html-entities",
"state": { "state": {
"branch": null, "branch": null,

View File

@ -44,8 +44,6 @@ public struct GeminiParser {
// URL(string:relativeTo:) does not handle // meaning the same protocol as the base URL // URL(string:relativeTo:) does not handle // meaning the same protocol as the base URL
urlString = baseURL.scheme! + ":" + urlString urlString = baseURL.scheme! + ":" + urlString
} }
// todo: if the URL initializer fails, should there be a .link line with a nil URL?
let url = URL(string: urlString, relativeTo: baseURL)!.absoluteURL
let text: String? let text: String?
if textStart < line.endIndex { if textStart < line.endIndex {
@ -54,7 +52,18 @@ public struct GeminiParser {
text = nil text = nil
} }
if let url = URL(string: urlString, relativeTo: baseURL)?.absoluteURL {
doc.lines.append(.link(url, text: text)) doc.lines.append(.link(url, text: text))
} else {
let str: String
if let text = text {
// todo: localize me?
str = "\(text): \(urlString)"
} else {
str = urlString
}
doc.lines.append(.text(str))
}
} else if line.starts(with: "#") { } else if line.starts(with: "#") {
let level: Document.HeadingLevel let level: Document.HeadingLevel
if line.starts(with: "###") { if line.starts(with: "###") {

View File

@ -53,4 +53,28 @@ test
""") """)
} }
func testGetTitle() {
let h1 = Document(url: URL(string: "gemini://example.com/")!, lines: [
.heading("Test", level: .h1)
])
XCTAssertEqual(h1.title, "Test")
let h2 = Document(url: URL(string: "gemini://example.com/")!, lines: [
.heading("Test", level: .h2)
])
XCTAssertEqual(h2.title, "Test")
let h3 = Document(url: URL(string: "gemini://example.com/")!, lines: [
.heading("Test", level: .h3)
])
XCTAssertEqual(h3.title, "Test")
let multiple = Document(url: URL(string: "gemini://example.com/")!, lines: [
.heading("Two", level: .h2),
.heading("One", level: .h1),
.heading("Three", level: .h3),
])
XCTAssertEqual(multiple.title, "Two")
}
} }

View File

@ -79,6 +79,7 @@ public class GeminiDataTask {
self.completion(.failure(.connectionError(error))) self.completion(.failure(.connectionError(error)))
} else if let message = context?.protocolMetadata(definition: GeminiProtocol.definition) as? NWProtocolFramer.Message, } else if let message = context?.protocolMetadata(definition: GeminiProtocol.definition) as? NWProtocolFramer.Message,
let header = message.geminiResponseHeader { let header = message.geminiResponseHeader {
guard isComplete else { fatalError() }
let response = GeminiResponse(header: header, body: data) let response = GeminiResponse(header: header, body: data)
self.completion(.success(response)) self.completion(.success(response))
} }

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
} }
@ -88,12 +102,12 @@ class GeminiProtocol: NWProtocolFramerImplementation {
let header = GeminiResponseHeader(status: statusCode, meta: meta) let header = GeminiResponseHeader(status: statusCode, meta: meta)
let message = NWProtocolFramer.Message(geminiResponseHeader: header) let message = NWProtocolFramer.Message(geminiResponseHeader: header)
// What does the return value of deliverInputNoCopy mean, you ask? Why, I have no idea // Deliver all the input (the response body) to the client without copying.
// It always returns true for a length of zero, so following the sample code and looping
// infinitely until it returns false causes an infinite loop.
// Additionally, calling deliverInput with an empty Data() causes an error inside Network.framework.
// So, we just ignore the result since it doesn't seem to cause any problems ¯\_()_/¯
_ = framer.deliverInputNoCopy(length: statusCode.isSuccess ? .max : 0, message: message, isComplete: true) _ = framer.deliverInputNoCopy(length: statusCode.isSuccess ? .max : 0, message: message, isComplete: true)
// Just in case, set the framer to pass-through input so it never invokes this method again.
// todo: this should work according to an apple engineer, but the request seems to hang forever on a real device
// sometimes works fine when stepping through w/ debugger => race condition?
// framer.passThroughInput()
return 0 return 0
} }

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