Compare commits

...

21 Commits

Author SHA1 Message Date
Shadowfacts fe9c42a2f8
Add toolbar customization 2021-10-16 00:16:53 -04:00
Shadowfacts bea574d7bb
Fix scroll indicator color in dark mode 2021-10-09 10:18:06 -04:00
Shadowfacts 32648808cb
Add Open Homepage intent 2021-10-09 10:17:49 -04:00
Shadowfacts 29e2589d5c
Add Homepage home screen shortcut item 2021-10-09 10:17:49 -04:00
Shadowfacts e470d7b58e
Add pull to refresh 2021-10-09 10:17:49 -04:00
Shadowfacts 86719b4528
Add preference for hiding toolbars 2021-10-09 10:17:49 -04:00
Shadowfacts b313f37170
Add all the intents 2021-10-09 10:17:49 -04:00
Shadowfacts e872af04a3
Move Markdown/HTML renderer to GeminiFormat 2021-10-09 10:17:48 -04:00
Shadowfacts 0d85c6f6ea
Fix HTML renderer inserting extra </ul>s 2021-10-02 10:49:41 -04:00
Shadowfacts d6ff2141dc
GeminiRenderer: Add Markdown renderer 2021-10-02 10:49:26 -04:00
Shadowfacts 806149ec1b
GeminiRenderer: Add HTML renderer tests 2021-10-01 19:27:45 -04:00
Shadowfacts a9327249eb
GeminiProtocl: Fix attribution not being set, and not compiling on macOS 2021-10-01 19:25:54 -04:00
Shadowfacts c59fe5b319
GeminiRenderer: allow disabling element classes/ids 2021-10-01 19:03:26 -04:00
Shadowfacts 2b82e3b545
Add Open URL intent 2021-09-30 11:06:03 -04:00
Shadowfacts 144695cc96
Add title to browse user activities 2021-09-30 11:05:54 -04:00
Shadowfacts 7dd4f3aa4c
Show page titles in back/forwards menus on iOS 15 2021-09-28 22:18:56 -04:00
Shadowfacts ebdd06738a
State restoration 2021-09-28 22:09:38 -04:00
Shadowfacts afa2a7b771
Fix retain cycle in toolbar 2021-09-28 21:03:23 -04:00
Shadowfacts c391a274e1
Support multiple windows 2021-09-28 20:57:06 -04:00
Shadowfacts e2cc4c4caa
Remove old logging statement 2021-09-28 20:56:58 -04:00
Shadowfacts 8c53685d21
Set request attribution to user 2021-09-28 20:20:08 -04:00
35 changed files with 3115 additions and 156 deletions

View File

@ -0,0 +1,24 @@
//
// BrowserHelper.swift
// BrowserCore
//
// Created by Shadowfacts on 9/30/21.
//
import Foundation
public struct BrowserHelper {
private init() {}
public static func urlForDisplay(_ url: URL) -> String {
var str = url.host!
if let port = url.port,
url.scheme != "gemini" || port != 1965 {
str += ":\(port)"
}
if url.path != "/" {
str += url.path
}
return str
}
}

View File

@ -6,19 +6,19 @@
//
import Foundation
import Combine
import Combine
public protocol NavigationManagerDelegate: AnyObject {
func loadNonGeminiURL(_ url: URL)
}
public class NavigationManager: NSObject, ObservableObject {
public class NavigationManager: NSObject, ObservableObject, Codable {
public weak var delegate: NavigationManagerDelegate?
@Published public var currentURL: URL
@Published public var backStack = [URL]()
@Published public var forwardStack = [URL]()
@Published public var backStack = [HistoryEntry]()
@Published public var forwardStack = [HistoryEntry]()
public let navigationOperation = PassthroughSubject<Operation, Never>()
@ -30,8 +30,30 @@ public class NavigationManager: NSObject, ObservableObject {
return components.string!
}
private var currentHistoryEntry: HistoryEntry
public init(url: URL) {
self.currentURL = url
self.currentHistoryEntry = HistoryEntry(url: url, title: nil)
}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.currentHistoryEntry = try container.decode(HistoryEntry.self, forKey: .currentHistoryEntry)
self.currentURL = self.currentHistoryEntry.url
self.backStack = try container.decode([HistoryEntry].self, forKey: .backStack)
self.forwardStack = try container.decode([HistoryEntry].self, forKey: .forwardStack)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(currentHistoryEntry, forKey: .currentHistoryEntry)
try container.encode(backStack, forKey: .backStack)
try container.encode(forwardStack, forKey: .forwardStack)
}
public func setTitleForCurrentURL(_ title: String?) {
currentHistoryEntry.title = title
}
public func changeURL(_ url: URL) {
@ -58,8 +80,9 @@ public class NavigationManager: NSObject, ObservableObject {
let url = components.url!
backStack.append(currentURL)
backStack.append(currentHistoryEntry)
currentURL = url
currentHistoryEntry = HistoryEntry(url: url, title: nil)
forwardStack = []
navigationOperation.send(.go)
@ -79,8 +102,9 @@ public class NavigationManager: NSObject, ObservableObject {
guard count <= backStack.count else { return }
var removed = backStack.suffix(count)
backStack.removeLast(count)
forwardStack.insert(currentURL, at: 0)
currentURL = removed.removeFirst()
forwardStack.insert(currentHistoryEntry, at: 0)
currentHistoryEntry = removed.removeFirst()
currentURL = currentHistoryEntry.url
forwardStack.insert(contentsOf: removed, at: 0)
navigationOperation.send(.backward(count: count))
@ -94,8 +118,9 @@ public class NavigationManager: NSObject, ObservableObject {
guard count <= forwardStack.count else { return }
var removed = forwardStack.prefix(count)
forwardStack.removeFirst(count)
backStack.append(currentURL)
currentURL = removed.removeLast()
backStack.append(currentHistoryEntry)
currentHistoryEntry = removed.removeLast()
currentURL = currentHistoryEntry.url
backStack.append(contentsOf: removed)
navigationOperation.send(.forward(count: count))
@ -103,8 +128,23 @@ public class NavigationManager: NSObject, ObservableObject {
}
extension NavigationManager {
enum CodingKeys: String, CodingKey {
case currentHistoryEntry
case backStack
case forwardStack
}
}
public extension NavigationManager {
enum Operation {
case go, reload, forward(count: Int), backward(count: Int)
}
}
public extension NavigationManager {
struct HistoryEntry: Codable {
public let url: URL
public internal(set) var title: String?
}
}

View File

@ -6,6 +6,7 @@
//
import UIKit
import Intents
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
@ -13,6 +14,7 @@ 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()
return true
@ -32,6 +34,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
func application(_ application: UIApplication, handlerFor intent: INIntent) -> Any? {
switch intent {
case is OpenURLIntent:
return OpenURLIntentHandler()
case is OpenHomepageIntent:
return OpenHomepageIntentHandler()
default:
return nil
}
}
}

View File

@ -50,6 +50,13 @@ class BrowserNavigationController: UIViewController {
.slide
}
override var userActivity: NSUserActivity? {
get {
currentBrowserVC?.userActivity
}
set {}
}
private var cancellables = [AnyCancellable]()
init(navigator: NavigationManager) {
@ -75,6 +82,9 @@ class BrowserNavigationController: UIViewController {
currentBrowserVC.scrollViewDelegate = self
embedChild(currentBrowserVC, in: browserContainer)
backBrowserVCs = navigator.backStack.map { createBrowserVC(url: $0.url) }
forwardBrowserVCs = navigator.forwardStack.map { createBrowserVC(url: $0.url) }
navBarView = NavigationBarView(navigator: navigator)
navBarView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(navBarView)
@ -101,6 +111,17 @@ class BrowserNavigationController: UIViewController {
.store(in: &cancellables)
view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized)))
Preferences.shared.$hideToolbarsWhenScrolling
.sink { [unowned self] (hideToolbarsWhenScrolling) in
if !hideToolbarsWhenScrolling {
// animate just in case the preference change came from another window and we're currently visible
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
self.toolbarOffset = 0
}
}
}
.store(in: &cancellables)
}
override func viewDidLayoutSubviews() {
@ -191,7 +212,7 @@ class BrowserNavigationController: UIViewController {
}
if location.x < startEdgeNavigationSwipeDistance && velocity.x > 0 && navigator.backStack.count > 0 {
let older = backBrowserVCs.last ?? BrowserWebViewController(navigator: navigator, url: navigator.backStack.last!)
let older = backBrowserVCs.last ?? BrowserWebViewController(navigator: navigator, url: navigator.backStack.last!.url)
embedChild(older, in: browserContainer)
older.view.layer.zPosition = -2
older.view.transform = CGAffineTransform(translationX: -1 * edgeNavigationParallaxFactor * view.bounds.width, y: 0)
@ -226,7 +247,7 @@ class BrowserNavigationController: UIViewController {
}
gestureState = .backwards(animator)
} else if location.x > view.bounds.width - startEdgeNavigationSwipeDistance && velocity.x < 0 && navigator.forwardStack.count > 0 {
let newer = forwardBrowserVCs.first ?? BrowserWebViewController(navigator: navigator, url: navigator.backStack.first!)
let newer = forwardBrowserVCs.first ?? BrowserWebViewController(navigator: navigator, url: navigator.backStack.first!.url)
embedChild(newer, in: browserContainer)
newer.view.transform = CGAffineTransform(translationX: view.bounds.width, y: 0)
newer.view.layer.zPosition = 2
@ -331,6 +352,7 @@ extension BrowserNavigationController {
extension BrowserNavigationController: UIScrollViewDelegate {
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
guard Preferences.shared.hideToolbarsWhenScrolling else { return }
trackingScroll = true
prevScrollViewContentOffset = scrollView.contentOffset
scrollStartedBelowEnd = scrollView.contentOffset.y >= (scrollView.contentSize.height - scrollView.bounds.height + scrollView.safeAreaInsets.bottom)

View File

@ -10,7 +10,6 @@ import BrowserCore
import WebKit
import GeminiProtocol
import GeminiFormat
import GeminiRenderer
import SafariServices
import Combine
@ -45,6 +44,13 @@ class BrowserWebViewController: UIViewController {
self.url = url
super.init(nibName: nil, bundle: nil)
userActivity = NSUserActivity(geminiURL: url)
userActivity!.isEligibleForPrediction = true
userActivity!.title = BrowserHelper.urlForDisplay(url)
// set the persistent identifier to the url, so that we don't get duplicate shortcuts for the same url
// (at least, i think that's how it works)
userActivity!.persistentIdentifier = url.absoluteString
}
required init?(coder: NSCoder) {
@ -56,11 +62,10 @@ class BrowserWebViewController: UIViewController {
configureRenderer()
view.backgroundColor = .systemBackground
view.backgroundColor = .systemBackground
webView = WKWebView()
webView.backgroundColor = .systemBackground
webView.isOpaque = false
webView.navigationDelegate = self
webView.uiDelegate = self
// it is safe to set the delegate of the web view's internal scroll view becuase WebKit takes care of forwarding between its internal delegate and our own
@ -68,6 +73,7 @@ class BrowserWebViewController: UIViewController {
// this doesn't default to .default :S
webView.scrollView.indicatorStyle = .default
webView.scrollView.keyboardDismissMode = .interactive
configureWebViewRefreshControl()
webView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(webView)
NSLayoutConstraint.activate([
@ -148,14 +154,28 @@ class BrowserWebViewController: UIViewController {
}
}
private func configureWebViewRefreshControl(pullToRefreshEnabled: Bool = Preferences.shared.pullToRefreshEnabled) {
if pullToRefreshEnabled {
let refreshControl = UIRefreshControl()
refreshControl.addTarget(navigator, action: #selector(NavigationManager.reload), for: .valueChanged)
webView.scrollView.refreshControl = refreshControl
} else {
webView.scrollView.refreshControl = nil
}
}
func reload() {
loaded = false
loadedFallback = false
document = nil
loadDocument()
loadDocument() {
DispatchQueue.main.async {
self.webView.scrollView.refreshControl?.endRefreshing()
}
}
}
private func loadDocument() {
private func loadDocument(completion: (() -> Void)? = nil) {
guard !loaded else { return }
webView.isHidden = true
@ -186,7 +206,8 @@ class BrowserWebViewController: UIViewController {
} else if response.status.isSuccess {
if response.mimeType == "text/gemini",
let text = response.bodyText {
self.renderDocument(GeminiParser.parse(text: text, baseURL: url))
let doc = GeminiParser.parse(text: text, baseURL: url)
self.renderDocument(doc)
} else {
self.renderFallback(response: response)
}
@ -200,6 +221,11 @@ class BrowserWebViewController: UIViewController {
}
}
}
completion?()
}
if #available(iOS 15.0, *) {
task!.attribution = .user
}
task!.resume()
}
@ -230,6 +256,15 @@ class BrowserWebViewController: UIViewController {
private func renderDocument(_ doc: Document) {
self.document = doc
if navigator.currentURL == doc.url {
navigator.setTitleForCurrentURL(doc.title)
}
if let title = doc.title {
DispatchQueue.main.async {
self.userActivity!.title = title
}
}
let html = BrowserWebViewController.preamble + renderer.renderDocumentToHTML(doc) + BrowserWebViewController.postamble
DispatchQueue.main.async {
self.webView.isHidden = false
@ -355,7 +390,18 @@ extension BrowserWebViewController: WKUIDelegate {
return SFSafariViewController(url: url)
}
} actionProvider: { (_) in
return nil
guard #available(iOS 15.0, *),
url.scheme == "gemini" else {
return nil
}
return UIMenu(children: [
UIWindowScene.ActivationAction({ (_) in
let options = UIWindowScene.ActivationRequestOptions()
// automatic presents in the prominent style even when a fullscreen window is the only existing one
options.preferredPresentationStyle = .standard
return UIWindowScene.ActivationConfiguration(userActivity: NSUserActivity(geminiURL: url), options: options, preview: nil)
})
])
}
completionHandler(config)
}

View File

@ -6,6 +6,10 @@
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Rocketeer</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict/>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@ -33,12 +37,29 @@
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>INIntentsSupported</key>
<array>
<string>OpenURLIntent</string>
<string>OpenHomepageIntent</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSUserActivityTypes</key>
<array>
<string>$(PRODUCE_BUNDLE_IDENTIFIER).activity.browse</string>
<string>$(PRODUCE_BUNDLE_IDENTIFIER).activity.homepage</string>
<string>GemtextToHTMLIntent</string>
<string>GemtextToMarkdownIntent</string>
<string>GetBodyIntent</string>
<string>GetMetaIntent</string>
<string>GetStatusIntent</string>
<string>MakeRequestIntent</string>
<string>OpenURLIntent</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
@ -52,6 +73,17 @@
</array>
</dict>
</dict>
<key>UIApplicationShortcutItems</key>
<array>
<dict>
<key>UIApplicationShortcutItemIconType</key>
<string>UIApplicationShortcutIconTypeHome</string>
<key>UIApplicationShortcutItemTitle</key>
<string>Homepage</string>
<key>UIApplicationShortcutItemType</key>
<string>home</string>
</dict>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,16 @@
//
// OpenHomepageIntentHandler.swift
// Gemini-iOS
//
// Created by Shadowfacts on 10/2/21.
//
import Intents
class OpenHomepageIntentHandler: NSObject, OpenHomepageIntentHandling {
func handle(intent: OpenHomepageIntent, completion: @escaping (OpenHomepageIntentResponse) -> Void) {
completion(OpenHomepageIntentResponse(code: .continueInApp, userActivity: .homepage()))
}
}

View File

@ -0,0 +1,30 @@
//
// OpenURLIntentHandler.swift
// Gemini-iOS
//
// Created by Shadowfacts on 9/29/21.
//
import Intents
class OpenURLIntentHandler: NSObject, OpenURLIntentHandling {
func resolveUrl(for intent: OpenURLIntent, with completion: @escaping (INURLResolutionResult) -> Void) {
guard let url = intent.url,
url.scheme?.lowercased() == "gemini" else {
completion(.unsupported())
return
}
completion(.success(with: url))
}
func handle(intent: OpenURLIntent, completion: @escaping (OpenURLIntentResponse) -> Void) {
guard let url = intent.url,
url.scheme?.lowercased() == "gemini" else {
completion(OpenURLIntentResponse(code: .failure, userActivity: nil))
return
}
completion(OpenURLIntentResponse(code: .continueInApp, userActivity: NSUserActivity(geminiURL: url)))
}
}

View File

@ -0,0 +1,54 @@
//
// UserActivities.swift
// Gemini-iOS
//
// Created by Shadowfacts on 9/28/21.
//
import Foundation
import BrowserCore
private let browseType = "space.vaccor.Gemini.activity.browse"
private let homepageType = "space.vaccor.Gemini.activity.homepage"
private let encoder = PropertyListEncoder()
private let decoder = PropertyListDecoder()
extension NSUserActivity {
static func homepage() -> NSUserActivity {
return NSUserActivity(activityType: homepageType)
}
convenience init(geminiURL url: URL) {
self.init(activityType: browseType)
self.userInfo = [
"url": url,
]
}
convenience init(navigationManager manager: NavigationManager) {
self.init(activityType: browseType)
self.userInfo = [
"url": manager.currentURL,
]
if let data = try? encoder.encode(manager) {
self.userInfo!["manager"] = data
}
}
var navigationManager: NavigationManager? {
guard activityType == browseType,
let data = userInfo?["manager"] as? Data,
let manager = try? decoder.decode(NavigationManager.self, from: data) else { return nil }
return manager
}
var geminiURL: URL? {
guard activityType == browseType,
let url = userInfo?["url"] as? URL else { return nil }
return url
}
var isHomepage: Bool {
return activityType == homepageType
}
}

View File

@ -45,6 +45,17 @@ class Preferences: Codable, ObservableObject {
useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
useReaderMode = try container.decode(Bool.self, forKey: .useReaderMode)
if let stored = try container.decodeIfPresent(Bool.self, forKey: .hideToolbarsWhenScrolling) {
hideToolbarsWhenScrolling = stored
}
if let stored = try container.decodeIfPresent(Bool.self, forKey: .pullToRefreshEnabled) {
pullToRefreshEnabled = stored
}
if let stored = try container.decodeIfPresent([ToolbarItem].self, forKey: .toolbar) {
toolbar = stored
}
}
func encode(to encoder: Encoder) throws {
@ -57,6 +68,11 @@ class Preferences: Codable, ObservableObject {
try container.encode(useInAppSafari, forKey: .useInAppSafari)
try container.encode(useReaderMode, forKey: .useReaderMode)
try container.encode(hideToolbarsWhenScrolling, forKey: .hideToolbarsWhenScrolling)
try container.encode(pullToRefreshEnabled, forKey: .pullToRefreshEnabled)
try container.encode(toolbar, forKey: .toolbar)
}
@Published var homepage = AppDelegate.defaultHomepage
@ -67,6 +83,11 @@ class Preferences: Codable, ObservableObject {
@Published var useInAppSafari = false
@Published var useReaderMode = false
@Published var hideToolbarsWhenScrolling = true
@Published var pullToRefreshEnabled = true
@Published var toolbar: [ToolbarItem] = [.back, .forward, .reload, .tableOfContents, .share, .preferences]
enum CodingKeys: String, CodingKey {
case homepage
@ -75,6 +96,11 @@ class Preferences: Codable, ObservableObject {
case useInAppSafari
case useReaderMode
case hideToolbarsWhenScrolling
case pullToRefreshEnabled
case toolbar
}
}

View File

@ -10,7 +10,9 @@ import SwiftUI
struct PreferencesView: View {
let dismiss: () -> Void
@ObservedObject var preferences: Preferences = .shared
@ObservedObject private var preferences: Preferences = .shared
// todo: this should really be a @StateObject on ToolbarPrefView
@State private var toolbarViewModel = CustomizeToolbarViewModel()
var body: some View {
NavigationView {
@ -20,6 +22,8 @@ struct PreferencesView: View {
appearanceSection
safariSection
behaviorSection
}
.navigationBarTitle("Preferences")
.insetOrGroupedListStyle()
@ -69,6 +73,18 @@ struct PreferencesView: View {
.disabled(!preferences.useInAppSafari)
}
}
private var behaviorSection: some View {
Section(header: Text("Behavior")) {
Toggle("Pull to Refresh", isOn: $preferences.pullToRefreshEnabled)
Toggle("Hide Toolbars When Scrolling", isOn: $preferences.hideToolbarsWhenScrolling)
NavigationLink {
ToolbarPrefView(model: toolbarViewModel)
} label: {
Text("Customize Toolbar")
}
}
}
}
fileprivate extension View {

View File

@ -10,6 +10,7 @@ import SwiftUI
import BrowserCore
import SafariServices
import Combine
import Intents
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@ -23,20 +24,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
let initialURL: URL
if let context = connectionOptions.urlContexts.first {
initialURL = context.url
} else {
if ProcessInfo.processInfo.environment.keys.contains("DEFAULT_URL") {
initialURL = URL(string: ProcessInfo.processInfo.environment["DEFAULT_URL"]!)!
} else {
initialURL = Preferences.shared.homepage
}
}
navigationManager = NavigationManager(url: initialURL)
navigationManager = createNavigationManager(for: session, with: connectionOptions)
navigationManager.delegate = self
// Create the SwiftUI view that provides the window contents.
@ -54,7 +43,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
Preferences.shared.$theme
.sink { (newStyle) in
.sink { [unowned self] (newStyle) in
self.window!.overrideUserInterfaceStyle = newStyle
}
.store(in: &cancellables)
@ -65,6 +54,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
navigationManager.changeURL(context.url)
}
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
if userActivity.isHomepage {
navigationManager.changeURL(Preferences.shared.homepage)
} else if let url = userActivity.geminiURL {
navigationManager.changeURL(url)
}
}
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
if shortcutItem.type == "home" {
navigationManager.changeURL(Preferences.shared.homepage)
completionHandler(true)
} else {
completionHandler(false)
}
}
func sceneDidDisconnect(_ scene: UIScene) {
// Called as the scene is being released by the system.
@ -93,6 +99,58 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
}
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
return NSUserActivity(navigationManager: navigationManager)
}
private func createNavigationManager(for session: UISceneSession, with connectionOptions: UIScene.ConnectionOptions) -> NavigationManager {
// try to restore the existing nav manager if there is one
if let manager = session.stateRestorationActivity?.navigationManager {
// if there's a user activity with a gemini url (e.g., from OpenURLIntentHandler),
// navigate the existing manager to that URL
if let activity = connectionOptions.userActivities.first {
if let newURL = activity.geminiURL {
manager.changeURL(newURL)
} else if activity.isHomepage {
manager.changeURL(Preferences.shared.homepage)
}
} else if connectionOptions.shortcutItem?.type == "home" {
manager.changeURL(Preferences.shared.homepage)
}
return manager
}
// otherwise, work out the initial URL and create a new manager
var initialURL: URL? = nil
if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity {
if let manager = activity.navigationManager {
return manager
} else if let url = activity.geminiURL {
initialURL = url
} else if activity.isHomepage {
initialURL = Preferences.shared.homepage
}
}
if initialURL == nil {
initialURL = connectionOptions.urlContexts.first?.url
}
if initialURL == nil || connectionOptions.shortcutItem?.type == "home" {
initialURL = Preferences.shared.homepage
}
#if DEBUG
if ProcessInfo.processInfo.environment.keys.contains("DEFAULT_URL") {
initialURL = URL(string: ProcessInfo.processInfo.environment["DEFAULT_URL"]!)!
}
#endif
return NavigationManager(url: initialURL!)
}
}

View File

@ -0,0 +1,58 @@
//
// ToolbarItem.swift
// Gemini-iOS
//
// Created by Shadowfacts on 10/10/21.
//
import Foundation
enum ToolbarItem: String, Codable, CaseIterable {
case back
case forward
case reload
case share
case home
case tableOfContents
case preferences
}
extension ToolbarItem {
var imageName: String {
switch self {
case .back:
return "arrow.left"
case .forward:
return "arrow.right"
case .reload:
return "arrow.clockwise"
case .tableOfContents:
return "list.bullet.indent"
case .share:
return "square.and.arrow.up"
case .preferences:
return "gear"
case .home:
return "house"
}
}
var displayName: String {
switch self {
case .back:
return "Go Back"
case .forward:
return "Go Forward"
case .reload:
return "Reload"
case .tableOfContents:
return "Table of Contents"
case .share:
return "Share"
case .preferences:
return "Preferences"
case .home:
return "Home"
}
}
}

View File

@ -0,0 +1,336 @@
//
// ToolbarPrefView.swift
// Gemini-iOS
//
// Created by Shadowfacts on 10/9/21.
//
import SwiftUI
import Combine
let toolbarItemType = "space.vaccor.Gemini.toolbar-item"
struct ToolbarPrefView: View {
// todo: this should really be a @StateObject and shouldn't be passed in from the outside, but that requires iOS 14
@ObservedObject private var model: CustomizeToolbarViewModel
init(model: CustomizeToolbarViewModel) {
self.model = model
}
var body: some View {
VStack(spacing: 0) {
Spacer()
Text("Drag and drop items to change your toolbar")
Spacer()
separator
HStack(spacing: 8) {
Spacer()
ForEach(model.items) { (toolbarItem) in
Image(systemName: toolbarItem.imageName)
.font(.system(size: 24))
.foregroundColor(toolbarItem.isPlaceholder ? .gray : .blue)
.overlay(GeometryReader { (proxy) in
Color.clear
.preference(key: FloatPrefKey.self, value: proxy.size.width)
.onPreferenceChange(FloatPrefKey.self) { newValue in
model.itemWidths[toolbarItem] = newValue
}
})
.onDrag {
guard !toolbarItem.isPlaceholder else {
return NSItemProvider()
}
model.beginDragging(item: toolbarItem.item)
return NSItemProvider(item: nil, typeIdentifier: toolbarItemType)
}
Spacer()
}
.animation(.default, value: model.items)
}
.padding(.vertical, 8)
.overlay(GeometryReader { proxy in
Color.clear
.preference(key: FloatPrefKey.self, value: proxy.size.width)
.onPreferenceChange(FloatPrefKey.self) { newValue in
model.totalWidth = newValue
}
})
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
// this onDrop is needed because the one on the .background doesn't fire when the drag item is held above one of the items themselves
.onDrop(of: [toolbarItemType], delegate: model)
.background(
Color(UIColor.systemBackground)
// this onDrop is needed because the one on the ForEach doesn't fire when is the drag item held in between two of the ForEach items (i.e., above one of the spacers)
.onDrop(of: [toolbarItemType], delegate: model)
)
separator
Spacer(minLength: 16)
VStack(alignment: .textLeading, spacing: 8) {
ForEach(ToolbarItem.allCases, id: \.rawValue) { (item) in
HStack(spacing: 4) {
Image(systemName: item.imageName)
.font(.system(size: 24))
.foregroundColor(canAdd(item: item) ? .blue : .gray)
.disabled(!canAdd(item: item))
.animation(.default, value: canAdd(item: item))
.onDrag {
model.beginDragging(item: item)
return NSItemProvider(item: nil, typeIdentifier: toolbarItemType)
}
Text(item.displayName)
.alignmentGuide(.textLeading) { d in d[HorizontalAlignment.leading] }
}
}
}
Spacer()
}
.background(
Color(.secondarySystemBackground)
.edgesIgnoringSafeArea(.all)
// this stupid onDrop is necessary because sometimes when quickly dragging an item out of the toolbar, dropExited is never called on the main delegate
// so the BackgroundDropDelegate ensures that any placeholders are removed
.onDrop(of: [toolbarItemType], delegate: BackgroundDropDelegate(toolbarDropDelegate: model))
)
}
private var separator: some View {
Rectangle()
.frame(height: 0.5)
.foregroundColor(.gray)
}
private func canAdd(item: ToolbarItem) -> Bool {
return !model.items.contains(.item(item))
}
}
private extension HorizontalAlignment {
struct TextLeadingAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
return context[HorizontalAlignment.center]
}
}
/// The alignment used for aligning the leading edges of all the text in the toolbar item list.
static let textLeading = HorizontalAlignment(TextLeadingAlignment.self)
}
private enum CustomizeToolbarItem: Identifiable, Equatable, Hashable {
case item(ToolbarItem)
case placeholder(ToolbarItem)
var id: String {
switch self {
case let .item(item):
return item.rawValue
case let .placeholder(item):
return "placeholder_\(item.rawValue)"
}
}
var imageName: String {
switch self {
case .item(let item), .placeholder(let item):
return item.imageName
}
}
var isPlaceholder: Bool {
switch self {
case .item(_):
return false
case .placeholder(_):
return true
}
}
var item: ToolbarItem {
switch self {
case .item(let item), .placeholder(let item):
return item
}
}
}
class CustomizeToolbarViewModel: ObservableObject, DropDelegate {
@Published fileprivate var items: [CustomizeToolbarItem]
fileprivate var totalWidth: CGFloat = 0
fileprivate var itemWidths: [CustomizeToolbarItem: CGFloat] = [:]
fileprivate var draggedItem: ToolbarItem?
private var isDraggingExisting = false
private var cancellables = Set<AnyCancellable>()
init() {
items = Preferences.shared.toolbar.map { .item($0) }
Preferences.shared.$toolbar
.sink { [unowned self] (newValue) in
self.items = newValue.map { .item($0) }
}
.store(in: &cancellables)
}
fileprivate func beginDragging(item: ToolbarItem) {
draggedItem = item
if let index = items.firstIndex(where: { $0.item == item }) {
items[index] = .placeholder(item)
isDraggingExisting = true
} else {
isDraggingExisting = false
}
}
fileprivate func cleanupCustomizeItems() {
items = items.compactMap {
switch $0 {
case .item(_):
return $0
case .placeholder(let item):
if canRemove(item: item) {
return nil
} else {
return .item(item)
}
}
}
updatePreferences()
}
private func canRemove(item: ToolbarItem) -> Bool {
// preferences can't be removed because the user would lose access to the toolbar preferences
return item != .preferences
}
private func updatePreferences() {
Preferences.shared.toolbar = self.items.compactMap {
switch $0 {
case let .item(item):
return item
case .placeholder:
return nil
}
}
}
private func proposedDropIndex(info: DropInfo) -> Int {
let totalItemWidth = itemWidths.filter { items.contains($0.key) }.reduce(0, { $0 + $1.value })
let remainingWidth = totalWidth - totalItemWidth
let spacerWidth = remainingWidth / CGFloat(items.count + 1)
var accumulatedWidth = spacerWidth
for (index, item) in items.enumerated() {
let itemWidth = itemWidths[item]!
if info.location.x < accumulatedWidth + itemWidth / 2 {
return index
} else {
accumulatedWidth += itemWidth + spacerWidth
}
}
return items.count
}
// MARK: DropDelegate
func validateDrop(info: DropInfo) -> Bool {
return draggedItem != nil && (isDraggingExisting || items.filter { !$0.isPlaceholder }.count < 6)
}
func dropEntered(info: DropInfo) {
items.removeAll(where: \.isPlaceholder)
// if we just exited the other onDrop handler, we need to re-add the placeholder
if !items.contains(where: { $0.item == draggedItem! }) {
items.insert(.placeholder(draggedItem!), at: proposedDropIndex(info: info))
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
if isDraggingExisting {
let index = items.firstIndex { $0.item == draggedItem! }!
items[index] = .placeholder(draggedItem!)
items.move(fromOffsets: IndexSet(integer: index), toOffset: proposedDropIndex(info: info))
return DropProposal(operation: .move)
} else {
items.removeAll(where: \.isPlaceholder)
let index = proposedDropIndex(info: info)
items.insert(.placeholder(draggedItem!), at: index)
return DropProposal(operation: .copy)
}
}
func dropExited(info: DropInfo) {
cleanupCustomizeItems()
}
func performDrop(info: DropInfo) -> Bool {
let placeholderIndex = items.firstIndex(where: \.isPlaceholder)!
items[placeholderIndex] = .item(draggedItem!)
self.updatePreferences()
return true
}
}
private struct BackgroundDropDelegate: DropDelegate {
let toolbarDropDelegate: CustomizeToolbarViewModel
func dropUpdated(info: DropInfo) -> DropProposal? {
if toolbarDropDelegate.draggedItem != nil {
toolbarDropDelegate.cleanupCustomizeItems()
}
return DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
return false
}
}
private struct FloatPrefKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
private extension View {
@ViewBuilder
func onDragWithPreviewIfPossible<V>(_ data: @escaping () -> NSItemProvider, preview: () -> V) -> some View where V : View {
if #available(iOS 15.0, *) {
self.onDrag(data, preview: preview)
} else {
self.onDrag(data)
}
}
@ViewBuilder
func onDragIf(condition: () -> Bool, data: @escaping () -> NSItemProvider) -> some View {
if condition() {
self.onDrag(data)
} else {
self
}
}
}
struct ToolbarPrefView_Previews: PreviewProvider {
static var previews: some View {
ToolbarPrefView(model: CustomizeToolbarViewModel())
}
}

View File

@ -18,12 +18,8 @@ class ToolbarView: UIView {
var showPreferences: (() -> Void)?
private var border: UIView!
private var backButton: UIButton!
private var forwardsButton: UIButton!
private var reloadButton: UIButton!
private var tableOfContentsButton: UIButton!
private var shareButton: UIButton!
private var prefsButton: UIButton!
private var buttonsStack: UIStackView!
private var toolbarButtons: [ToolbarItem: UIButton] = [:]
private var cancellables = [AnyCancellable]()
@ -45,85 +41,35 @@ class ToolbarView: UIView {
border.heightAnchor.constraint(equalToConstant: 1),
])
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 24)
backButton = UIButton()
backButton.addTarget(navigator, action: #selector(NavigationManager.goBack), for: .touchUpInside)
backButton.isEnabled = navigator.backStack.count > 0
backButton.setImage(UIImage(systemName: "arrow.left", withConfiguration: symbolConfig), for: .normal)
backButton.accessibilityLabel = "Back"
backButton.isPointerInteractionEnabled = true
// fallback for when UIButton.menu isn't available
if #available(iOS 14.0, *) {
} else {
backButton.addInteraction(UIContextMenuInteraction(delegate: self))
}
forwardsButton = UIButton()
forwardsButton.addTarget(navigator, action: #selector(NavigationManager.goForward), for: .touchUpInside)
forwardsButton.isEnabled = navigator.forwardStack.count > 0
forwardsButton.setImage(UIImage(systemName: "arrow.right", withConfiguration: symbolConfig), for: .normal)
forwardsButton.accessibilityLabel = "Forward"
forwardsButton.isPointerInteractionEnabled = true
if #available(iOS 14.0, *) {
} else {
forwardsButton.addInteraction(UIContextMenuInteraction(delegate: self))
}
reloadButton = UIButton()
reloadButton.addTarget(navigator, action: #selector(NavigationManager.reload), for: .touchUpInside)
reloadButton.setImage(UIImage(systemName: "arrow.clockwise", withConfiguration: symbolConfig), for: .normal)
reloadButton.accessibilityLabel = "Reload"
reloadButton.isPointerInteractionEnabled = true
tableOfContentsButton = UIButton()
tableOfContentsButton.addTarget(self, action: #selector(tableOfContentsPressed), for: .touchUpInside)
tableOfContentsButton.setImage(UIImage(systemName: "list.bullet.indent", withConfiguration: symbolConfig), for: .normal)
tableOfContentsButton.accessibilityLabel = "Table of Contents"
tableOfContentsButton.isPointerInteractionEnabled = true
shareButton = UIButton()
shareButton.addTarget(self, action: #selector(sharePressed), for: .touchUpInside)
shareButton.setImage(UIImage(systemName: "square.and.arrow.up", withConfiguration: symbolConfig), for: .normal)
shareButton.accessibilityLabel = "Share"
shareButton.isPointerInteractionEnabled = true
prefsButton = UIButton()
prefsButton.addTarget(self, action: #selector(prefsPressed), for: .touchUpInside)
prefsButton.setImage(UIImage(systemName: "gear", withConfiguration: symbolConfig), for: .normal)
prefsButton.accessibilityLabel = "Preferences"
prefsButton.isPointerInteractionEnabled = true
let stack = UIStackView(arrangedSubviews: [
backButton,
forwardsButton,
reloadButton,
tableOfContentsButton,
shareButton,
prefsButton,
])
stack.axis = .horizontal
stack.distribution = .fillEqually
stack.alignment = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
let safeAreaConstraint = stack.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor)
buttonsStack = UIStackView()
buttonsStack.axis = .horizontal
buttonsStack.distribution = .fillEqually
buttonsStack.alignment = .fill
buttonsStack.translatesAutoresizingMaskIntoConstraints = false
addSubview(buttonsStack)
let safeAreaConstraint = buttonsStack.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor)
safeAreaConstraint.priority = .defaultHigh
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 5),
buttonsStack.leadingAnchor.constraint(equalTo: leadingAnchor),
buttonsStack.trailingAnchor.constraint(equalTo: trailingAnchor),
buttonsStack.topAnchor.constraint(equalTo: topAnchor, constant: 5),
safeAreaConstraint,
stack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -8)
buttonsStack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -8)
])
updateNavigationButtons()
navigator.navigationOperation
.sink { (_) in
.sink { [unowned self] (_) in
self.updateNavigationButtons()
}
.store(in: &cancellables)
Preferences.shared.$toolbar
.sink { [unowned self] (newValue) in
self.createToolbarButtons(newValue)
}
.store(in: &cancellables)
}
required init?(coder: NSCoder) {
@ -136,36 +82,100 @@ class ToolbarView: UIView {
border.backgroundColor = UIColor(white: traitCollection.userInterfaceStyle == .dark ? 0.25 : 0.75, alpha: 1)
}
private func urlForDisplay(_ url: URL) -> String {
var str = url.host!
if let port = url.port,
url.scheme != "gemini" || port != 1965 {
str += ":\(port)"
private func createToolbarButtons(_ items: [ToolbarItem] = Preferences.shared.toolbar) {
toolbarButtons = [:]
buttonsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for item in items {
let button = createButton(item)
toolbarButtons[item] = button
buttonsStack.addArrangedSubview(button)
}
str += url.path
return str
updateNavigationButtons()
}
private func createButton(_ item: ToolbarItem) -> UIButton {
let button = UIButton()
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 24)
button.setImage(UIImage(systemName: item.imageName, withConfiguration: symbolConfig)!, for: .normal)
button.accessibilityLabel = item.displayName
button.isPointerInteractionEnabled = true
switch item {
case .back:
button.addTarget(navigator, action: #selector(NavigationManager.goBack), for: .touchUpInside)
// fallback for when UIButton.menu isn't available
if #available(iOS 14.0, *) {
} else {
button.addInteraction(UIContextMenuInteraction(delegate: self))
}
case .forward:
button.addTarget(navigator, action: #selector(NavigationManager.goForward), for: .touchUpInside)
if #available(iOS 14.0, *) {
} else {
button.addInteraction(UIContextMenuInteraction(delegate: self))
}
case .reload:
button.addTarget(navigator, action: #selector(NavigationManager.reload), for: .touchUpInside)
case .share:
button.addTarget(self, action: #selector(sharePressed), for: .touchUpInside)
case .home:
button.addTarget(self, action: #selector(homePressed), for: .touchUpInside)
case .tableOfContents:
button.addTarget(self, action: #selector(tableOfContentsPressed), for: .touchUpInside)
case .preferences:
button.addTarget(self, action: #selector(prefsPressed), for: .touchUpInside)
}
return button
}
private func updateNavigationButtons() {
backButton.isEnabled = navigator.backStack.count > 0
forwardsButton.isEnabled = navigator.forwardStack.count > 0
if let backButton = toolbarButtons[.back] {
backButton.isEnabled = navigator.backStack.count > 0
if #available(iOS 14.0, *) {
let back = navigator.backStack.suffix(5).enumerated().reversed().map { (index, entry) -> UIAction in
let backCount = min(5, navigator.backStack.count) - index
if #available(iOS 15.0, *),
let title = entry.title {
return UIAction(title: title, subtitle: BrowserHelper.urlForDisplay(entry.url)) { [unowned self] (_) in
self.navigator.back(count: backCount)
}
} else {
return UIAction(title: BrowserHelper.urlForDisplay(entry.url)) { [unowned self] (_) in
self.navigator.back(count: backCount)
}
}
}
backButton.menu = UIMenu(children: back)
}
}
if #available(iOS 14.0, *) {
let back = navigator.backStack.suffix(5).enumerated().reversed().map { (index, url) -> UIAction in
let backCount = min(5, navigator.backStack.count) - index
return UIAction(title: urlForDisplay(url)) { (_) in
self.navigator.back(count: backCount)
if let forwardsButton = toolbarButtons[.forward] {
forwardsButton.isEnabled = navigator.forwardStack.count > 0
if #available(iOS 14.0, *) {
let forward = navigator.forwardStack.prefix(5).enumerated().map { (index, entry) -> UIAction in
let forwardCount = index + 1
if #available(iOS 15.0, *),
let title = entry.title {
return UIAction(title: title, subtitle: BrowserHelper.urlForDisplay(entry.url)) { [unowned self] (_) in
self.navigator.forward(count: forwardCount)
}
} else {
return UIAction(title: BrowserHelper.urlForDisplay(entry.url)) { [unowned self] (_) in
self.navigator.forward(count: forwardCount)
}
}
}
forwardsButton.menu = UIMenu(children: forward)
}
backButton.menu = UIMenu(children: back)
let forward = navigator.forwardStack.prefix(5).enumerated().map { (index, url) -> UIAction in
let forwardCount = index + 1
return UIAction(title: urlForDisplay(url)) { (_) in
self.navigator.forward(count: forwardCount)
}
}
forwardsButton.menu = UIMenu(children: forward)
}
}
@ -174,31 +184,38 @@ class ToolbarView: UIView {
}
@objc private func sharePressed() {
showShareSheet?(shareButton)
showShareSheet?(toolbarButtons[.share]!)
}
@objc private func prefsPressed() {
showPreferences?()
}
@objc private func homePressed() {
navigator.changeURL(Preferences.shared.homepage)
}
}
extension ToolbarView: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
if interaction.view == backButton {
// this path is only used on <iOS 14, on >=iOS 14, we don't create a UIContextMenuInteraction
if let backButton = toolbarButtons[.back],
interaction.view == backButton {
return UIContextMenuConfiguration(identifier: nil, previewProvider: { nil }) { (_) -> UIMenu? in
let children = self.navigator.backStack.suffix(5).enumerated().map { (index, url) in
UIAction(title: self.urlForDisplay(url)) { (_) in
let children = self.navigator.backStack.suffix(5).enumerated().map { (index, entry) in
UIAction(title: BrowserHelper.urlForDisplay(entry.url)) { (_) in
self.navigator.back(count: min(5, self.navigator.backStack.count) - index)
}
}
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: children)
}
} else if interaction.view == forwardsButton {
} else if let forwardsButton = toolbarButtons[.forward],
interaction.view == forwardsButton {
return UIContextMenuConfiguration(identifier: nil, previewProvider: { nil }) { (_) -> UIMenu? in
let children = self.navigator.forwardStack.prefix(5).enumerated().map { (index, url) -> UIAction in
let children = self.navigator.forwardStack.prefix(5).enumerated().map { (index, entry) -> UIAction in
let forwardCount = index + 1
return UIAction(title: self.urlForDisplay(url)) { (_) in
return UIAction(title: BrowserHelper.urlForDisplay(entry.url)) { (_) in
self.navigator.forward(count: forwardCount)
}
}

View File

@ -37,20 +37,43 @@
D62664F024BC0D7700DF9B88 /* GeminiFormat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D62664A824BBF26A00DF9B88 /* GeminiFormat.framework */; };
D62664FA24BC12BC00DF9B88 /* DocumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62664F924BC12BC00DF9B88 /* DocumentTests.swift */; };
D6376A7026DDAF65005AD89C /* URIFixup.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6376A6F26DDAF65005AD89C /* URIFixup.swift */; };
D640A2322711DC7700177E85 /* ToolbarPrefView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D640A2312711DC7700177E85 /* ToolbarPrefView.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 */; };
D688F586258AC738003A0A73 /* GeminiHTMLRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */; };
D688F590258AC814003A0A73 /* HTMLEntities in Frameworks */ = {isa = PBXBuildFile; productRef = D688F58F258AC814003A0A73 /* HTMLEntities */; };
D664E4FA2713DF72005BAF55 /* ToolbarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664E4F92713DF72005BAF55 /* ToolbarItem.swift */; };
D688F599258ACAAE003A0A73 /* BrowserWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */; };
D688F5FF258ACE6B003A0A73 /* browser.css in Resources */ = {isa = PBXBuildFile; fileRef = D688F5FE258ACE6B003A0A73 /* browser.css */; };
D688F633258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */; };
D688F64A258C17F3003A0A73 /* SymbolCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F649258C17F3003A0A73 /* SymbolCache.swift */; };
D688F65A258C2256003A0A73 /* BrowserNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F659258C2256003A0A73 /* BrowserNavigationController.swift */; };
D688F663258C2479003A0A73 /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F662258C2479003A0A73 /* UIViewController+Children.swift */; };
D68C1E1927055E09002D642B /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E1827055E09002D642B /* Intents.intentdefinition */; };
D68C1E1E270605A7002D642B /* BrowserHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E1D270605A7002D642B /* BrowserHelper.swift */; };
D68C1E25270614F9002D642B /* Intents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D68C1E24270614F9002D642B /* Intents.framework */; platformFilter = maccatalyst; };
D68C1E28270614F9002D642B /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E27270614F9002D642B /* IntentHandler.swift */; };
D68C1E2C270614F9002D642B /* GeminiIntents.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D68C1E23270614F9002D642B /* GeminiIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D68C1E312706150E002D642B /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E1827055E09002D642B /* Intents.intentdefinition */; };
D68C1E3227061557002D642B /* OpenURLIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E1B27055EB0002D642B /* OpenURLIntentHandler.swift */; };
D68C1E3327061568002D642B /* UserActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1DFF2703EA13002D642B /* UserActivities.swift */; };
D68C1E34270615D3002D642B /* OpenURLIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E1B27055EB0002D642B /* OpenURLIntentHandler.swift */; };
D68C1E35270615D3002D642B /* UserActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1DFF2703EA13002D642B /* UserActivities.swift */; };
D68C1E372706215A002D642B /* MakeRequestIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E362706215A002D642B /* MakeRequestIntentHandler.swift */; };
D68C1E392707A1E5002D642B /* GetBodyIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E382707A1E5002D642B /* GetBodyIntentHandler.swift */; };
D68C1E3B2707BF25002D642B /* GetStatusIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E3A2707BF25002D642B /* GetStatusIntentHandler.swift */; };
D68C1E3D2707C002002D642B /* GetMetaIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E3C2707C002002D642B /* GetMetaIntentHandler.swift */; };
D68C1E3F2707C3B2002D642B /* GemtextToHTMLIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E3E2707C3B2002D642B /* GemtextToHTMLIntentHandler.swift */; };
D68C1E412707CB03002D642B /* GemtextToMarkdownIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E402707CB03002D642B /* GemtextToMarkdownIntentHandler.swift */; };
D68C1E482708A958002D642B /* GeminiMarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E422707CB69002D642B /* GeminiMarkdownRenderer.swift */; };
D68C1E492708A958002D642B /* GeminiHTMLRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */; };
D68C1E4A2708A95D002D642B /* GeminiMarkdownRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E462707D109002D642B /* GeminiMarkdownRendererTests.swift */; };
D68C1E4B2708A95D002D642B /* GeminiHTMLRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E442707CCDB002D642B /* GeminiHTMLRendererTests.swift */; };
D68C1E4D2708A981002D642B /* HTMLEntities in Frameworks */ = {isa = PBXBuildFile; productRef = D68C1E4C2708A981002D642B /* HTMLEntities */; };
D68C1E512708B276002D642B /* OpenHomepageIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E502708B276002D642B /* OpenHomepageIntentHandler.swift */; };
D68C1E522708B2D3002D642B /* OpenHomepageIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68C1E502708B276002D642B /* OpenHomepageIntentHandler.swift */; };
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A64D25217C6F00348C4B /* Preferences.swift */; };
D691A66725217FD800348C4B /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A66625217FD800348C4B /* PreferencesView.swift */; };
D69F00AC24BE9DD300E37622 /* GeminiDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */; };
@ -207,6 +230,13 @@
remoteGlobalIDString = D62664A724BBF26A00DF9B88;
remoteInfo = GeminiFormat;
};
D68C1E2A270614F9002D642B /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D626645324BBF1C200DF9B88 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D68C1E22270614F9002D642B;
remoteInfo = GeminiIntents;
};
D6E152DA24C0007200FDF9D3 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D626645324BBF1C200DF9B88 /* Project object */;
@ -255,6 +285,17 @@
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
D68C1E30270614F9002D642B /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
D68C1E2C270614F9002D642B /* GeminiIntents.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
D6E152C224BFFE2500FDF9D3 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@ -308,12 +349,14 @@
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>"; };
D6376A6F26DDAF65005AD89C /* URIFixup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URIFixup.swift; sourceTree = "<group>"; };
D640A2312711DC7700177E85 /* ToolbarPrefView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarPrefView.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>"; };
D664E4F92713DF72005BAF55 /* ToolbarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarItem.swift; sourceTree = "<group>"; };
D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiHTMLRenderer.swift; sourceTree = "<group>"; };
D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWebViewController.swift; sourceTree = "<group>"; };
D688F5FE258ACE6B003A0A73 /* browser.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = browser.css; sourceTree = "<group>"; };
@ -321,6 +364,24 @@
D688F649258C17F3003A0A73 /* SymbolCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymbolCache.swift; sourceTree = "<group>"; };
D688F659258C2256003A0A73 /* BrowserNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserNavigationController.swift; sourceTree = "<group>"; };
D688F662258C2479003A0A73 /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
D68C1DFF2703EA13002D642B /* UserActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivities.swift; sourceTree = "<group>"; };
D68C1E1827055E09002D642B /* Intents.intentdefinition */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; path = Intents.intentdefinition; sourceTree = "<group>"; };
D68C1E1B27055EB0002D642B /* OpenURLIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenURLIntentHandler.swift; sourceTree = "<group>"; };
D68C1E1D270605A7002D642B /* BrowserHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserHelper.swift; sourceTree = "<group>"; };
D68C1E23270614F9002D642B /* GeminiIntents.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GeminiIntents.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D68C1E24270614F9002D642B /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; };
D68C1E27270614F9002D642B /* IntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = "<group>"; };
D68C1E29270614F9002D642B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D68C1E362706215A002D642B /* MakeRequestIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeRequestIntentHandler.swift; sourceTree = "<group>"; };
D68C1E382707A1E5002D642B /* GetBodyIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetBodyIntentHandler.swift; sourceTree = "<group>"; };
D68C1E3A2707BF25002D642B /* GetStatusIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStatusIntentHandler.swift; sourceTree = "<group>"; };
D68C1E3C2707C002002D642B /* GetMetaIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetMetaIntentHandler.swift; sourceTree = "<group>"; };
D68C1E3E2707C3B2002D642B /* GemtextToHTMLIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GemtextToHTMLIntentHandler.swift; sourceTree = "<group>"; };
D68C1E402707CB03002D642B /* GemtextToMarkdownIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GemtextToMarkdownIntentHandler.swift; sourceTree = "<group>"; };
D68C1E422707CB69002D642B /* GeminiMarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiMarkdownRenderer.swift; sourceTree = "<group>"; };
D68C1E442707CCDB002D642B /* GeminiHTMLRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiHTMLRendererTests.swift; sourceTree = "<group>"; };
D68C1E462707D109002D642B /* GeminiMarkdownRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiMarkdownRendererTests.swift; sourceTree = "<group>"; };
D68C1E502708B276002D642B /* OpenHomepageIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenHomepageIntentHandler.swift; sourceTree = "<group>"; };
D691A64D25217C6F00348C4B /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D691A66625217FD800348C4B /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiDataTask.swift; sourceTree = "<group>"; };
@ -382,6 +443,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D68C1E4D2708A981002D642B /* HTMLEntities in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -397,7 +459,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D688F590258AC814003A0A73 /* HTMLEntities in Frameworks */,
D62664F024BC0D7700DF9B88 /* GeminiFormat.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -410,6 +471,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D68C1E20270614F9002D642B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D68C1E25270614F9002D642B /* Intents.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D6E1529F24BFFDF500FDF9D3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@ -444,6 +513,7 @@
children = (
D626645D24BBF1C200DF9B88 /* Gemini */,
D6E152A324BFFDF500FDF9D3 /* Gemini-iOS */,
D68C1E26270614F9002D642B /* GeminiIntents */,
D6E152D124C0007200FDF9D3 /* BrowserCore */,
D6E152DE24C0007200FDF9D3 /* BrowserCoreTests */,
D626647824BBF22E00DF9B88 /* GeminiProtocol */,
@ -470,6 +540,7 @@
D6E152A224BFFDF500FDF9D3 /* Gemini-iOS.app */,
D6E152D024C0007200FDF9D3 /* BrowserCore.framework */,
D6E152D824C0007200FDF9D3 /* BrowserCoreTests.xctest */,
D68C1E23270614F9002D642B /* GeminiIntents.appex */,
);
name = Products;
sourceTree = "<group>";
@ -531,6 +602,8 @@
D62664C724BBF2C600DF9B88 /* Document.swift */,
D62664C524BBF27300DF9B88 /* GeminiParser.swift */,
D6BC9AC4258F01F6008652BC /* TableOfContents.swift */,
D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */,
D68C1E422707CB69002D642B /* GeminiMarkdownRenderer.swift */,
);
path = GeminiFormat;
sourceTree = "<group>";
@ -541,6 +614,8 @@
D62664F924BC12BC00DF9B88 /* DocumentTests.swift */,
D62664B724BBF26A00DF9B88 /* GeminiParserTests.swift */,
D6BC9ACD258F07BC008652BC /* TableOfContentsTests.swift */,
D68C1E442707CCDB002D642B /* GeminiHTMLRendererTests.swift */,
D68C1E462707D109002D642B /* GeminiMarkdownRendererTests.swift */,
D62664B924BBF26A00DF9B88 /* Info.plist */,
);
path = GeminiFormatTests;
@ -557,7 +632,6 @@
D62664EB24BC0B4D00DF9B88 /* DocumentView.swift */,
D664673724BD086F00B0B741 /* RenderingBlockView.swift */,
D6DA5782252396030048B65A /* View+Extensions.swift */,
D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */,
);
path = GeminiRenderer;
sourceTree = "<group>";
@ -574,6 +648,7 @@
D62664EF24BC0D7700DF9B88 /* Frameworks */ = {
isa = PBXGroup;
children = (
D68C1E24270614F9002D642B /* Intents.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -594,6 +669,31 @@
path = Resources;
sourceTree = "<group>";
};
D68C1E1A27055EA0002D642B /* Intents */ = {
isa = PBXGroup;
children = (
D68C1DFF2703EA13002D642B /* UserActivities.swift */,
D68C1E1B27055EB0002D642B /* OpenURLIntentHandler.swift */,
D68C1E502708B276002D642B /* OpenHomepageIntentHandler.swift */,
);
path = Intents;
sourceTree = "<group>";
};
D68C1E26270614F9002D642B /* GeminiIntents */ = {
isa = PBXGroup;
children = (
D68C1E27270614F9002D642B /* IntentHandler.swift */,
D68C1E362706215A002D642B /* MakeRequestIntentHandler.swift */,
D68C1E382707A1E5002D642B /* GetBodyIntentHandler.swift */,
D68C1E3A2707BF25002D642B /* GetStatusIntentHandler.swift */,
D68C1E3C2707C002002D642B /* GetMetaIntentHandler.swift */,
D68C1E3E2707C3B2002D642B /* GemtextToHTMLIntentHandler.swift */,
D68C1E402707CB03002D642B /* GemtextToMarkdownIntentHandler.swift */,
D68C1E29270614F9002D642B /* Info.plist */,
);
path = GeminiIntents;
sourceTree = "<group>";
};
D6E152A324BFFDF500FDF9D3 /* Gemini-iOS */ = {
isa = PBXGroup;
children = (
@ -608,13 +708,17 @@
D6BC9ABB258E9862008652BC /* NavigationBarView.swift */,
D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */,
D691A64D25217C6F00348C4B /* Preferences.swift */,
D664E4F92713DF72005BAF55 /* ToolbarItem.swift */,
D691A66625217FD800348C4B /* PreferencesView.swift */,
D653F40C26799F2F004E32B1 /* HomepagePrefView.swift */,
D640A2312711DC7700177E85 /* ToolbarPrefView.swift */,
D653F40A267996FF004E32B1 /* ActivityItemSource.swift */,
D653F40E2679A0AB004E32B1 /* SetHomepageActivity.swift */,
D68C1E1A27055EA0002D642B /* Intents */,
D688F618258AD231003A0A73 /* Resources */,
D6376A6E26DDAF57005AD89C /* Vendor */,
D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */,
D68C1E1827055E09002D642B /* Intents.intentdefinition */,
D6E152AF24BFFDF600FDF9D3 /* LaunchScreen.storyboard */,
D6E152B224BFFDF600FDF9D3 /* Info.plist */,
D6E152AC24BFFDF600FDF9D3 /* Preview Content */,
@ -636,6 +740,7 @@
D6E152D224C0007200FDF9D3 /* BrowserCore.h */,
D6E152D324C0007200FDF9D3 /* Info.plist */,
D69F00AF24BEA84D00E37622 /* NavigationManager.swift */,
D68C1E1D270605A7002D642B /* BrowserHelper.swift */,
D626646024BBF1C200DF9B88 /* BrowserView.swift */,
);
path = BrowserCore;
@ -761,6 +866,9 @@
dependencies = (
);
name = GeminiFormat;
packageProductDependencies = (
D68C1E4C2708A981002D642B /* HTMLEntities */,
);
productName = GeminiFormat;
productReference = D62664A824BBF26A00DF9B88 /* GeminiFormat.framework */;
productType = "com.apple.product-type.framework";
@ -801,7 +909,6 @@
);
name = GeminiRenderer;
packageProductDependencies = (
D688F58F258AC814003A0A73 /* HTMLEntities */,
);
productName = GeminiRenderer;
productReference = D62664CE24BC081B00DF9B88 /* GeminiRenderer.framework */;
@ -826,6 +933,23 @@
productReference = D62664D624BC081B00DF9B88 /* GeminiRendererTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
D68C1E22270614F9002D642B /* GeminiIntents */ = {
isa = PBXNativeTarget;
buildConfigurationList = D68C1E2D270614F9002D642B /* Build configuration list for PBXNativeTarget "GeminiIntents" */;
buildPhases = (
D68C1E1F270614F9002D642B /* Sources */,
D68C1E20270614F9002D642B /* Frameworks */,
D68C1E21270614F9002D642B /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = GeminiIntents;
productName = GeminiIntents;
productReference = D68C1E23270614F9002D642B /* GeminiIntents.appex */;
productType = "com.apple.product-type.app-extension";
};
D6E152A124BFFDF500FDF9D3 /* Gemini-iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = D6E152B324BFFDF600FDF9D3 /* Build configuration list for PBXNativeTarget "Gemini-iOS" */;
@ -834,6 +958,7 @@
D6E1529F24BFFDF500FDF9D3 /* Frameworks */,
D6E152A024BFFDF500FDF9D3 /* Resources */,
D6E152C224BFFE2500FDF9D3 /* Embed Frameworks */,
D68C1E30270614F9002D642B /* Embed App Extensions */,
);
buildRules = (
);
@ -842,6 +967,7 @@
D68543FC2522DEF5004C4AE0 /* PBXTargetDependency */,
D68543FA2522DEF3004C4AE0 /* PBXTargetDependency */,
D68543F82522DEF0004C4AE0 /* PBXTargetDependency */,
D68C1E2B270614F9002D642B /* PBXTargetDependency */,
);
name = "Gemini-iOS";
productName = "Gemini-iOS";
@ -894,7 +1020,7 @@
D626645324BBF1C200DF9B88 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1200;
LastSwiftUpdateCheck = 1300;
LastUpgradeCheck = 1220;
TargetAttributes = {
D626645A24BBF1C200DF9B88 = {
@ -924,6 +1050,9 @@
CreatedOnToolsVersion = 12.0;
TestTargetID = D626645A24BBF1C200DF9B88;
};
D68C1E22270614F9002D642B = {
CreatedOnToolsVersion = 13.0;
};
D6E152A124BFFDF500FDF9D3 = {
CreatedOnToolsVersion = 12.0;
};
@ -962,6 +1091,7 @@
D62664D524BC081B00DF9B88 /* GeminiRendererTests */,
D6E152CF24C0007200FDF9D3 /* BrowserCore */,
D6E152D724C0007200FDF9D3 /* BrowserCoreTests */,
D68C1E22270614F9002D642B /* GeminiIntents */,
);
};
/* End PBXProject section */
@ -1020,6 +1150,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D68C1E21270614F9002D642B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D6E152A024BFFDF500FDF9D3 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@ -1086,6 +1223,8 @@
files = (
D62664C824BBF2C600DF9B88 /* Document.swift in Sources */,
D62664C624BBF27300DF9B88 /* GeminiParser.swift in Sources */,
D68C1E492708A958002D642B /* GeminiHTMLRenderer.swift in Sources */,
D68C1E482708A958002D642B /* GeminiMarkdownRenderer.swift in Sources */,
D6BC9AC5258F01F6008652BC /* TableOfContents.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1096,7 +1235,9 @@
files = (
D62664FA24BC12BC00DF9B88 /* DocumentTests.swift in Sources */,
D6BC9ACE258F07BC008652BC /* TableOfContentsTests.swift in Sources */,
D68C1E4A2708A95D002D642B /* GeminiMarkdownRendererTests.swift in Sources */,
D62664B824BBF26A00DF9B88 /* GeminiParserTests.swift in Sources */,
D68C1E4B2708A95D002D642B /* GeminiHTMLRendererTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -1109,7 +1250,6 @@
D62664EE24BC0BCE00DF9B88 /* MaybeLazyVStack.swift in Sources */,
D62664EC24BC0B4D00DF9B88 /* DocumentView.swift in Sources */,
D6DA5783252396030048B65A /* View+Extensions.swift in Sources */,
D688F586258AC738003A0A73 /* GeminiHTMLRenderer.swift in Sources */,
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1122,6 +1262,24 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
D68C1E1F270614F9002D642B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D68C1E3227061557002D642B /* OpenURLIntentHandler.swift in Sources */,
D68C1E3B2707BF25002D642B /* GetStatusIntentHandler.swift in Sources */,
D68C1E312706150E002D642B /* Intents.intentdefinition in Sources */,
D68C1E392707A1E5002D642B /* GetBodyIntentHandler.swift in Sources */,
D68C1E522708B2D3002D642B /* OpenHomepageIntentHandler.swift in Sources */,
D68C1E3D2707C002002D642B /* GetMetaIntentHandler.swift in Sources */,
D68C1E3F2707C3B2002D642B /* GemtextToHTMLIntentHandler.swift in Sources */,
D68C1E28270614F9002D642B /* IntentHandler.swift in Sources */,
D68C1E412707CB03002D642B /* GemtextToMarkdownIntentHandler.swift in Sources */,
D68C1E372706215A002D642B /* MakeRequestIntentHandler.swift in Sources */,
D68C1E3327061568002D642B /* UserActivities.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D6E1529E24BFFDF500FDF9D3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@ -1130,17 +1288,23 @@
D688F599258ACAAE003A0A73 /* BrowserWebViewController.swift in Sources */,
D688F633258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift in Sources */,
D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */,
D640A2322711DC7700177E85 /* ToolbarPrefView.swift in Sources */,
D6E152A724BFFDF500FDF9D3 /* SceneDelegate.swift in Sources */,
D68C1E35270615D3002D642B /* UserActivities.swift in Sources */,
D6376A7026DDAF65005AD89C /* URIFixup.swift in Sources */,
D68C1E34270615D3002D642B /* OpenURLIntentHandler.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 */,
D664E4FA2713DF72005BAF55 /* ToolbarItem.swift in Sources */,
D6BC9AD7258FC8B3008652BC /* TableOfContentsView.swift in Sources */,
D68C1E1927055E09002D642B /* Intents.intentdefinition in Sources */,
D688F663258C2479003A0A73 /* UIViewController+Children.swift in Sources */,
D653F40D26799F2F004E32B1 /* HomepagePrefView.swift in Sources */,
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */,
D68C1E512708B276002D642B /* OpenHomepageIntentHandler.swift in Sources */,
D6BC9ABC258E9862008652BC /* NavigationBarView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1149,6 +1313,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D68C1E1E270605A7002D642B /* BrowserHelper.swift in Sources */,
D6E152F124C000A000FDF9D3 /* NavigationManager.swift in Sources */,
D6E152F224C000CD00FDF9D3 /* BrowserView.swift in Sources */,
);
@ -1250,6 +1415,11 @@
target = D62664A724BBF26A00DF9B88 /* GeminiFormat */;
targetProxy = D685442F2522E10F004C4AE0 /* PBXContainerItemProxy */;
};
D68C1E2B270614F9002D642B /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D68C1E22270614F9002D642B /* GeminiIntents */;
targetProxy = D68C1E2A270614F9002D642B /* PBXContainerItemProxy */;
};
D6E152DB24C0007200FDF9D3 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D6E152CF24C0007200FDF9D3 /* BrowserCore */;
@ -1449,6 +1619,7 @@
D626648F24BBF22E00DF9B88 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
@ -1482,6 +1653,7 @@
D626649024BBF22E00DF9B88 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
@ -1554,6 +1726,7 @@
D62664C024BBF26A00DF9B88 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
@ -1587,6 +1760,7 @@
D62664C124BBF26A00DF9B88 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
@ -1659,6 +1833,7 @@
D62664E624BC081B00DF9B88 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
@ -1692,6 +1867,7 @@
D62664E724BC081B00DF9B88 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
@ -1761,6 +1937,63 @@
};
name = Release;
};
D68C1E2E270614F9002D642B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = V4WK9KR9U2;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = GeminiIntents/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = GeminiIntents;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 13.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2021.1;
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Gemini.GeminiIntents;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
D68C1E2F270614F9002D642B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = V4WK9KR9U2;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = GeminiIntents/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = GeminiIntents;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 13.5;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2021.1;
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Gemini.GeminiIntents;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
D6E152B424BFFDF600FDF9D3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@ -1817,6 +2050,7 @@
D6E152E824C0007200FDF9D3 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@ -1848,6 +2082,7 @@
D6E152E924C0007200FDF9D3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
@ -1991,6 +2226,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D68C1E2D270614F9002D642B /* Build configuration list for PBXNativeTarget "GeminiIntents" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D68C1E2E270614F9002D642B /* Debug */,
D68C1E2F270614F9002D642B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
D6E152B324BFFDF600FDF9D3 /* Build configuration list for PBXNativeTarget "Gemini-iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@ -2032,7 +2276,7 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
D688F58F258AC814003A0A73 /* HTMLEntities */ = {
D68C1E4C2708A981002D642B /* HTMLEntities */ = {
isa = XCSwiftPackageProductDependency;
package = D688F58E258AC814003A0A73 /* XCRemoteSwiftPackageReference "swift-html-entities" */;
productName = HTMLEntities;

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D68C1E22270614F9002D642B"
BuildableName = "GeminiIntents.appex"
BlueprintName = "GeminiIntents"
ReferencedContainer = "container:Gemini.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D6E152A124BFFDF500FDF9D3"
BuildableName = "Gemini-iOS.app"
BlueprintName = "Gemini-iOS"
ReferencedContainer = "container:Gemini.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D6E152A124BFFDF500FDF9D3"
BuildableName = "Gemini-iOS.app"
BlueprintName = "Gemini-iOS"
ReferencedContainer = "container:Gemini.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D6E152A124BFFDF500FDF9D3"
BuildableName = "Gemini-iOS.app"
BlueprintName = "Gemini-iOS"
ReferencedContainer = "container:Gemini.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -7,7 +7,7 @@
<key>BrowserCore.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>4</integer>
</dict>
<key>Gemini-iOS.xcscheme_^#shared#^_</key>
<dict>
@ -24,15 +24,20 @@
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>GeminiIntents.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>GeminiProtocol.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
<integer>5</integer>
</dict>
<key>GeminiRenderer.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>5</integer>
<integer>6</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
@ -62,6 +67,11 @@
<key>primary</key>
<true/>
</dict>
<key>D68C1E22270614F9002D642B</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>D6E152A124BFFDF500FDF9D3</key>
<dict>
<key>primary</key>

View File

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

View File

@ -6,12 +6,13 @@
//
import Foundation
import GeminiFormat
import HTMLEntities
public class GeminiHTMLRenderer {
public var linkPrefix: ((URL) -> String?)?
public var addHeadingLineIDs = true
public var addLinkClass = true
public init() {
}
@ -25,6 +26,7 @@ public class GeminiHTMLRenderer {
for (index, line) in doc.lines.enumerated() {
if inList && !line.isListItem {
str += "</ul>"
inList = false
}
switch line {
@ -33,7 +35,11 @@ public class GeminiHTMLRenderer {
case let .link(url, text: maybeText):
let text = maybeText ?? url.absoluteString
let linkPrefix = self.linkPrefix?(url) ?? ""
str += "<p class=\"link\">\(linkPrefix)<a href=\"\(url.absoluteString)\">\(text.htmlEscape())</a></p>"
str += "<p"
if addLinkClass {
str += " class=\"link\""
}
str += ">\(linkPrefix)<a href=\"\(url.absoluteString)\">\(text.htmlEscape())</a></p>"
case .preformattedToggle(alt: _):
inPreformatting = !inPreformatting
if inPreformatting {
@ -46,7 +52,11 @@ public class GeminiHTMLRenderer {
str += "\n"
case let .heading(text, level: level):
let tag = "h\(level.rawValue)"
str += "<\(tag) id=\"l\(index)\">\(text.htmlEscape())</\(tag)>"
str += "<\(tag)"
if addHeadingLineIDs {
str += " id=\"l\(index)\""
}
str += ">\(text.htmlEscape())</\(tag)>"
case let .unorderedListItem(text):
if !inList {
inList = true
@ -63,7 +73,7 @@ public class GeminiHTMLRenderer {
}
fileprivate extension Document.Line {
extension Document.Line {
var isListItem: Bool {
switch self {
case .unorderedListItem(_):

View File

@ -0,0 +1,81 @@
//
// GeminiMarkdownRenderer.swift
// GeminiRenderer
//
// Created by Shadowfacts on 10/1/21.
//
import Foundation
import HTMLEntities
public class GeminiMarkdownRenderer {
public init() {
}
public func renderDocumentToMarkdown(_ doc: Document) -> String {
var str = ""
var inPreformatting = false
var inList = false
for line in doc.lines {
if inList && !line.isListItem {
str += "\n"
inList = false
}
switch line {
case let .text(text):
if !text.trimmingCharacters(in: .whitespaces).isEmpty {
str += text.htmlEscape()
str += "\n\n"
}
case let .link(url, text: maybeText):
let text = maybeText ?? url.absoluteString
// todo: do ] in the text need to be escaped?
str += "[\(text.htmlEscape())](\(url))"
str += "\n\n"
case let .preformattedToggle(alt: alt):
inPreformatting = !inPreformatting
if inPreformatting {
str += "```"
if let alt = alt {
str += alt
}
str += "\n"
} else {
str += "```"
str += "\n\n"
}
case let .preformattedText(text):
str += text
str += "\n"
case let .heading(text, level: level):
str += String(repeating: "#", count: level.rawValue)
str += " "
str += text.htmlEscape()
str += "\n\n"
case let .unorderedListItem(text):
if !inList {
inList = true
}
str += "* \(text.htmlEscape())"
str += "\n"
case let .quote(text):
str += "> "
str += text.htmlEscape()
str += "\n\n"
}
}
return str
}
}

View File

@ -0,0 +1,112 @@
//
// GeminiHTMLRendererTests.swift
// GeminiRendererTests
//
// Created by Shadowfacts on 10/1/21.
//
import XCTest
import GeminiFormat
@testable import GeminiRenderer
class GeminiHTMLRendererTests: XCTestCase {
private var doc: Document!
override func setUp() {
doc = Document(url: URL(string: "gemini://example.com/")!)
}
func testEscapeToEntities() {
doc.lines = [.text("<b>hello</b>")]
let html = GeminiHTMLRenderer().renderDocumentToHTML(doc)
XCTAssertEqual(html, "<p>&#x3C;b&#x3E;hello&#x3C;/b&#x3E;</p>")
}
func testParagraph() {
doc.lines = [.text("Hello, world!")]
let html = GeminiHTMLRenderer().renderDocumentToHTML(doc)
XCTAssertEqual(html, "<p>Hello, world!</p>")
}
func testLink() {
doc.lines = [.link(URL(string: "gemini://example.com/")!, text: "text")]
let html = GeminiHTMLRenderer().renderDocumentToHTML(doc)
XCTAssertEqual(html, "<p class=\"link\"><a href=\"gemini://example.com/\">text</a></p>")
doc.lines = [.link(URL(string: "gemini://example.com/")!, text: nil)]
let noText = GeminiHTMLRenderer().renderDocumentToHTML(doc)
XCTAssertEqual(noText, "<p class=\"link\"><a href=\"gemini://example.com/\">gemini://example.com/</a></p>")
}
func testDisableLinkClass() {
doc.lines = [.link(URL(string: "gemini://example.com/")!, text: "test")]
let renderer = GeminiHTMLRenderer()
renderer.addLinkClass = false
let html = renderer.renderDocumentToHTML(doc)
XCTAssertEqual(html, "<p><a href=\"gemini://example.com/\">test</a></p>")
}
func testPreformatting() {
doc.lines = [
.preformattedToggle(alt: nil),
.preformattedText("foo"),
.preformattedText("* bar"),
.preformattedToggle(alt: nil),
]
let html = GeminiHTMLRenderer().renderDocumentToHTML(doc)
XCTAssertEqual(html, "<pre>foo\n* bar\n</pre>")
}
func testHeading() {
doc.lines = [
.heading("One", level: .h1),
.heading("Two", level: .h2),
]
let html = GeminiHTMLRenderer().renderDocumentToHTML(doc)
XCTAssertEqual(html, "<h1 id=\"l0\">One</h1><h2 id=\"l1\">Two</h2>")
}
func testDisableHeadingIDs() {
doc.lines = [
.heading("One", level: .h1),
.heading("Two", level: .h2),
]
let renderer = GeminiHTMLRenderer()
renderer.addHeadingLineIDs = false
let html = renderer.renderDocumentToHTML(doc)
XCTAssertEqual(html, "<h1>One</h1><h2>Two</h2>")
}
func testUnorderedList() {
doc.lines = [
.text("before"),
.unorderedListItem("a"),
.unorderedListItem("b"),
.unorderedListItem("c"),
.text("after"),
]
let html = GeminiHTMLRenderer().renderDocumentToHTML(doc)
XCTAssertEqual(html, "<p>before</p><ul><li>a</li><li>b</li><li>c</li></ul><p>after</p>")
}
func testQuote() {
doc.lines = [
.quote("quoted")
]
let html = GeminiHTMLRenderer().renderDocumentToHTML(doc)
XCTAssertEqual(html, "<blockquote>quoted</blockquote>")
}
func testStuffAfterList() {
doc.lines = [
.unorderedListItem("a"),
.unorderedListItem("b"),
.text("c"),
.text("d"),
]
let html = GeminiHTMLRenderer().renderDocumentToHTML(doc)
XCTAssertEqual(html, "<ul><li>a</li><li>b</li></ul><p>c</p><p>d</p>")
}
}

View File

@ -0,0 +1,103 @@
//
// GeminiMarkdownRendererTests.swift
// GeminiRendererTests
//
// Created by Shadowfacts on 10/1/21.
//
import XCTest
import GeminiFormat
@testable import GeminiRenderer
class GeminiMarkdownRendererTests: XCTestCase {
private var doc: Document!
override func setUp() {
doc = Document(url: URL(string: "gemini://example.com/")!)
}
func testEscapeToEntities() {
doc.lines = [.text("<b>hello</b>")]
let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc)
XCTAssertEqual(markdown, "&#x3C;b&#x3E;hello&#x3C;/b&#x3E;\n\n")
}
func testParagraph() {
doc.lines = [.text("Hello, world!")]
let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc)
XCTAssertEqual(markdown, "Hello, world!\n\n")
}
func testLink() {
doc.lines = [.link(URL(string: "gemini://example.com/")!, text: "text")]
let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc)
XCTAssertEqual(markdown, "[text](gemini://example.com/)\n\n")
doc.lines = [.link(URL(string: "gemini://example.com/")!, text: nil)]
let noText = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc)
XCTAssertEqual(noText, "[gemini://example.com/](gemini://example.com/)\n\n")
}
func testLinksAfterList() {
doc.lines = [
.unorderedListItem("a"),
.unorderedListItem("b"),
.link(URL(string: "gemini://example.com")!, text: "one"),
.link(URL(string: "gemini://example.com")!, text: "two"),
]
let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc)
XCTAssertEqual(markdown, "* a\n* b\n\n[one](gemini://example.com)\n\n[two](gemini://example.com)\n\n")
}
func testPreformatting() {
doc.lines = [
.preformattedToggle(alt: nil),
.preformattedText("foo"),
.preformattedText("* bar"),
.preformattedToggle(alt: nil),
]
let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc)
XCTAssertEqual(markdown, "```\nfoo\n* bar\n```\n\n")
}
func testHeading() {
doc.lines = [
.heading("One", level: .h1),
.heading("Two", level: .h2),
]
let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc)
XCTAssertEqual(markdown, "# One\n\n## Two\n\n")
}
func testUnorderedList() {
doc.lines = [
.text("before"),
.unorderedListItem("a"),
.unorderedListItem("b"),
.unorderedListItem("c"),
.text("after"),
]
let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc)
XCTAssertEqual(markdown, "before\n\n* a\n* b\n* c\n\nafter\n\n")
}
func testQuote() {
doc.lines = [
.quote("quoted")
]
let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc)
XCTAssertEqual(markdown, "> quoted\n\n")
}
func testSkipBlankLines() {
doc.lines = [
.heading("Hello", level: .h1),
.text(""),
.text("World"),
]
let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc)
XCTAssertEqual(markdown, "# Hello\n\nWorld\n\n")
}
}

View File

@ -0,0 +1,30 @@
//
// GemtextToHTMLIntentHandler.swift
// GeminiIntents
//
// Created by Shadowfacts on 10/1/21.
//
import Intents
import GeminiFormat
class GemtextToHTMLIntentHandler: NSObject, GemtextToHTMLIntentHandling {
func handle(intent: GemtextToHTMLIntent, completion: @escaping (GemtextToHTMLIntentResponse) -> Void) {
guard let response = intent.response,
let text = response.body,
let url = response.url else {
completion(GemtextToHTMLIntentResponse(code: .failure, userActivity: nil))
return
}
let doc = GeminiParser.parse(text: text, baseURL: url)
let renderer = GeminiHTMLRenderer()
renderer.addLinkClass = false
renderer.addHeadingLineIDs = false
let html = renderer.renderDocumentToHTML(doc)
let intentResp = GemtextToHTMLIntentResponse(code: .success, userActivity: nil)
intentResp.html = INFile(data: html.data(using: .utf8)!, filename: "converted_gemtext.html", typeIdentifier: "public.html")
completion(intentResp)
}
}

View File

@ -0,0 +1,28 @@
//
// GemtextToMarkdownIntentHandler.swift
// GeminiIntents
//
// Created by Shadowfacts on 10/1/21.
//
import Intents
import GeminiFormat
class GemtextToMarkdownIntentHandler: NSObject, GemtextToMarkdownIntentHandling {
func handle(intent: GemtextToMarkdownIntent, completion: @escaping (GemtextToMarkdownIntentResponse) -> Void) {
guard let response = intent.response,
let text = response.body,
let url = response.url else {
completion(GemtextToMarkdownIntentResponse(code: .failure, userActivity: nil))
return
}
let doc = GeminiParser.parse(text: text, baseURL: url)
let renderer = GeminiMarkdownRenderer()
let html = renderer.renderDocumentToMarkdown(doc)
let intentResp = GemtextToMarkdownIntentResponse(code: .success, userActivity: nil)
intentResp.markdown = INFile(data: html.data(using: .utf8)!, filename: "converted_gemtext.md", typeIdentifier: "net.daringfireball.markdown")
completion(intentResp)
}
}

View File

@ -0,0 +1,22 @@
//
// GetBodyIntenHandler.swift
// GeminiIntents
//
// Created by Shadowfacts on 10/1/21.
//
import Intents
class GetBodyIntentHandler: NSObject, GetBodyIntentHandling {
func handle(intent: GetBodyIntent, completion: @escaping (GetBodyIntentResponse) -> Void) {
guard let response = intent.response else {
completion(GetBodyIntentResponse(code: .failure, userActivity: nil))
return
}
let intentResp = GetBodyIntentResponse(code: .success, userActivity: nil)
intentResp.text = response.body
completion(intentResp)
}
}

View File

@ -0,0 +1,22 @@
//
// GetMetaIntentHandler.swift
// GeminiIntents
//
// Created by Shadowfacts on 10/1/21.
//
import Intents
class GetMetaIntentHandler: NSObject, GetMetaIntentHandling {
func handle(intent: GetMetaIntent, completion: @escaping (GetMetaIntentResponse) -> Void) {
guard let response = intent.response else {
completion(GetMetaIntentResponse(code: .failure, userActivity: nil))
return
}
let intentResp = GetMetaIntentResponse(code: .success, userActivity: nil)
intentResp.meta = response.meta
completion(intentResp)
}
}

View File

@ -0,0 +1,22 @@
//
// GetStatusIntentHandler.swift
// GeminiIntents
//
// Created by Shadowfacts on 10/1/21.
//
import Intents
class GetStatusIntentHandler: NSObject, GetStatusIntentHandling {
func handle(intent: GetStatusIntent, completion: @escaping (GetStatusIntentResponse) -> Void) {
guard let response = intent.response else {
completion(GetStatusIntentResponse(code: .failure, userActivity: nil))
return
}
let intentResp = GetStatusIntentResponse(code: .success, userActivity: nil)
intentResp.status = response.status.rawValue as NSNumber
completion(intentResp)
}
}

30
GeminiIntents/Info.plist Normal file
View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsRestrictedWhileLocked</key>
<array/>
<key>IntentsRestrictedWhileProtectedDataUnavailable</key>
<array/>
<key>IntentsSupported</key>
<array>
<string>GemtextToHTMLIntent</string>
<string>GemtextToMarkdownIntent</string>
<string>GetBodyIntent</string>
<string>GetMetaIntent</string>
<string>GetStatusIntent</string>
<string>MakeRequestIntent</string>
<string>OpenURLIntent</string>
</array>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.intents-service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).IntentHandler</string>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,43 @@
//
// IntentHandler.swift
// GeminiIntents
//
// Created by Shadowfacts on 9/30/21.
//
import Intents
class IntentHandler: INExtension {
override func handler(for intent: INIntent) -> Any? {
switch intent {
// we also need to support extension-based handling because in-app handling isn't support <iOS 14
case is OpenURLIntent:
return OpenURLIntentHandler()
case is OpenHomepageIntent:
return OpenHomepageIntentHandler()
case is MakeRequestIntent:
return MakeRequestIntentHandler()
case is GetBodyIntent:
return GetBodyIntentHandler()
case is GetStatusIntent:
return GetStatusIntentHandler()
case is GetMetaIntent:
return GetMetaIntentHandler()
case is GemtextToHTMLIntent:
return GemtextToHTMLIntentHandler()
case is GemtextToMarkdownIntent:
return GemtextToMarkdownIntentHandler()
default:
return nil
}
}
}

View File

@ -0,0 +1,77 @@
//
// MakeRequestIntentHandler.swift
// GeminiIntents
//
// Created by Shadowfacts on 9/30/21.
//
import Intents
import GeminiProtocol
import BrowserCore
class MakeRequestIntentHandler: NSObject, MakeRequestIntentHandling {
private var task: GeminiDataTask?
func resolveUrl(for intent: MakeRequestIntent, with completion: @escaping (INURLResolutionResult) -> Void) {
guard let url = intent.url,
url.scheme?.lowercased() == "gemini" else {
completion(.unsupported())
return
}
completion(.success(with: url))
}
func handle(intent: MakeRequestIntent, completion: @escaping (MakeRequestIntentResponse) -> Void) {
guard let url = intent.url else {
completion(MakeRequestIntentResponse(code: .failure, userActivity: nil))
return
}
makeRequest(for: url, followRedirects: intent.followRedirects?.boolValue ?? true, completion: completion)
}
private func makeRequest(for url: URL, followRedirects: Bool, completion: @escaping (MakeRequestIntentResponse) -> Void) {
guard let request = try? GeminiRequest(url: url) else {
completion(MakeRequestIntentResponse(code: .failure, userActivity: nil))
return
}
// foo(bar(baz))
// a
task = GeminiDataTask(request: request) { [unowned self] (result) in
switch result {
case let .success(response):
if response.status.isRedirect && followRedirects {
if let newURL = URL(string: response.meta, relativeTo: url) {
self.makeRequest(for: newURL, followRedirects: followRedirects, completion: completion)
} else {
completion(MakeRequestIntentResponse(code: .failure, userActivity: nil))
}
} else {
let intentResp = MakeRequestIntentResponse(code: .success, userActivity: nil)
let displayStr = "\(response.status), \(BrowserHelper.urlForDisplay(url)), '\(response.meta)'"
intentResp.response = Response(identifier: url.absoluteString, display: displayStr)
intentResp.response!.url = url
intentResp.response!.body = response.bodyText
intentResp.response!.status = ResponseStatus(rawValue: response.status.rawValue)!
intentResp.response!.meta = response.meta
// intentResp.status = ResponseStatus(rawValue: response.status.rawValue)!
// intentResp.status = NSNumber(integerLiteral: response.status.rawValue)
// intentResp.statusCategory = ResponseStatusCategory(rawValue: response.status.rawValue / 10)!
// intentResp.meta = response.meta
// intentResp.mimeType = response.mimeType
// intentResp.body = response.bodyText
completion(intentResp)
}
case .failure(_):
completion(MakeRequestIntentResponse(code: .failure, userActivity: nil))
}
}
if #available(iOS 15.0, *) {
task!.attribution = .user
}
task!.resume()
}
}

View File

@ -17,6 +17,19 @@ public class GeminiDataTask {
private let completion: Completion
private var state = State.unstarted
private let connection: NWConnection
// todo: remove stupid hack when deployment target is >= iOS 15/macOS 12
private var _attribution: Any? = nil
#if os(iOS)
@available(iOS 15.0, *)
public var attribution: NWParameters.Attribution {
get {
_attribution as? NWParameters.Attribution ?? .developer
}
set {
_attribution = newValue
}
}
#endif
public init(request: GeminiRequest, completion: @escaping Completion) {
self.request = request
@ -50,6 +63,13 @@ public class GeminiDataTask {
public func resume() {
guard state == .unstarted else { return }
#if os(iOS)
if #available(iOS 15.0, *) {
connection.parameters.attribution = attribution
}
#endif
state = .started
connection.start(queue: GeminiDataTask.queue)
}

View File

@ -60,7 +60,6 @@ class GeminiProtocol: NWProtocolFramerImplementation {
_ = framer.parseInput(minimumIncompleteLength: min, maximumLength: 1024 + 2) { (buffer, isComplete) -> Int in
guard let buffer = buffer,
buffer.count >= 2 else { return 0 }
print("got count: \(buffer.count)")
self.lastAttemptedMetaLength = buffer.count
let lastPossibleCRIndex = buffer.index(before: buffer.index(before: buffer.endIndex))

View File

@ -8,6 +8,8 @@
import Network
extension NWParameters {
/// An NWParameters configured with the GeminiProtocol and appropriate TLS options.
/// This property always returns a new NWParameters instance.
static var gemini: NWParameters {
let tlsOptions = geminiTLSOptions
let tcpOptions = NWProtocolTCP.Options()