Compare commits

..

8 Commits

16 changed files with 276 additions and 36 deletions

View File

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

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
class AppDelegate: UIResponder, UIApplicationDelegate {
static let defaultHomepage = URL(string: "gemini://gemini.circumlunar.space/")!
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
SymbolCache.load()

View File

@ -303,7 +303,8 @@ class BrowserNavigationController: UIViewController {
}
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
present(vc, animated: true)
}

View File

@ -12,6 +12,7 @@ import GeminiProtocol
import GeminiFormat
import GeminiRenderer
import SafariServices
import Combine
class BrowserWebViewController: UIViewController {
@ -32,6 +33,8 @@ class BrowserWebViewController: UIViewController {
private var loaded = false
private var loadedFallback = false
private var cancellables = Set<AnyCancellable>()
private var errorStack: UIStackView!
private var errorMessageLabel: UILabel!
private var activityIndicator: UIActivityIndicatorView!
@ -51,25 +54,7 @@ class BrowserWebViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
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>"
}
configureRenderer()
view.backgroundColor = .systemBackground
@ -122,6 +107,14 @@ class BrowserWebViewController: UIViewController {
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
Preferences.shared.$showLinkIcons
.sink { [weak self] newVal in
guard let self = self, let doc = self.document else { return }
self.configureRenderer(showLinkIcons: newVal)
self.renderDocument(doc)
}
.store(in: &cancellables)
}
override func viewWillAppear(_ animated: Bool) {
@ -130,6 +123,31 @@ class BrowserWebViewController: UIViewController {
loadDocument()
}
private func configureRenderer(showLinkIcons: Bool = Preferences.shared.showLinkIcons) {
if showLinkIcons {
let documentURL = self.url
renderer.linkPrefix = { (url: URL) -> String in
let symbolClass: String
if url.scheme == "gemini" {
if url.host == documentURL.host {
symbolClass = "arrow-right"
} else {
symbolClass = "link"
}
} else if url.scheme == "http" || url.scheme == "https" {
symbolClass = "safari"
} else if url.scheme == "mailto" {
symbolClass = "envelope"
} else {
symbolClass = "arrow-up-left-square"
}
return "<span class=\"symbol \(symbolClass)\" aria-hidden=\"true\"></span>"
}
} else {
renderer.linkPrefix = nil
}
}
func reload() {
loaded = false
loadedFallback = false

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

@ -77,7 +77,9 @@ class NavigationBarView: UIView {
@objc private func commitURL() {
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 {
components.scheme = "gemini"
}

View File

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

View File

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

View File

@ -32,7 +32,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
if ProcessInfo.processInfo.environment.keys.contains("DEFAULT_URL") {
initialURL = URL(string: ProcessInfo.processInfo.environment["DEFAULT_URL"]!)!
} 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 */; };
D62664F024BC0D7700DF9B88 /* GeminiFormat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D62664A824BBF26A00DF9B88 /* GeminiFormat.framework */; };
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 */; };
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673724BD086F00B0B741 /* RenderingBlockView.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>"; };
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>"; };
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>"; };
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>"; };
@ -593,6 +599,9 @@
D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */,
D691A64D25217C6F00348C4B /* Preferences.swift */,
D691A66625217FD800348C4B /* PreferencesView.swift */,
D653F40C26799F2F004E32B1 /* HomepagePrefView.swift */,
D653F40A267996FF004E32B1 /* ActivityItemSource.swift */,
D653F40E2679A0AB004E32B1 /* SetHomepageActivity.swift */,
D688F618258AD231003A0A73 /* Resources */,
D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */,
D6E152AF24BFFDF600FDF9D3 /* LaunchScreen.storyboard */,
@ -1111,11 +1120,14 @@
D688F633258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift in Sources */,
D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */,
D6E152A724BFFDF500FDF9D3 /* SceneDelegate.swift in Sources */,
D653F40B267996FF004E32B1 /* ActivityItemSource.swift in Sources */,
D6BC9AB3258E8E13008652BC /* ToolbarView.swift in Sources */,
D688F64A258C17F3003A0A73 /* SymbolCache.swift in Sources */,
D653F40F2679A0AB004E32B1 /* SetHomepageActivity.swift in Sources */,
D688F65A258C2256003A0A73 /* BrowserNavigationController.swift in Sources */,
D6BC9AD7258FC8B3008652BC /* TableOfContentsView.swift in Sources */,
D688F663258C2479003A0A73 /* UIViewController+Children.swift in Sources */,
D653F40D26799F2F004E32B1 /* HomepagePrefView.swift in Sources */,
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */,
D6BC9ABC258E9862008652BC /* NavigationBarView.swift in Sources */,
);

View File

@ -2,7 +2,7 @@
"object": {
"pins": [
{
"package": "HTMLEntities",
"package": "swift-html-entities",
"repositoryURL": "https://github.com/Kitura/swift-html-entities",
"state": {
"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
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?
if textStart < line.endIndex {
@ -54,7 +52,18 @@ public struct GeminiParser {
text = nil
}
doc.lines.append(.link(url, text: text))
if let url = URL(string: urlString, relativeTo: baseURL)?.absoluteURL {
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: "#") {
let level: Document.HeadingLevel
if line.starts(with: "###") {

View File

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

View File

@ -88,12 +88,12 @@ class GeminiProtocol: NWProtocolFramerImplementation {
let header = GeminiResponseHeader(status: statusCode, meta: meta)
let message = NWProtocolFramer.Message(geminiResponseHeader: header)
// What does the return value of deliverInputNoCopy mean, you ask? Why, I have no idea
// 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 ¯\_()_/¯
// Deliver all the input (the response body) to the client without copying.
_ = 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
}