diff --git a/Gemini.xcodeproj/project.pbxproj b/Gemini.xcodeproj/project.pbxproj index 4836f8c..eecb01c 100644 --- a/Gemini.xcodeproj/project.pbxproj +++ b/Gemini.xcodeproj/project.pbxproj @@ -43,6 +43,8 @@ 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 */; }; + 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 */ /* Begin PBXContainerItemProxy section */ @@ -186,6 +188,8 @@ D664673524BD07F700B0B741 /* RenderingBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlock.swift; sourceTree = ""; }; D664673724BD086F00B0B741 /* RenderingBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlockView.swift; sourceTree = ""; }; D664673924BD0B8E00B0B741 /* Fonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fonts.swift; sourceTree = ""; }; + D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiDataTask.swift; sourceTree = ""; }; + D69F00AD24BEA29100E37622 /* GeminiResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiResponse.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -309,7 +313,9 @@ D626649924BBF24100DF9B88 /* GeminiProtocol.swift */, D626649A24BBF24100DF9B88 /* GeminiRequest.swift */, D626649524BBF24000DF9B88 /* GeminiResponseHeader.swift */, + D69F00AD24BEA29100E37622 /* GeminiResponse.swift */, D626649724BBF24100DF9B88 /* GeminiConnection.swift */, + D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */, ); path = GeminiProtocol; sourceTree = ""; @@ -671,10 +677,12 @@ files = ( D62664A024BBF24100DF9B88 /* GeminiProtocol.swift in Sources */, D626649E24BBF24100DF9B88 /* GeminiConnection.swift in Sources */, + D69F00AC24BE9DD300E37622 /* GeminiDataTask.swift in Sources */, D62664A124BBF24100DF9B88 /* GeminiRequest.swift in Sources */, D626649F24BBF24100DF9B88 /* NWParameters+Gemini.swift in Sources */, D626649D24BBF24100DF9B88 /* Message+Gemini.swift in Sources */, D626649C24BBF24100DF9B88 /* GeminiResponseHeader.swift in Sources */, + D69F00AE24BEA29100E37622 /* GeminiResponse.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Gemini/AppDelegate.swift b/Gemini/AppDelegate.swift index 8d35f0b..2617382 100644 --- a/Gemini/AppDelegate.swift +++ b/Gemini/AppDelegate.swift @@ -14,7 +14,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { var window: NSWindow! - var connection: GeminiConnection! + var task: GeminiDataTask! let url = URL(string: "gemini://localhost:1965/overview.gmi")! func applicationDidFinishLaunching(_ aNotification: Notification) { @@ -33,42 +33,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.title = "Gemini" 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) { // 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") - } -} - diff --git a/GeminiProtocol/GeminiConnection.swift b/GeminiProtocol/GeminiConnection.swift index f30f600..e6b3c4e 100644 --- a/GeminiProtocol/GeminiConnection.swift +++ b/GeminiProtocol/GeminiConnection.swift @@ -21,26 +21,22 @@ public class GeminiConnection { public weak var delegate: GeminiConnectionDelegate? - private var connection: NWConnection? + private let connection: NWConnection public init(endpoint: NWEndpoint, delegate: GeminiConnectionDelegate? = nil) { self.connection = NWConnection(to: endpoint, using: .gemini) self.delegate = delegate - - startConnection() } - private func startConnection() { - guard let connection = connection else { return } - + public func startConnection() { connection.stateUpdateHandler = { (newState) in switch newState { case .ready: - print("\(connection) established") + print("\(self.connection) established") self.delegate?.connectionReady(self) case let .failed(error): - print("\(connection) failed: \(error)") - connection.cancel() + print("\(self.connection) failed: \(error)") + self.connection.cancel() default: break } @@ -50,22 +46,21 @@ public class GeminiConnection { connection.start(queue: .main) } - var report: NWConnection.PendingDataTransferReport! + public func cancelConnection() { + if connection.state != .cancelled { + connection.cancel() + } + } public func sendRequest(_ request: GeminiRequest) { - guard let connection = connection else { return } - let message = NWProtocolFramer.Message(geminiRequest: request) let context = NWConnection.ContentContext(identifier: "GeminiRequest", metadata: [message]) // todo: should this really always be idempotent? connection.send(content: nil, contentContext: context, isComplete: true, completion: .contentProcessed({ (_) in })) receiveNextMessage() - report = connection.startDataTransferReport() } private func receiveNextMessage() { - guard let connection = connection else { return } - connection.receiveMessage { (data, context, isComplete, error) in if let error = error { self.delegate?.connection(self, handleError: error) @@ -75,12 +70,9 @@ public class GeminiConnection { delegate.connection(self, receivedData: data, header: header) } - if isComplete { - self.delegate?.connectionCompleted(self) - connection.cancel() - } else { - self.receiveNextMessage() - } + guard isComplete else { fatalError("Connection should complete immediately") } + self.delegate?.connectionCompleted(self) + self.connection.cancel() } } diff --git a/GeminiProtocol/GeminiDataTask.swift b/GeminiProtocol/GeminiDataTask.swift new file mode 100644 index 0000000..27f8ea4 --- /dev/null +++ b/GeminiProtocol/GeminiDataTask.swift @@ -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) -> Void + + private let connection: GeminiConnection + + public private(set) var completed: Bool = false + + public init(request: GeminiRequest, completion: @escaping (Result) -> 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) -> 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) { + } + +} diff --git a/GeminiProtocol/GeminiRequest.swift b/GeminiProtocol/GeminiRequest.swift index 1e74347..7386604 100644 --- a/GeminiProtocol/GeminiRequest.swift +++ b/GeminiProtocol/GeminiRequest.swift @@ -11,10 +11,18 @@ public struct GeminiRequest { public let url: URL 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 } - self.url = url } var data: Data { @@ -26,6 +34,8 @@ public struct GeminiRequest { public extension GeminiRequest { enum Error: Swift.Error { + case invalidURL + case wrongProtocol case urlTooLong } } diff --git a/GeminiProtocol/GeminiResponse.swift b/GeminiProtocol/GeminiResponse.swift new file mode 100644 index 0000000..1257f09 --- /dev/null +++ b/GeminiProtocol/GeminiResponse.swift @@ -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) + } +} diff --git a/GeminiProtocol/GeminiResponseHeader.swift b/GeminiProtocol/GeminiResponseHeader.swift index fdef7af..4d4ccdf 100644 --- a/GeminiProtocol/GeminiResponseHeader.swift +++ b/GeminiProtocol/GeminiResponseHeader.swift @@ -6,33 +6,10 @@ // import Foundation -import UniformTypeIdentifiers public struct GeminiResponseHeader { public let status: StatusCode 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 { @@ -74,41 +51,41 @@ extension GeminiResponseHeader.StatusCode: CustomStringConvertible { public var description: String { switch self { case .input: - return "input" + return "\(rawValue): input" case .sensitiveInput: - return "sensitiveInput" + return "\(rawValue): sensitiveInput" case .success: - return "success" + return "\(rawValue): success" case .temporaryRedirect: - return "temporaryRedirect" + return "\(rawValue): temporaryRedirect" case .permanentRedirect: - return "permanentRedirect" + return "\(rawValue): permanentRedirect" case .temporaryFailure: - return "temporaryFailure" + return "\(rawValue): temporaryFailure" case .serverUnavailable: - return "serverUnavailable" + return "\(rawValue): serverUnavailable" case .cgiError: - return "cgiError" + return "\(rawValue): cgiError" case .proxyError: - return "proxyError" + return "\(rawValue): proxyError" case .slowDown: - return "slowDown" + return "\(rawValue): slowDown" case .permanentFailure: - return "permanentFailure" + return "\(rawValue): permanentFailure" case .notFound: - return "notFound" + return "\(rawValue): notFound" case .gone: - return "gone" + return "\(rawValue): gone" case .proxyRequestRefused: - return "proxyRequestRefused" + return "\(rawValue): proxyRequestRefused" case .badRequest: - return "badRequest" + return "\(rawValue): badRequest" case .clientCertificateRequested: - return "clientCertificateRequested" + return "\(rawValue): clientCertificateRequested" case .certificateNotAuthorised: - return "certificateNotAuthorised" + return "\(rawValue): certificateNotAuthorised" case .certificateNotValid: - return "certificateNotValid" + return "\(rawValue): certificateNotValid" } } }