Add GeminiDataTask

This commit is contained in:
Shadowfacts 2020-07-14 22:39:38 -04:00
parent befccfc38a
commit 87917687bf
Signed by: shadowfacts
GPG Key ID: 94A5AB95422746E5
7 changed files with 201 additions and 97 deletions

View File

@ -43,6 +43,8 @@
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 */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -186,6 +188,8 @@
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>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -309,7 +313,9 @@
D626649924BBF24100DF9B88 /* GeminiProtocol.swift */, D626649924BBF24100DF9B88 /* GeminiProtocol.swift */,
D626649A24BBF24100DF9B88 /* GeminiRequest.swift */, D626649A24BBF24100DF9B88 /* GeminiRequest.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,10 +677,12 @@
files = ( files = (
D62664A024BBF24100DF9B88 /* GeminiProtocol.swift in Sources */, D62664A024BBF24100DF9B88 /* GeminiProtocol.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

@ -14,7 +14,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var window: NSWindow! var window: NSWindow!
var connection: GeminiConnection! var task: GeminiDataTask!
let url = URL(string: "gemini://localhost:1965/overview.gmi")! let url = URL(string: "gemini://localhost:1965/overview.gmi")!
func applicationDidFinishLaunching(_ aNotification: Notification) { func applicationDidFinishLaunching(_ aNotification: Notification) {
@ -33,42 +33,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
window.title = "Gemini" window.title = "Gemini"
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
connection = GeminiConnection(endpoint: .url(url), delegate: self) task = try! GeminiDataTask(url: url, completion: { (result) in
switch result {
case let .success(response):
print("Received response header: \(response.header)")
print("Received data: \(String(describing: response.body))")
print(response.bodyText.debugDescription)
case let .failure(error):
print("Error: \(error)")
}
})
task.resume()
} }
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("Received: \(data)")
print(String(data: data, encoding: .utf8)!.debugDescription)
}
}
func connection(_ connection: GeminiConnection, handleError error: GeminiConnection.Error) {
print("!! connection error: \(error)")
}
func connectionCompleted(_ connection: GeminiConnection) {
print("!! completed")
}
}

View File

@ -21,26 +21,22 @@ public class GeminiConnection {
public weak var delegate: GeminiConnectionDelegate? public weak var delegate: GeminiConnectionDelegate?
private var connection: NWConnection? private let 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()
} }
private func startConnection() { public 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("\(connection) established") print("\(self.connection) established")
self.delegate?.connectionReady(self) self.delegate?.connectionReady(self)
case let .failed(error): case let .failed(error):
print("\(connection) failed: \(error)") print("\(self.connection) failed: \(error)")
connection.cancel() self.connection.cancel()
default: default:
break break
} }
@ -50,22 +46,21 @@ public class GeminiConnection {
connection.start(queue: .main) connection.start(queue: .main)
} }
var report: NWConnection.PendingDataTransferReport! public func cancelConnection() {
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() {
guard let connection = connection else { return }
connection.receiveMessage { (data, context, isComplete, error) in connection.receiveMessage { (data, context, isComplete, error) in
if let error = error { if let error = error {
self.delegate?.connection(self, handleError: error) self.delegate?.connection(self, handleError: error)
@ -75,12 +70,9 @@ public class GeminiConnection {
delegate.connection(self, receivedData: data, header: header) delegate.connection(self, receivedData: data, header: header)
} }
if isComplete { guard isComplete else { fatalError("Connection should complete immediately") }
self.delegate?.connectionCompleted(self) self.delegate?.connectionCompleted(self)
connection.cancel() self.connection.cancel()
} else {
self.receiveNextMessage()
}
} }
} }

View File

@ -0,0 +1,82 @@
//
// 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

@ -11,10 +11,18 @@ public struct GeminiRequest {
public let url: URL public let url: URL
public init(url: URL) throws { public init(url: URL) throws {
if url.absoluteString.count > 1024 { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { throw Error.invalidURL }
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 {
@ -26,6 +34,8 @@ 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

@ -0,0 +1,56 @@
//
// GeminiResponse.swift
// GeminiProtocol
//
// Created by Shadowfacts on 7/14/20.
//
import Foundation
import UniformTypeIdentifiers
public struct GeminiResponse {
public let header: GeminiResponseHeader
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? {
guard let rawMimeType = rawMimeType else { return nil }
return rawMimeType.split(separator: ";").first?.trimmingCharacters(in: .whitespaces)
}
public var mimeTypeParameters: [String: String]? {
guard let rawMimeType = rawMimeType else { return nil }
return rawMimeType.split(separator: ";").dropFirst().reduce(into: [String: String]()) { (parameters, parameter) in
let parts = parameter.split(separator: "=").map { $0.trimmingCharacters(in: .whitespaces) }
precondition(parts.count == 2)
parameters[parts[0].lowercased()] = parts[1]
}
}
@available(macOS 10.16, *)
public var utiType: UTType? {
guard let mimeType = mimeType else { return nil }
return UTType.types(tag: mimeType, tagClass: .mimeType, conformingTo: nil).first
}
public var bodyText: String? {
guard let body = body, let parameters = mimeTypeParameters else { return nil }
let encoding: String.Encoding
switch parameters["charset"]?.lowercased() {
case nil, "utf-8":
// The Gemini spec defines UTF-8 to be the default charset.
encoding = .utf8
case "us-ascii":
encoding = .ascii
default:
// todo: log warning
encoding = .utf8
}
return String(data: body, encoding: encoding)
}
}

View File

@ -6,33 +6,10 @@
// //
import Foundation import Foundation
import UniformTypeIdentifiers
public struct GeminiResponseHeader { public struct GeminiResponseHeader {
public let status: StatusCode public let status: StatusCode
public let meta: String public let meta: String
public var rawMimeType: String? {
guard status.isSuccess else { return nil }
return meta.trimmingCharacters(in: .whitespaces)
}
var mimeType: String? {
guard let rawMimeType = rawMimeType else { return nil }
return rawMimeType.split(separator: ";").first?.trimmingCharacters(in: .whitespaces)
}
var mimeTypeParameters: [String: String]? {
guard let rawMimeType = rawMimeType else { return nil }
return rawMimeType.split(separator: ";").dropFirst().reduce(into: [String: String]()) { (parameters, parameter) in
let parts = parameter.split(separator: "=").map { $0.trimmingCharacters(in: .whitespaces) }
precondition(parts.count == 2)
parameters[parts[0].lowercased()] = parts[1]
}
}
@available(macOS 10.16, *)
var utiType: UTType? {
guard let mimeType = mimeType else { return nil }
return UTType.types(tag: mimeType, tagClass: .mimeType, conformingTo: nil).first
}
} }
public extension GeminiResponseHeader { public extension GeminiResponseHeader {
@ -74,41 +51,41 @@ extension GeminiResponseHeader.StatusCode: CustomStringConvertible {
public var description: String { public var description: String {
switch self { switch self {
case .input: case .input:
return "input" return "\(rawValue): input"
case .sensitiveInput: case .sensitiveInput:
return "sensitiveInput" return "\(rawValue): sensitiveInput"
case .success: case .success:
return "success" return "\(rawValue): success"
case .temporaryRedirect: case .temporaryRedirect:
return "temporaryRedirect" return "\(rawValue): temporaryRedirect"
case .permanentRedirect: case .permanentRedirect:
return "permanentRedirect" return "\(rawValue): permanentRedirect"
case .temporaryFailure: case .temporaryFailure:
return "temporaryFailure" return "\(rawValue): temporaryFailure"
case .serverUnavailable: case .serverUnavailable:
return "serverUnavailable" return "\(rawValue): serverUnavailable"
case .cgiError: case .cgiError:
return "cgiError" return "\(rawValue): cgiError"
case .proxyError: case .proxyError:
return "proxyError" return "\(rawValue): proxyError"
case .slowDown: case .slowDown:
return "slowDown" return "\(rawValue): slowDown"
case .permanentFailure: case .permanentFailure:
return "permanentFailure" return "\(rawValue): permanentFailure"
case .notFound: case .notFound:
return "notFound" return "\(rawValue): notFound"
case .gone: case .gone:
return "gone" return "\(rawValue): gone"
case .proxyRequestRefused: case .proxyRequestRefused:
return "proxyRequestRefused" return "\(rawValue): proxyRequestRefused"
case .badRequest: case .badRequest:
return "badRequest" return "\(rawValue): badRequest"
case .clientCertificateRequested: case .clientCertificateRequested:
return "clientCertificateRequested" return "\(rawValue): clientCertificateRequested"
case .certificateNotAuthorised: case .certificateNotAuthorised:
return "certificateNotAuthorised" return "\(rawValue): certificateNotAuthorised"
case .certificateNotValid: case .certificateNotValid:
return "certificateNotValid" return "\(rawValue): certificateNotValid"
} }
} }
} }