Compare commits

..

No commits in common. "29bd87f28761a8d8591d55df18bf28f6936dfb1f" and "7da0368758bd6700f0bf21fe473b17e516fd2c84" have entirely different histories.

14 changed files with 192 additions and 498 deletions

View File

@ -23,6 +23,7 @@
D626649F24BBF24100DF9B88 /* NWParameters+Gemini.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626649824BBF24100DF9B88 /* NWParameters+Gemini.swift */; }; D626649F24BBF24100DF9B88 /* NWParameters+Gemini.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626649824BBF24100DF9B88 /* NWParameters+Gemini.swift */; };
D62664A024BBF24100DF9B88 /* GeminiProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626649924BBF24100DF9B88 /* GeminiProtocol.swift */; }; D62664A024BBF24100DF9B88 /* GeminiProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626649924BBF24100DF9B88 /* GeminiProtocol.swift */; };
D62664A124BBF24100DF9B88 /* GeminiRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626649A24BBF24100DF9B88 /* GeminiRequest.swift */; }; D62664A124BBF24100DF9B88 /* GeminiRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626649A24BBF24100DF9B88 /* GeminiRequest.swift */; };
D62664A224BBF24100DF9B88 /* GeminiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D626649B24BBF24100DF9B88 /* GeminiResponse.swift */; };
D62664B124BBF26A00DF9B88 /* GeminiFormat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D62664A824BBF26A00DF9B88 /* GeminiFormat.framework */; }; D62664B124BBF26A00DF9B88 /* GeminiFormat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D62664A824BBF26A00DF9B88 /* GeminiFormat.framework */; };
D62664B824BBF26A00DF9B88 /* GeminiParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62664B724BBF26A00DF9B88 /* GeminiParserTests.swift */; }; D62664B824BBF26A00DF9B88 /* GeminiParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62664B724BBF26A00DF9B88 /* GeminiParserTests.swift */; };
D62664BA24BBF26A00DF9B88 /* GeminiFormat.h in Headers */ = {isa = PBXBuildFile; fileRef = D62664AA24BBF26A00DF9B88 /* GeminiFormat.h */; settings = {ATTRIBUTES = (Public, ); }; }; D62664BA24BBF26A00DF9B88 /* GeminiFormat.h in Headers */ = {isa = PBXBuildFile; fileRef = D62664AA24BBF26A00DF9B88 /* GeminiFormat.h */; settings = {ATTRIBUTES = (Public, ); }; };
@ -43,9 +44,6 @@
D664673624BD07F700B0B741 /* RenderingBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673524BD07F700B0B741 /* RenderingBlock.swift */; }; D664673624BD07F700B0B741 /* RenderingBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673524BD07F700B0B741 /* RenderingBlock.swift */; };
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673724BD086F00B0B741 /* RenderingBlockView.swift */; }; D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673724BD086F00B0B741 /* RenderingBlockView.swift */; };
D664673A24BD0B8E00B0B741 /* Fonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673924BD0B8E00B0B741 /* Fonts.swift */; }; D664673A24BD0B8E00B0B741 /* Fonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673924BD0B8E00B0B741 /* Fonts.swift */; };
D69F00AC24BE9DD300E37622 /* GeminiDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */; };
D69F00AE24BEA29100E37622 /* GeminiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AD24BEA29100E37622 /* GeminiResponse.swift */; };
D69F00B024BEA84D00E37622 /* NavigationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AF24BEA84D00E37622 /* NavigationManager.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -169,6 +167,7 @@
D626649824BBF24100DF9B88 /* NWParameters+Gemini.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NWParameters+Gemini.swift"; sourceTree = "<group>"; }; D626649824BBF24100DF9B88 /* NWParameters+Gemini.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NWParameters+Gemini.swift"; sourceTree = "<group>"; };
D626649924BBF24100DF9B88 /* GeminiProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeminiProtocol.swift; sourceTree = "<group>"; }; D626649924BBF24100DF9B88 /* GeminiProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeminiProtocol.swift; sourceTree = "<group>"; };
D626649A24BBF24100DF9B88 /* GeminiRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeminiRequest.swift; sourceTree = "<group>"; }; D626649A24BBF24100DF9B88 /* GeminiRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeminiRequest.swift; sourceTree = "<group>"; };
D626649B24BBF24100DF9B88 /* GeminiResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeminiResponse.swift; sourceTree = "<group>"; };
D62664A824BBF26A00DF9B88 /* GeminiFormat.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = GeminiFormat.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D62664A824BBF26A00DF9B88 /* GeminiFormat.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = GeminiFormat.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D62664AA24BBF26A00DF9B88 /* GeminiFormat.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeminiFormat.h; sourceTree = "<group>"; }; D62664AA24BBF26A00DF9B88 /* GeminiFormat.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeminiFormat.h; sourceTree = "<group>"; };
D62664AB24BBF26A00DF9B88 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D62664AB24BBF26A00DF9B88 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -189,9 +188,6 @@
D664673524BD07F700B0B741 /* RenderingBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlock.swift; sourceTree = "<group>"; }; D664673524BD07F700B0B741 /* RenderingBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlock.swift; sourceTree = "<group>"; };
D664673724BD086F00B0B741 /* RenderingBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlockView.swift; sourceTree = "<group>"; }; D664673724BD086F00B0B741 /* RenderingBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlockView.swift; sourceTree = "<group>"; };
D664673924BD0B8E00B0B741 /* Fonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fonts.swift; sourceTree = "<group>"; }; D664673924BD0B8E00B0B741 /* Fonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fonts.swift; sourceTree = "<group>"; };
D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiDataTask.swift; sourceTree = "<group>"; };
D69F00AD24BEA29100E37622 /* GeminiResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiResponse.swift; sourceTree = "<group>"; };
D69F00AF24BEA84D00E37622 /* NavigationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationManager.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -288,7 +284,6 @@
children = ( children = (
D626645E24BBF1C200DF9B88 /* AppDelegate.swift */, D626645E24BBF1C200DF9B88 /* AppDelegate.swift */,
D626646024BBF1C200DF9B88 /* ContentView.swift */, D626646024BBF1C200DF9B88 /* ContentView.swift */,
D69F00AF24BEA84D00E37622 /* NavigationManager.swift */,
D626646224BBF1C300DF9B88 /* Assets.xcassets */, D626646224BBF1C300DF9B88 /* Assets.xcassets */,
D626646724BBF1C300DF9B88 /* Main.storyboard */, D626646724BBF1C300DF9B88 /* Main.storyboard */,
D626646A24BBF1C300DF9B88 /* Info.plist */, D626646A24BBF1C300DF9B88 /* Info.plist */,
@ -315,10 +310,9 @@
D626649624BBF24100DF9B88 /* Message+Gemini.swift */, D626649624BBF24100DF9B88 /* Message+Gemini.swift */,
D626649924BBF24100DF9B88 /* GeminiProtocol.swift */, D626649924BBF24100DF9B88 /* GeminiProtocol.swift */,
D626649A24BBF24100DF9B88 /* GeminiRequest.swift */, D626649A24BBF24100DF9B88 /* GeminiRequest.swift */,
D626649B24BBF24100DF9B88 /* GeminiResponse.swift */,
D626649524BBF24000DF9B88 /* GeminiResponseHeader.swift */, D626649524BBF24000DF9B88 /* GeminiResponseHeader.swift */,
D69F00AD24BEA29100E37622 /* GeminiResponse.swift */,
D626649724BBF24100DF9B88 /* GeminiConnection.swift */, D626649724BBF24100DF9B88 /* GeminiConnection.swift */,
D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */,
); );
path = GeminiProtocol; path = GeminiProtocol;
sourceTree = "<group>"; sourceTree = "<group>";
@ -671,7 +665,6 @@
files = ( files = (
D626646124BBF1C200DF9B88 /* ContentView.swift in Sources */, D626646124BBF1C200DF9B88 /* ContentView.swift in Sources */,
D626645F24BBF1C200DF9B88 /* AppDelegate.swift in Sources */, D626645F24BBF1C200DF9B88 /* AppDelegate.swift in Sources */,
D69F00B024BEA84D00E37622 /* NavigationManager.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -680,13 +673,12 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D62664A024BBF24100DF9B88 /* GeminiProtocol.swift in Sources */, D62664A024BBF24100DF9B88 /* GeminiProtocol.swift in Sources */,
D62664A224BBF24100DF9B88 /* GeminiResponse.swift in Sources */,
D626649E24BBF24100DF9B88 /* GeminiConnection.swift in Sources */, D626649E24BBF24100DF9B88 /* GeminiConnection.swift in Sources */,
D69F00AC24BE9DD300E37622 /* GeminiDataTask.swift in Sources */,
D62664A124BBF24100DF9B88 /* GeminiRequest.swift in Sources */, D62664A124BBF24100DF9B88 /* GeminiRequest.swift in Sources */,
D626649F24BBF24100DF9B88 /* NWParameters+Gemini.swift in Sources */, D626649F24BBF24100DF9B88 /* NWParameters+Gemini.swift in Sources */,
D626649D24BBF24100DF9B88 /* Message+Gemini.swift in Sources */, D626649D24BBF24100DF9B88 /* Message+Gemini.swift in Sources */,
D626649C24BBF24100DF9B88 /* GeminiResponseHeader.swift in Sources */, D626649C24BBF24100DF9B88 /* GeminiResponseHeader.swift in Sources */,
D69F00AE24BEA29100E37622 /* GeminiResponse.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@ -1,115 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1200"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D626645A24BBF1C200DF9B88"
BuildableName = "Gemini.app"
BlueprintName = "Gemini"
ReferencedContainer = "container:Gemini.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D626647E24BBF22E00DF9B88"
BuildableName = "GeminiProtocolTests.xctest"
BlueprintName = "GeminiProtocolTests"
ReferencedContainer = "container:Gemini.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D62664AF24BBF26A00DF9B88"
BuildableName = "GeminiFormatTests.xctest"
BlueprintName = "GeminiFormatTests"
ReferencedContainer = "container:Gemini.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D62664D524BC081B00DF9B88"
BuildableName = "GeminiRendererTests.xctest"
BlueprintName = "GeminiRendererTests"
ReferencedContainer = "container:Gemini.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D626645A24BBF1C200DF9B88"
BuildableName = "Gemini.app"
BlueprintName = "Gemini"
ReferencedContainer = "container:Gemini.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "CFNETWORK_DIAGNOSTICS"
value = "3"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D626645A24BBF1C200DF9B88"
BuildableName = "Gemini.app"
BlueprintName = "Gemini"
ReferencedContainer = "container:Gemini.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -7,51 +7,31 @@
<key>Gemini.xcscheme_^#shared#^_</key> <key>Gemini.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>0</integer>
</dict> </dict>
<key>GeminiFormat.xcscheme_^#shared#^_</key> <key>GeminiFormat.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>1</integer>
</dict> </dict>
<key>GeminiProtocol.xcscheme_^#shared#^_</key> <key>GeminiProtocol.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>3</integer> <integer>2</integer>
</dict> </dict>
<key>GeminiRenderer.xcscheme_^#shared#^_</key> <key>GeminiRenderer.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>2</integer> <integer>3</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key> <key>SuppressBuildableAutocreation</key>
<dict> <dict>
<key>D626645A24BBF1C200DF9B88</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>D626647E24BBF22E00DF9B88</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>D62664A724BBF26A00DF9B88</key> <key>D62664A724BBF26A00DF9B88</key>
<dict> <dict>
<key>primary</key> <key>primary</key>
<true/> <true/>
</dict> </dict>
<key>D62664AF24BBF26A00DF9B88</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>D62664D524BC081B00DF9B88</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict> </dict>
</dict> </dict>
</plist> </plist>

View File

@ -14,12 +14,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow! var window: NSWindow!
let homePage = URL(string: "gemini://gemini.circumlunar.space/")! var connection: GeminiConnection!
private(set) lazy var navigationManager = NavigationManager(url: homePage) let url = URL(string: "gemini://localhost:1965/overview.gmi")!
func applicationDidFinishLaunching(_ aNotification: Notification) { func applicationDidFinishLaunching(_ aNotification: Notification) {
// Create the SwiftUI view that provides the window contents. // Create the SwiftUI view that provides the window contents.
let contentView = ContentView(navigator: navigationManager) let contentView = ContentView()
// Create the window and set the content view. // Create the window and set the content view.
window = NSWindow( window = NSWindow(
@ -32,10 +32,38 @@ class AppDelegate: NSObject, NSApplicationDelegate {
window.contentView = NSHostingView(rootView: contentView) window.contentView = NSHostingView(rootView: contentView)
window.title = "Gemini" window.title = "Gemini"
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
connection = GeminiConnection(endpoint: .url(url), delegate: self)
} }
func applicationWillTerminate(_ aNotification: Notification) { func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application // Insert code here to tear down your application
} }
var alreadyReceived = false
} }
extension AppDelegate: GeminiConnectionDelegate {
func connectionReady(_ connection: GeminiConnection) {
print("!! Ready")
let req = try! GeminiRequest(url: url)
connection.sendRequest(req)
}
func connection(_ connection: GeminiConnection, receivedData data: Data?, header: GeminiResponseHeader) {
if !alreadyReceived {
alreadyReceived = true
print("!! Status: \(header.status)")
print("!! Meta: '\(header.meta)'")
}
if let data = data {
print(String(data: data, encoding: .utf8)!)
}
}
func connectionCompleted(_ connection: GeminiConnection) {
print("!! completed")
}
}

View File

@ -6,89 +6,15 @@
// //
import SwiftUI import SwiftUI
import GeminiFormat
import GeminiRenderer
import GeminiProtocol
struct ContentView: View { struct ContentView: View {
let navigator: NavigationManager
@State var task: GeminiDataTask?
@State var document: Document?
@State var errorMessage: String?
var body: some View { var body: some View {
VStack { Text("Hello, world!").padding()
HStack {
Button(action: navigator.back) {
Image(systemName: "chevron.left")
}.disabled(navigator.backStack.isEmpty)
Button(action: navigator.forward) {
Image(systemName: "chevron.right")
}.disabled(navigator.forwardStack.isEmpty)
TextField("URL", text: Binding<String>(get: {
self.navigator.currentURL.absoluteString
}, set: { (newValue) in
self.navigator.currentURL = URL(string: newValue)!
}))
}.padding([.leading, .trailing])
mainView.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minWidth: 480, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity)
.onReceive(navigator.$currentURL, perform: self.urlChanged)
}
@ViewBuilder
private var mainView: some View {
if let document = document {
DocumentView(document: document, changeURL: navigator.changeURL)
} else if let errorMessage = errorMessage {
VStack {
Text("An error occurred")
.font(.headline)
Text(errorMessage)
}
} else {
loadingView
.onAppear(perform: self.loadDocument)
}
}
@ViewBuilder
private var loadingView: some View {
if #available(macOS 10.16, iOS 14.0, *) {
ProgressView("Loading...")
} else {
Text("Loading...")
}
}
private func loadDocument() {
let url = navigator.currentURL
task = try! GeminiDataTask(url: url, completion: { (response) in
switch response {
case let .failure(error):
self.errorMessage = error.localizedDescription
case let .success(response):
guard let text = response.bodyText else {
self.errorMessage = "Response had no body text"
return
}
self.document = GeminiParser.parse(text: text, baseURL: url)
}
self.task = nil
})
task!.resume()
}
private func urlChanged(_ newValue: URL) {
self.task = nil
self.document = nil
self.errorMessage = nil
} }
} }
struct ContentView_Previews: PreviewProvider { struct ContentView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ContentView(navigator: NavigationManager(url: URL(string: "gemini://localhost/overview.gmi")!)) ContentView()
} }
} }

View File

@ -1,38 +0,0 @@
//
// NavigationManager.swift
// Gemini
//
// Created by Shadowfacts on 7/14/20.
//
import Foundation
class NavigationManager: ObservableObject {
@Published var currentURL: URL
@Published var backStack = [URL]()
@Published var forwardStack = [URL]()
init(url: URL) {
self.currentURL = url
}
func changeURL(_ url: URL) {
backStack.append(currentURL)
currentURL = url
forwardStack = []
}
func back() {
guard !backStack.isEmpty else { return }
forwardStack.insert(currentURL, at: 0)
currentURL = backStack.removeLast()
}
func forward() {
guard !forwardStack.isEmpty else { return }
backStack.append(currentURL)
currentURL = forwardStack.removeFirst()
}
}

View File

@ -11,32 +11,33 @@ import Network
public protocol GeminiConnectionDelegate: class { public protocol GeminiConnectionDelegate: class {
func connectionReady(_ connection: GeminiConnection) func connectionReady(_ connection: GeminiConnection)
func connection(_ connection: GeminiConnection, receivedData data: Data?, header: GeminiResponseHeader) func connection(_ connection: GeminiConnection, receivedData data: Data?, header: GeminiResponseHeader)
func connection(_ connection: GeminiConnection, handleError error: GeminiConnection.Error)
func connectionCompleted(_ connection: GeminiConnection) func connectionCompleted(_ connection: GeminiConnection)
} }
public class GeminiConnection { public class GeminiConnection {
public typealias Error = NWError
public weak var delegate: GeminiConnectionDelegate? public weak var delegate: GeminiConnectionDelegate?
private let connection: NWConnection private var connection: NWConnection?
public init(endpoint: NWEndpoint, delegate: GeminiConnectionDelegate? = nil) { public init(endpoint: NWEndpoint, delegate: GeminiConnectionDelegate? = nil) {
self.connection = NWConnection(to: endpoint, using: .gemini) self.connection = NWConnection(to: endpoint, using: .gemini)
self.delegate = delegate self.delegate = delegate
startConnection()
} }
public func startConnection() { private func startConnection() {
guard let connection = connection else { return }
connection.stateUpdateHandler = { (newState) in connection.stateUpdateHandler = { (newState) in
switch newState { switch newState {
case .ready: case .ready:
print("\(self.connection) established") print("\(connection) established")
self.delegate?.connectionReady(self) self.delegate?.connectionReady(self)
case let .failed(error): case let .failed(error):
print("\(self.connection) failed: \(error)") print("\(connection) failed: \(error)")
self.connection.cancel() connection.cancel()
default: default:
break break
} }
@ -46,34 +47,56 @@ public class GeminiConnection {
connection.start(queue: .main) connection.start(queue: .main)
} }
public func cancelConnection() { var report: NWConnection.PendingDataTransferReport!
if connection.state != .cancelled {
connection.cancel()
}
}
public func sendRequest(_ request: GeminiRequest) { public func sendRequest(_ request: GeminiRequest) {
guard let connection = connection else { return }
let message = NWProtocolFramer.Message(geminiRequest: request) let message = NWProtocolFramer.Message(geminiRequest: request)
let context = NWConnection.ContentContext(identifier: "GeminiRequest", metadata: [message]) let context = NWConnection.ContentContext(identifier: "GeminiRequest", metadata: [message])
// todo: should this really always be idempotent? // todo: should this really always be idempotent?
connection.send(content: nil, contentContext: context, isComplete: true, completion: .contentProcessed({ (_) in })) connection.send(content: nil, contentContext: context, isComplete: true, completion: .contentProcessed({ (_) in }))
receiveNextMessage() receiveNextMessage()
report = connection.startDataTransferReport()
} }
private func receiveNextMessage() { private func receiveNextMessage() {
connection.receiveMessage { (data, context, isComplete, error) in guard let connection = connection else { return }
if let error = error {
self.delegate?.connection(self, handleError: error) // todo: should this really be .max
} else if let message = context?.protocolMetadata(definition: GeminiProtocol.definition) as? NWProtocolFramer.Message, connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { (data, context, isComplete, error) in
// todo: handle error
if let message = context?.protocolMetadata(definition: GeminiProtocol.definition) as? NWProtocolFramer.Message,
let header = message.geminiResponseHeader, let header = message.geminiResponseHeader,
let delegate = self.delegate { let delegate = self.delegate {
delegate.connection(self, receivedData: data, header: header) delegate.connection(self, receivedData: data, header: header)
self.receiveNextMessage()
} }
if isComplete {
guard isComplete else { fatalError("Connection should complete immediately") }
self.delegate?.connectionCompleted(self) self.delegate?.connectionCompleted(self)
self.connection.cancel() self.report.collect(queue: .main) { (report) in
print(report.debugDescription)
} }
// connection.cancel()
}
}
// connection.receiveMessage { (data, context, isComplete, error) in
// if let message = context?.protocolMetadata(definition: GeminiProtocol.definition) as? NWProtocolFramer.Message,
// let header = message.geminiResponseHeader,
// let delegate = self.delegate {
//// let response = GeminiResponse(status: header.status, meta: header.meta, body: data)
// delegate.connection(self, receivedData: data, header: header)
// }
// // todo: error handling
// if let error = error {
// print(error)
// } else if isComplete {
// self.delegate?.connectionCompleted(self)
// connection.cancel()
// } else {
// self.receiveNextMessage()
// }
// }
} }
} }

View File

@ -1,82 +0,0 @@
//
// GeminiDataTask.swift
// GeminiProtocol
//
// Created by Shadowfacts on 7/14/20.
//
import Foundation
import Network
public class GeminiDataTask {
public let request: GeminiRequest
private let completion: (Result<GeminiResponse, Error>) -> Void
private let connection: GeminiConnection
public private(set) var completed: Bool = false
public init(request: GeminiRequest, completion: @escaping (Result<GeminiResponse, Error>) -> Void) {
self.request = request
self.completion = completion
self.connection = GeminiConnection(endpoint: .url(request.url))
self.connection.delegate = self
}
public convenience init(url: URL, completion: @escaping (Result<GeminiResponse, Error>) -> Void) throws {
self.init(request: try GeminiRequest(url: url), completion: completion)
}
deinit {
self.cancel()
}
public func resume() {
self.connection.startConnection()
}
public func cancel() {
connection.cancelConnection()
}
}
public extension GeminiDataTask {
enum Error: Swift.Error {
case noData
case connectionError(NWError)
}
}
extension GeminiDataTask: GeminiConnectionDelegate {
public func connectionReady(_ connection: GeminiConnection) {
connection.sendRequest(self.request)
}
public func connection(_ connection: GeminiConnection, receivedData data: Data?, header: GeminiResponseHeader) {
guard !completed else {
print("GeminiRequestTask(\(self)) has already completed, shouldn't be receiving any data")
return
}
completed = true
if let data = data {
let response = GeminiResponse(header: header, body: data)
self.completion(.success(response))
} else {
self.completion(.failure(.noData))
}
}
public func connection(_ connection: GeminiConnection, handleError error: GeminiConnection.Error) {
guard !completed else {
print("GeminiRequestTask(\(self)) has already completed, shouldn't be receiving any data")
return
}
completed = true
self.completion(.failure(.connectionError(error)))
}
public func connectionCompleted(_ connection: GeminiConnection) {
}
}

View File

@ -31,11 +31,11 @@ class GeminiProtocol: NWProtocolFramerImplementation {
func handleInput(framer: NWProtocolFramer.Instance) -> Int { func handleInput(framer: NWProtocolFramer.Instance) -> Int {
while true { while true {
var tempStatusCode: GeminiResponseHeader.StatusCode? var tempStatusCode: GeminiResponse.StatusCode?
let parsedStatusCodeAndSpace = framer.parseInput(minimumIncompleteLength: 3, maximumLength: 3) { (buffer, isComplete) -> Int in let parsedStatusCodeAndSpace = framer.parseInput(minimumIncompleteLength: 3, maximumLength: 3) { (buffer, isComplete) -> Int in
guard let buffer = buffer, guard let buffer = buffer,
buffer.count == 3 else { return 0 } buffer.count == 3 else { return 0 }
tempStatusCode = GeminiResponseHeader.StatusCode(buffer) tempStatusCode = GeminiResponse.StatusCode(buffer)
return 3 return 3
} }
guard parsedStatusCodeAndSpace, let statusCode = tempStatusCode else { guard parsedStatusCodeAndSpace, let statusCode = tempStatusCode else {
@ -73,7 +73,8 @@ class GeminiProtocol: NWProtocolFramerImplementation {
let header = GeminiResponseHeader(status: statusCode, meta: meta) let header = GeminiResponseHeader(status: statusCode, meta: meta)
let message = NWProtocolFramer.Message(geminiResponseHeader: header) let message = NWProtocolFramer.Message(geminiResponseHeader: header)
if !framer.deliverInputNoCopy(length: .max, message: message, isComplete: true) { if !framer.deliverInputNoCopy(length: 2 + 1 + meta.utf8.count + 2, message: message, isComplete: true) {
// todo: why return zero here?
return 0 return 0
} }
} }
@ -86,7 +87,7 @@ class GeminiProtocol: NWProtocolFramerImplementation {
} }
fileprivate extension GeminiResponseHeader.StatusCode { fileprivate extension GeminiResponse.StatusCode {
init?(_ buffer: UnsafeMutableRawBufferPointer) { init?(_ buffer: UnsafeMutableRawBufferPointer) {
guard let str = String(bytes: buffer[...buffer.index(after: buffer.startIndex)], encoding: .utf8), guard let str = String(bytes: buffer[...buffer.index(after: buffer.startIndex)], encoding: .utf8),
let value = Int(str, radix: 10) else { return nil } let value = Int(str, radix: 10) else { return nil }

View File

@ -11,18 +11,10 @@ public struct GeminiRequest {
public let url: URL public let url: URL
public init(url: URL) throws { public init(url: URL) throws {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { throw Error.invalidURL } if url.absoluteString.count > 1024 {
guard components.scheme == "gemini" else { throw Error.wrongProtocol }
if components.port == nil {
components.port = 1965
}
self.url = components.url!
if self.url.absoluteString.count > 1024 {
throw Error.urlTooLong throw Error.urlTooLong
} }
self.url = url
} }
var data: Data { var data: Data {
@ -34,8 +26,6 @@ public struct GeminiRequest {
public extension GeminiRequest { public extension GeminiRequest {
enum Error: Swift.Error { enum Error: Swift.Error {
case invalidURL
case wrongProtocol
case urlTooLong case urlTooLong
} }
} }

View File

@ -1,30 +1,31 @@
// //
// GeminiResponse.swift // GeminiResponse.swift
// GeminiProtocol // Gemini
// //
// Created by Shadowfacts on 7/14/20. // Created by Shadowfacts on 7/12/20.
// //
import Foundation import Foundation
import UniformTypeIdentifiers import UniformTypeIdentifiers
public struct GeminiResponse { public struct GeminiResponse {
public let header: GeminiResponseHeader public let status: StatusCode
public let meta: String
public let body: Data? public let body: Data?
public var status: GeminiResponseHeader.StatusCode { header.status }
public var meta: String { header.meta }
public var rawMimeType: String? {
guard status.isSuccess else { return nil }
return meta.trimmingCharacters(in: .whitespaces)
} }
public var mimeType: String? { public extension GeminiResponse {
// Helpers
var rawMimeType: String? {
guard status.isSuccess else { return nil }
return meta.trimmingCharacters(in: .whitespacesAndNewlines)
}
var mimeType: String? {
guard let rawMimeType = rawMimeType else { return nil } guard let rawMimeType = rawMimeType else { return nil }
return rawMimeType.split(separator: ";").first?.trimmingCharacters(in: .whitespaces) return rawMimeType.split(separator: ";").first?.trimmingCharacters(in: .whitespaces)
} }
public var mimeTypeParameters: [String: String]? { var mimeTypeParameters: [String: String]? {
guard let rawMimeType = rawMimeType else { return nil } guard let rawMimeType = rawMimeType else { return nil }
return rawMimeType.split(separator: ";").dropFirst().reduce(into: [String: String]()) { (parameters, parameter) in return rawMimeType.split(separator: ";").dropFirst().reduce(into: [String: String]()) { (parameters, parameter) in
let parts = parameter.split(separator: "=").map { $0.trimmingCharacters(in: .whitespaces) } let parts = parameter.split(separator: "=").map { $0.trimmingCharacters(in: .whitespaces) }
@ -33,12 +34,12 @@ public struct GeminiResponse {
} }
} }
@available(macOS 10.16, *) @available(macOS 10.16, *)
public var utiType: UTType? { var utiType: UTType? {
guard let mimeType = mimeType else { return nil } guard let mimeType = mimeType else { return nil }
return UTType.types(tag: mimeType, tagClass: .mimeType, conformingTo: nil).first return UTType.types(tag: mimeType, tagClass: .mimeType, conformingTo: nil).first
} }
public var bodyText: String? { var bodyText: String? {
guard let body = body, let parameters = mimeTypeParameters else { return nil } guard let body = body, let parameters = mimeTypeParameters else { return nil }
let encoding: String.Encoding let encoding: String.Encoding
switch parameters["charset"]?.lowercased() { switch parameters["charset"]?.lowercased() {
@ -54,3 +55,81 @@ public struct GeminiResponse {
return String(data: body, encoding: encoding) return String(data: body, encoding: encoding)
} }
} }
public extension GeminiResponse {
enum StatusCode: Int {
// All statuses and subtypes
case input = 10
case sensitiveInput = 11
case success = 20
case temporaryRedirect = 30
case permanentRedirect = 31
case temporaryFailure = 40
case serverUnavailable = 41
case cgiError = 42
case proxyError = 43
case slowDown = 44
case permanentFailure = 50
case notFound = 51
case gone = 52
case proxyRequestRefused = 53
case badRequest = 59
case clientCertificateRequested = 60
case certificateNotAuthorised = 61
case certificateNotValid = 62
// Status type helpers
var isInput: Bool { rawValue / 10 == 1 }
var isSuccess: Bool { rawValue / 10 == 2 }
var isRedirect: Bool { rawValue / 10 == 3 }
var isTemporaryFailure: Bool { rawValue / 10 == 4 }
var isPermanentFailure: Bool { rawValue / 10 == 5 }
var isClientCertificateRequired: Bool { rawValue / 10 == 6 }
// Other helpers
var isFailure: Bool { isTemporaryFailure || isPermanentFailure }
}
}
extension GeminiResponse.StatusCode: CustomStringConvertible {
public var description: String {
switch self {
case .input:
return "input"
case .sensitiveInput:
return "sensitiveInput"
case .success:
return "success"
case .temporaryRedirect:
return "temporaryRedirect"
case .permanentRedirect:
return "permanentRedirect"
case .temporaryFailure:
return "temporaryFailure"
case .serverUnavailable:
return "serverUnavailable"
case .cgiError:
return "cgiError"
case .proxyError:
return "proxyError"
case .slowDown:
return "slowDown"
case .permanentFailure:
return "permanentFailure"
case .notFound:
return "notFound"
case .gone:
return "gone"
case .proxyRequestRefused:
return "proxyRequestRefused"
case .badRequest:
return "badRequest"
case .clientCertificateRequested:
return "clientCertificateRequested"
case .certificateNotAuthorised:
return "certificateNotAuthorised"
case .certificateNotValid:
return "certificateNotValid"
}
}
}

View File

@ -8,84 +8,6 @@
import Foundation import Foundation
public struct GeminiResponseHeader { public struct GeminiResponseHeader {
public let status: StatusCode public let status: GeminiResponse.StatusCode
public let meta: String public let meta: String
} }
public extension GeminiResponseHeader {
enum StatusCode: Int {
// All statuses and subtypes
case input = 10
case sensitiveInput = 11
case success = 20
case temporaryRedirect = 30
case permanentRedirect = 31
case temporaryFailure = 40
case serverUnavailable = 41
case cgiError = 42
case proxyError = 43
case slowDown = 44
case permanentFailure = 50
case notFound = 51
case gone = 52
case proxyRequestRefused = 53
case badRequest = 59
case clientCertificateRequested = 60
case certificateNotAuthorised = 61
case certificateNotValid = 62
// Status type helpers
var isInput: Bool { rawValue / 10 == 1 }
var isSuccess: Bool { rawValue / 10 == 2 }
var isRedirect: Bool { rawValue / 10 == 3 }
var isTemporaryFailure: Bool { rawValue / 10 == 4 }
var isPermanentFailure: Bool { rawValue / 10 == 5 }
var isClientCertificateRequired: Bool { rawValue / 10 == 6 }
// Other helpers
var isFailure: Bool { isTemporaryFailure || isPermanentFailure }
}
}
extension GeminiResponseHeader.StatusCode: CustomStringConvertible {
public var description: String {
switch self {
case .input:
return "\(rawValue): input"
case .sensitiveInput:
return "\(rawValue): sensitiveInput"
case .success:
return "\(rawValue): success"
case .temporaryRedirect:
return "\(rawValue): temporaryRedirect"
case .permanentRedirect:
return "\(rawValue): permanentRedirect"
case .temporaryFailure:
return "\(rawValue): temporaryFailure"
case .serverUnavailable:
return "\(rawValue): serverUnavailable"
case .cgiError:
return "\(rawValue): cgiError"
case .proxyError:
return "\(rawValue): proxyError"
case .slowDown:
return "\(rawValue): slowDown"
case .permanentFailure:
return "\(rawValue): permanentFailure"
case .notFound:
return "\(rawValue): notFound"
case .gone:
return "\(rawValue): gone"
case .proxyRequestRefused:
return "\(rawValue): proxyRequestRefused"
case .badRequest:
return "\(rawValue): badRequest"
case .clientCertificateRequested:
return "\(rawValue): clientCertificateRequested"
case .certificateNotAuthorised:
return "\(rawValue): certificateNotAuthorised"
case .certificateNotValid:
return "\(rawValue): certificateNotValid"
}
}
}

View File

@ -11,19 +11,17 @@ import GeminiFormat
public struct DocumentView: View { public struct DocumentView: View {
private let document: Document private let document: Document
private let blocks: [RenderingBlock] private let blocks: [RenderingBlock]
private let changeURL: ((URL) -> Void)?
public init(document: Document, changeURL: ((URL) -> Void)? = nil) { public init(document: Document) {
self.document = document self.document = document
self.blocks = document.renderingBlocks self.blocks = document.renderingBlocks
self.changeURL = changeURL
} }
public var body: some View { public var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
MaybeLazyVStack(alignment: .leading) { MaybeLazyVStack(alignment: .leading) {
ForEach(blocks.indices) { (index) in ForEach(blocks.indices) { (index) in
RenderingBlockView(block: blocks[index], changeURL: changeURL) RenderingBlockView(block: blocks[index])
} }
}.padding() }.padding()
} }

View File

@ -10,12 +10,6 @@ import GeminiFormat
struct RenderingBlockView: View { struct RenderingBlockView: View {
let block: RenderingBlock let block: RenderingBlock
let changeURL: ((URL) -> Void)?
init(block: RenderingBlock, changeURL: ((URL) -> Void)? = nil) {
self.block = block
self.changeURL = changeURL
}
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
@ -26,15 +20,11 @@ struct RenderingBlockView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
case let .link(url, text: linkText): case let .link(url, text: linkText):
let text = linkText ?? url.absoluteString let text = linkText ?? url.absoluteString
Button {
self.changeURL?(url)
} label: {
Text(verbatim: text) Text(verbatim: text)
.font(.documentBody) .font(.documentBody)
.foregroundColor(.blue) .foregroundColor(.blue)
.underline() .underline()
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
}.buttonStyle(LinkButtonStyle())
case let .preformatted(text, alt: _): case let .preformatted(text, alt: _):
ScrollView(.horizontal) { ScrollView(.horizontal) {
Text(verbatim: text) Text(verbatim: text)