swiftgemini

[ACTIVE] gemini protocol in swift
git clone git://git.figbert.com/swiftgemini.git
Log | Files | Refs | README

commit bc107305ea1fdc81f5b512297a909c162a0b911f
parent acca20f891987f3a95e595bd9afedcb42c774be1
Author: FIGBERT <figbert@figbert.com>
Date:   Thu, 21 Jul 2022 19:12:47 -0700

Add primitive but functional implementation

Diffstat:
MSources/SwiftGemini/MetaHeader.swift | 1-
MSources/SwiftGemini/StatusCode.swift | 1-
MSources/SwiftGemini/SwiftGemini.swift | 163+++++++++++++++++++++++++++++++++++++------------------------------------------
MTests/SwiftGeminiTests/SwiftGeminiTests.swift | 3++-
4 files changed, 79 insertions(+), 89 deletions(-)

diff --git a/Sources/SwiftGemini/MetaHeader.swift b/Sources/SwiftGemini/MetaHeader.swift @@ -1,7 +1,6 @@ import Foundation enum MetaHeader { - case GemenonDefault case Prompt(String) case MIME(String) case Redirect(URL) diff --git a/Sources/SwiftGemini/StatusCode.swift b/Sources/SwiftGemini/StatusCode.swift @@ -1,7 +1,6 @@ import Foundation enum StatusCode: Int { - case GemenonDefault = 0 case Input = 10 case SensitiveInput = 11 case Success = 20 diff --git a/Sources/SwiftGemini/SwiftGemini.swift b/Sources/SwiftGemini/SwiftGemini.swift @@ -1,96 +1,87 @@ import Foundation import Network -struct GeminiRequest { - public let url: URL - public let status: StatusCode - public let meta: MetaHeader - public let raw: Data - public let text: String - - init?(_ site: String) throws { - guard var components = URLComponents(url: URL(string: site)!, resolvingAgainstBaseURL: true) else { return nil } - guard components.scheme == "gemini" else { return nil } - guard components.user!.isEmpty && components.password!.isEmpty else { return nil } - if components.port == nil { components.port = 1965 } - let url = components.url! - if url.absoluteString.data(using: .utf8)!.count > 1024 { return nil } - - let queue = DispatchQueue(label: "gemenon") - let endpoint = NWEndpoint.Host.init(url.host!) - - let options = NWProtocolTLS.Options() - sec_protocol_options_set_verify_block(options.securityProtocolOptions, { (sec_protocol_metadata, sec_trust, sec_protocol_verify_complete) in +class GeminiRequest { + static let queue = DispatchQueue(label: "gemini", qos: .default) + fileprivate var conn: NWConnection? + fileprivate var response: Data? + + let url: URL + var status: StatusCode? + var meta: MetaHeader? + var body: Data? + var gemtext: String? + + init?(_ url: URL) throws { + guard url.scheme == "gemini", + url.user == nil && url.password == nil, + url.absoluteString.data(using: .utf8)!.count <= 1024 else { return nil } + self.url = url + request() + } + + fileprivate func request() { + let host = NWEndpoint.Host(self.url.host!) + let port = NWEndpoint.Port(integerLiteral: NWEndpoint.Port.IntegerLiteralType(self.url.port ?? 1965)) + let opts = NWProtocolTLS.Options() + sec_protocol_options_set_tls_min_version(opts.securityProtocolOptions, .tlsProtocol12) + sec_protocol_options_set_verify_block(opts.securityProtocolOptions, { (sec_protocol_metadata, sec_trust, sec_protocol_verify_complete) in + sec_protocol_metadata_copy_peer_public_key(sec_protocol_metadata) sec_protocol_verify_complete(true) - }, queue) - - let connection = NWConnection( - host: endpoint, - port: .init(rawValue: UInt16(url.port!))!, - using: NWParameters(tls: options) - ) - - var status = StatusCode.GemenonDefault - var meta = MetaHeader.GemenonDefault - var raw = Data() - var text = String() - - // Get status code - connection.receive(minimumIncompleteLength: 2, maximumLength: 2) { (data, contentContext, isComplete, error) in - guard data != nil else { return } - status = StatusCode(rawValue: Int(String(data: data!, encoding: .utf8)!)!)! - } - // Get meta header - connection.receive(minimumIncompleteLength: 0, maximumLength: 1024 + 3) { (data, contentContext, isComplete, error) in - guard data != nil else { return } - let header = String(data: data!, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines) - switch status { - case .Input, .SensitiveInput: - meta = .Prompt(header) - case .Success: - meta = .MIME(header) - case .TemporaryRedirect, .PermanentRedirect: - meta = .Redirect(URL(string: header)!) - case .TemporaryFailure, .ServerUnavailable, .CGIError, - .ProxyError, .PermanentFailure, .NotFound, - .Gone, .ProxyRequestRefused, .BadRequest: - meta = .FailureInfo(header) - case .SlowDown: - meta = .Seconds(Int(header)!) - case .ClientCertRequired, .ClientCertNotAuth, .ClientCertNotValid: - meta = .CertInfo(header) - case .GemenonDefault: - return - } - } - if status == .Success { - // Get content - connection.receive(minimumIncompleteLength: Int.min, maximumLength: Int.max) { (data, contentContext, isComplete, error) in - guard data != nil else { return } - raw = data! - text = String(data: data!, encoding: .utf8) ?? "" - - if isComplete { - connection.cancel() - } else if let error = error { - print("setupReceive: error \(error.localizedDescription)") - } - } - } - - connection.start(queue: queue) - connection.send(content: "\(url.absoluteString)\r\n".data(using: .utf8)!, completion: .contentProcessed( { error in - print("data sent") - if let error = error { - print("got error: \(error)") - return + }, GeminiRequest.queue) + + let request = "\(self.url.absoluteString)\r\n" + let data = request.data(using: .utf8) + + let conn = NWConnection(host: host, port: port, using: NWParameters(tls: opts)) + self.conn = conn + + conn.start(queue: GeminiRequest.queue) + conn.send(content: data, contentContext: .defaultMessage, isComplete: true, completion: .contentProcessed({ (err) in + conn.receiveMessage { (data, ctx, isComplete, err) in + conn.cancel() + self.parseResponse(data: data!) } })) - - self.url = url + } + + fileprivate func parseResponse(data: Data) { + guard let ix = data.firstIndex(of: 13), + data[ix+1] == 10, ix < (1024+3) else { return } + var header = String(data: data.prefix(upTo: ix), encoding: .utf8)! + let status = StatusCode(rawValue: Int(header.components(separatedBy: " ").first!)!)! + let meta: MetaHeader + + header = String(header.dropFirst(3)) + switch status { + case .Input, .SensitiveInput: meta = MetaHeader.Prompt(header) + case .Success: meta = MetaHeader.MIME(header) + case .TemporaryRedirect, .PermanentRedirect: meta = MetaHeader.Redirect(URL(string: header)!) + case .TemporaryFailure, .ServerUnavailable, .CGIError, + .ProxyError, .PermanentFailure, .NotFound, .Gone, + .ProxyRequestRefused, .BadRequest: meta = MetaHeader.FailureInfo(header) + case .SlowDown: meta = MetaHeader.Seconds(Int(header)!) + case .ClientCertNotAuth, .ClientCertNotValid, + .ClientCertRequired: meta = MetaHeader.CertInfo(header) + } + self.status = status self.meta = meta - self.raw = raw - self.text = text + self.response = data + + if self.status == StatusCode.Success { + self.parseBody() + } + } + + fileprivate func parseBody() { + let ix = self.response!.firstIndex(of: 13)! + 2 + let body = self.response!.suffix(from: ix) + + if case let .MIME(mime) = self.meta, mime.hasPrefix("text/gemini") { + self.gemtext = String(data: body, encoding: .utf8) + } else { + self.body = body + } } } diff --git a/Tests/SwiftGeminiTests/SwiftGeminiTests.swift b/Tests/SwiftGeminiTests/SwiftGeminiTests.swift @@ -6,7 +6,8 @@ final class SwiftGeminiTests: XCTestCase { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct // results. - XCTAssertEqual(SwiftGemini().text, "Hello, World!") + let _ = try! GeminiRequest(URL(string: "gemini://figbert.com")!) + sleep(1) } static var allTests = [