Compare commits
18 Commits
7253b1218a
...
3055cc339f
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 3055cc339f | |
Shadowfacts | fccce078b2 | |
Shadowfacts | 984ecc8879 | |
Shadowfacts | 1ce48bc77e | |
Shadowfacts | 8c43bc8a44 | |
Shadowfacts | de68ecbe4b | |
Shadowfacts | 5d2fb53510 | |
Shadowfacts | 255e5d7ff4 | |
Shadowfacts | dd0dcbde9c | |
Shadowfacts | e5f520cf6f | |
Shadowfacts | 8183e6986a | |
Shadowfacts | 6a8c5b56fe | |
Shadowfacts | 188599dc01 | |
Shadowfacts | a07d73cfe1 | |
Shadowfacts | 4bb2d064d1 | |
Shadowfacts | e79cdcdb57 | |
Shadowfacts | b6eef7b317 | |
Shadowfacts | 33e1f0dca6 |
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -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!
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 = """
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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: "###") {
|
||||||
|
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue