516 lines
22 KiB
Swift
516 lines
22 KiB
Swift
//
|
|
// Network.swift
|
|
// Wraps `playdate->network` (pd_api_network.h): HTTP and TCP connections.
|
|
//
|
|
// The binding stores a back-reference to each connection wrapper in the
|
|
// underlying object's userdata slot so callbacks can recover the wrapper;
|
|
// the C userdata slot is therefore reserved by the binding.
|
|
//
|
|
|
|
internal import CPlaydate
|
|
|
|
private var networkAPI: UnsafePointer<playdate_network> { Playdate.networkAPI.unsafelyUnwrapped }
|
|
private var httpAPI: UnsafePointer<playdate_http> { Playdate.httpAPI.unsafelyUnwrapped }
|
|
private var tcpAPI: UnsafePointer<playdate_tcp> { Playdate.tcpAPI.unsafelyUnwrapped }
|
|
|
|
/// The network API: wifi status, HTTP, and TCP.
|
|
public enum Network {}
|
|
|
|
extension Network {
|
|
/// A network error code (`PDNetErr`).
|
|
public enum NetError: Int32, Swift.Error, Sendable {
|
|
case noDevice = -1
|
|
case busy = -2
|
|
case writeError = -3
|
|
case writeBusy = -4
|
|
case writeTimeout = -5
|
|
case readError = -6
|
|
case readBusy = -7
|
|
case readTimeout = -8
|
|
case readOverflow = -9
|
|
case frameError = -10
|
|
case badResponse = -11
|
|
case errorResponse = -12
|
|
case resetTimeout = -13
|
|
case bufferTooSmall = -14
|
|
case unexpectedResponse = -15
|
|
case notConnectedToAP = -16
|
|
case notImplemented = -17
|
|
case connectionClosed = -18
|
|
case unknown = 1
|
|
|
|
init(_ error: PDNetErr) {
|
|
self = NetError(rawValue: Int32(error.rawValue)) ?? .unknown
|
|
}
|
|
}
|
|
|
|
/// Throws unless `error` is `NET_OK`.
|
|
static func check(_ error: PDNetErr) throws(NetError) {
|
|
if error != NET_OK {
|
|
throw NetError(error)
|
|
}
|
|
}
|
|
|
|
/// Converts an error code to `nil` (OK) or a `NetError`.
|
|
static func optionalError(_ error: PDNetErr) -> NetError? {
|
|
error == NET_OK ? nil : NetError(error)
|
|
}
|
|
|
|
/// The device's wifi status.
|
|
public enum WifiStatus: UInt32, Sendable {
|
|
case notConnected = 0
|
|
case connected = 1
|
|
/// A connection was attempted but no configured access point was
|
|
/// available.
|
|
case notAvailable = 2
|
|
}
|
|
|
|
public static var status: WifiStatus {
|
|
WifiStatus(rawValue: UInt32(networkAPI.pointee.getStatus.unsafelyUnwrapped().rawValue)) ?? .notConnected
|
|
}
|
|
|
|
/// Turns the wifi radio on or off. The completion receives `nil` on
|
|
/// success. Completions of overlapping calls are delivered in call order.
|
|
public static func setEnabled(_ enabled: Bool, completion: ((NetError?) -> Void)? = nil) {
|
|
if let completion {
|
|
setEnabledCompletions.append(completion)
|
|
networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, { error in
|
|
guard !Network.setEnabledCompletions.isEmpty else { return }
|
|
let completion = Network.setEnabledCompletions.removeFirst()
|
|
completion(Network.optionalError(error))
|
|
})
|
|
} else {
|
|
networkAPI.pointee.setEnabled.unsafelyUnwrapped(enabled, nil)
|
|
}
|
|
}
|
|
|
|
nonisolated(unsafe) private static var setEnabledCompletions: [(NetError?) -> Void] = []
|
|
|
|
/// Requests permission to connect to `server`. Shared by HTTP and TCP.
|
|
fileprivate static func requestAccess(
|
|
rawRequest: (UnsafePointer<CChar>?, Int32, Bool, UnsafePointer<CChar>?,
|
|
(@convention(c) (Bool, UnsafeMutableRawPointer?) -> Void)?,
|
|
UnsafeMutableRawPointer?) -> accessReply,
|
|
server: String, port: Int, useSSL: Bool, purpose: String?,
|
|
completion: @escaping (Bool) -> Void) -> AccessReply {
|
|
final class Box {
|
|
let body: (Bool) -> Void
|
|
init(_ body: @escaping (Bool) -> Void) { self.body = body }
|
|
}
|
|
let box = Unmanaged.passRetained(Box(completion))
|
|
let trampoline: @convention(c) (Bool, UnsafeMutableRawPointer?) -> Void = { allowed, userdata in
|
|
guard let userdata else { return }
|
|
Unmanaged<Box>.fromOpaque(userdata).takeRetainedValue().body(allowed)
|
|
}
|
|
let reply = server.withPlaydateCString { cServer in
|
|
if let purpose {
|
|
return purpose.withPlaydateCString { cPurpose in
|
|
rawRequest(cServer, Int32(port), useSSL, cPurpose, trampoline, box.toOpaque())
|
|
}
|
|
} else {
|
|
return rawRequest(cServer, Int32(port), useSSL, nil, trampoline, box.toOpaque())
|
|
}
|
|
}
|
|
if reply != kAccessAsk {
|
|
// The callback will not be invoked; balance the retain.
|
|
box.release()
|
|
}
|
|
return AccessReply(rawValue: UInt32(reply.rawValue)) ?? .ask
|
|
}
|
|
|
|
// MARK: - HTTP
|
|
|
|
/// An HTTP connection to a server. Wraps `HTTPConnection`.
|
|
public final class HTTPConnection {
|
|
let pointer: OpaquePointer
|
|
|
|
var headerReceivedCallback: ((HTTPConnection, _ key: String, _ value: String) -> Void)?
|
|
var headersReadCallback: ((HTTPConnection) -> Void)?
|
|
var responseCallback: ((HTTPConnection) -> Void)?
|
|
var requestCompleteCallback: ((HTTPConnection) -> Void)?
|
|
var connectionClosedCallback: ((HTTPConnection) -> Void)?
|
|
|
|
/// Requests permission to connect to `server`. If the reply is
|
|
/// `.ask`, the completion is called later with the user's answer.
|
|
@discardableResult
|
|
public static func requestAccess(server: String, port: Int = 443, useSSL: Bool = true,
|
|
purpose: String? = nil,
|
|
completion: @escaping (Bool) -> Void) -> AccessReply {
|
|
Network.requestAccess(
|
|
rawRequest: { httpAPI.pointee.requestAccess.unsafelyUnwrapped($0, $1, $2, $3, $4, $5) },
|
|
server: server, port: port, useSSL: useSSL, purpose: purpose,
|
|
completion: completion)
|
|
}
|
|
|
|
/// Opens a connection to `server`. Fails if access has not been
|
|
/// granted.
|
|
public init?(server: String, port: Int = 443, useSSL: Bool = true) {
|
|
let pointer = server.withPlaydateCString {
|
|
httpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL)
|
|
}
|
|
guard let pointer else { return nil }
|
|
self.pointer = pointer
|
|
httpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque())
|
|
}
|
|
|
|
deinit {
|
|
httpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, nil)
|
|
httpAPI.pointee.release.unsafelyUnwrapped(pointer)
|
|
}
|
|
|
|
private static func wrapper(for pointer: OpaquePointer?) -> HTTPConnection? {
|
|
guard let pointer,
|
|
let userdata = httpAPI.pointee.getUserdata.unsafelyUnwrapped(pointer) else { return nil }
|
|
return Unmanaged<HTTPConnection>.fromOpaque(userdata).takeUnretainedValue()
|
|
}
|
|
|
|
// MARK: Configuration
|
|
|
|
/// The time to wait for the connection to open, in milliseconds.
|
|
public func setConnectTimeout(milliseconds: Int) {
|
|
httpAPI.pointee.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
|
|
}
|
|
|
|
/// Whether to keep the connection open after a request completes.
|
|
public func setKeepAlive(_ keepAlive: Bool) {
|
|
httpAPI.pointee.setKeepAlive.unsafelyUnwrapped(pointer, keepAlive)
|
|
}
|
|
|
|
/// Adds a `Range: bytes=start-end` header to future requests.
|
|
public func setByteRange(start: Int, end: Int) {
|
|
httpAPI.pointee.setByteRange.unsafelyUnwrapped(pointer, Int32(start), Int32(end))
|
|
}
|
|
|
|
/// The time to wait for incoming data, in milliseconds.
|
|
public func setReadTimeout(milliseconds: Int) {
|
|
httpAPI.pointee.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
|
|
}
|
|
|
|
/// The size of the connection's read buffer, in bytes.
|
|
public func setReadBufferSize(bytes: Int) {
|
|
httpAPI.pointee.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes))
|
|
}
|
|
|
|
// MARK: Requests
|
|
|
|
/// Sends a GET request for `path`. `headers` are raw header lines
|
|
/// (e.g. "Accept: text/html\r\n").
|
|
public func get(path: String, headers: String = "") throws(NetError) {
|
|
let error = path.withPlaydateCString { cPath in
|
|
headers.withPlaydateCString { cHeaders in
|
|
httpAPI.pointee.get.unsafelyUnwrapped(pointer, cPath, cHeaders, headers.utf8.count)
|
|
}
|
|
}
|
|
try Network.check(error)
|
|
}
|
|
|
|
/// Sends a POST request for `path` with the given body.
|
|
public func post(path: String, headers: String = "", body: [UInt8]) throws(NetError) {
|
|
let error = path.withPlaydateCString { cPath in
|
|
headers.withPlaydateCString { cHeaders in
|
|
body.withUnsafeBytes { bodyBuffer in
|
|
httpAPI.pointee.post.unsafelyUnwrapped(
|
|
pointer, cPath, cHeaders, headers.utf8.count,
|
|
bodyBuffer.baseAddress?.assumingMemoryBound(to: CChar.self),
|
|
bodyBuffer.count)
|
|
}
|
|
}
|
|
}
|
|
try Network.check(error)
|
|
}
|
|
|
|
/// Sends a request with an arbitrary HTTP method.
|
|
public func query(method: String, path: String, headers: String = "",
|
|
body: [UInt8] = []) throws(NetError) {
|
|
let error = method.withPlaydateCString { cMethod in
|
|
path.withPlaydateCString { cPath in
|
|
headers.withPlaydateCString { cHeaders in
|
|
body.withUnsafeBytes { bodyBuffer in
|
|
httpAPI.pointee.query.unsafelyUnwrapped(
|
|
pointer, cMethod, cPath, cHeaders, headers.utf8.count,
|
|
bodyBuffer.baseAddress?.assumingMemoryBound(to: CChar.self),
|
|
bodyBuffer.count)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
try Network.check(error)
|
|
}
|
|
|
|
// MARK: Response
|
|
|
|
/// The last error on the connection, if any.
|
|
public var error: NetError? {
|
|
Network.optionalError(httpAPI.pointee.getError.unsafelyUnwrapped(pointer))
|
|
}
|
|
|
|
/// The number of bytes read of the current response, and the total
|
|
/// expected (0 if the response has no Content-Length).
|
|
public var progress: (read: Int, total: Int) {
|
|
var read: Int32 = 0, total: Int32 = 0
|
|
httpAPI.pointee.getProgress.unsafelyUnwrapped(pointer, &read, &total)
|
|
return (Int(read), Int(total))
|
|
}
|
|
|
|
/// The HTTP status code of the response.
|
|
public var responseStatus: Int {
|
|
Int(httpAPI.pointee.getResponseStatus.unsafelyUnwrapped(pointer))
|
|
}
|
|
|
|
/// The number of response bytes available to read.
|
|
public var bytesAvailable: Int {
|
|
Int(httpAPI.pointee.getBytesAvailable.unsafelyUnwrapped(pointer))
|
|
}
|
|
|
|
/// Reads up to `buffer.count` response bytes. Returns the number of
|
|
/// bytes read.
|
|
public func read(into buffer: UnsafeMutableRawBufferPointer) throws(NetError) -> Int {
|
|
let result = httpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress,
|
|
UInt32(buffer.count))
|
|
if result < 0 {
|
|
throw NetError(rawValue: result) ?? .unknown
|
|
}
|
|
return Int(result)
|
|
}
|
|
|
|
/// Reads up to `length` available response bytes.
|
|
public func read(length: Int) throws(NetError) -> [UInt8] {
|
|
var bytes = [UInt8](repeating: 0, count: length)
|
|
let result = bytes.withUnsafeMutableBytes { buffer in
|
|
httpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, UInt32(buffer.count))
|
|
}
|
|
if result < 0 {
|
|
throw NetError(rawValue: result) ?? .unknown
|
|
}
|
|
bytes.removeLast(length - Int(result))
|
|
return bytes
|
|
}
|
|
|
|
/// Closes the connection.
|
|
public func close() {
|
|
httpAPI.pointee.close.unsafelyUnwrapped(pointer)
|
|
}
|
|
|
|
// MARK: Callbacks
|
|
|
|
/// Called for each header line as it arrives.
|
|
public func setHeaderReceivedCallback(_ callback: ((HTTPConnection, _ key: String, _ value: String) -> Void)?) {
|
|
headerReceivedCallback = callback
|
|
if callback != nil {
|
|
httpAPI.pointee.setHeaderReceivedCallback.unsafelyUnwrapped(pointer, { connection, key, value in
|
|
guard let wrapper = HTTPConnection.wrapper(for: connection),
|
|
let key = String(playdateCString: key),
|
|
let value = String(playdateCString: value) else { return }
|
|
wrapper.headerReceivedCallback?(wrapper, key, value)
|
|
})
|
|
} else {
|
|
httpAPI.pointee.setHeaderReceivedCallback.unsafelyUnwrapped(pointer, nil)
|
|
}
|
|
}
|
|
|
|
/// Called when all headers have been read.
|
|
public func setHeadersReadCallback(_ callback: ((HTTPConnection) -> Void)?) {
|
|
headersReadCallback = callback
|
|
if callback != nil {
|
|
httpAPI.pointee.setHeadersReadCallback.unsafelyUnwrapped(pointer, { connection in
|
|
guard let wrapper = HTTPConnection.wrapper(for: connection) else { return }
|
|
wrapper.headersReadCallback?(wrapper)
|
|
})
|
|
} else {
|
|
httpAPI.pointee.setHeadersReadCallback.unsafelyUnwrapped(pointer, nil)
|
|
}
|
|
}
|
|
|
|
/// Called when response data is available to read.
|
|
public func setResponseCallback(_ callback: ((HTTPConnection) -> Void)?) {
|
|
responseCallback = callback
|
|
if callback != nil {
|
|
httpAPI.pointee.setResponseCallback.unsafelyUnwrapped(pointer, { connection in
|
|
guard let wrapper = HTTPConnection.wrapper(for: connection) else { return }
|
|
wrapper.responseCallback?(wrapper)
|
|
})
|
|
} else {
|
|
httpAPI.pointee.setResponseCallback.unsafelyUnwrapped(pointer, nil)
|
|
}
|
|
}
|
|
|
|
/// Called when the request finishes.
|
|
public func setRequestCompleteCallback(_ callback: ((HTTPConnection) -> Void)?) {
|
|
requestCompleteCallback = callback
|
|
if callback != nil {
|
|
httpAPI.pointee.setRequestCompleteCallback.unsafelyUnwrapped(pointer, { connection in
|
|
guard let wrapper = HTTPConnection.wrapper(for: connection) else { return }
|
|
wrapper.requestCompleteCallback?(wrapper)
|
|
})
|
|
} else {
|
|
httpAPI.pointee.setRequestCompleteCallback.unsafelyUnwrapped(pointer, nil)
|
|
}
|
|
}
|
|
|
|
/// Called when the connection closes.
|
|
public func setConnectionClosedCallback(_ callback: ((HTTPConnection) -> Void)?) {
|
|
connectionClosedCallback = callback
|
|
if callback != nil {
|
|
httpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, { connection in
|
|
guard let wrapper = HTTPConnection.wrapper(for: connection) else { return }
|
|
wrapper.connectionClosedCallback?(wrapper)
|
|
})
|
|
} else {
|
|
httpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - TCP
|
|
|
|
/// A TCP connection to a server. Wraps `TCPConnection`.
|
|
public final class TCPConnection {
|
|
let pointer: OpaquePointer
|
|
|
|
var openCompletion: ((TCPConnection, NetError?) -> Void)?
|
|
var connectionClosedCallback: ((TCPConnection, NetError?) -> Void)?
|
|
|
|
/// Requests permission to connect to `server`. If the reply is
|
|
/// `.ask`, the completion is called later with the user's answer.
|
|
@discardableResult
|
|
public static func requestAccess(server: String, port: Int, useSSL: Bool = true,
|
|
purpose: String? = nil,
|
|
completion: @escaping (Bool) -> Void) -> AccessReply {
|
|
Network.requestAccess(
|
|
rawRequest: { tcpAPI.pointee.requestAccess.unsafelyUnwrapped($0, $1, $2, $3, $4, $5) },
|
|
server: server, port: port, useSSL: useSSL, purpose: purpose,
|
|
completion: completion)
|
|
}
|
|
|
|
/// Creates a connection to `server`. Fails if access has not been
|
|
/// granted. Call `open(_:)` to connect.
|
|
public init?(server: String, port: Int, useSSL: Bool = true) {
|
|
let pointer = server.withPlaydateCString {
|
|
tcpAPI.pointee.newConnection.unsafelyUnwrapped($0, Int32(port), useSSL)
|
|
}
|
|
guard let pointer else { return nil }
|
|
self.pointer = pointer
|
|
tcpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, Unmanaged.passUnretained(self).toOpaque())
|
|
}
|
|
|
|
deinit {
|
|
tcpAPI.pointee.setUserdata.unsafelyUnwrapped(pointer, nil)
|
|
tcpAPI.pointee.release.unsafelyUnwrapped(pointer)
|
|
}
|
|
|
|
private static func wrapper(for pointer: OpaquePointer?) -> TCPConnection? {
|
|
guard let pointer,
|
|
let userdata = tcpAPI.pointee.getUserdata.unsafelyUnwrapped(pointer) else { return nil }
|
|
return Unmanaged<TCPConnection>.fromOpaque(userdata).takeUnretainedValue()
|
|
}
|
|
|
|
/// The last error on the connection, if any.
|
|
public var error: NetError? {
|
|
Network.optionalError(tcpAPI.pointee.getError.unsafelyUnwrapped(pointer))
|
|
}
|
|
|
|
/// The time to wait for the connection to open, in milliseconds.
|
|
public func setConnectTimeout(milliseconds: Int) {
|
|
tcpAPI.pointee.setConnectTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
|
|
}
|
|
|
|
/// Opens the connection. The completion receives `nil` on success.
|
|
public func open(_ completion: @escaping (TCPConnection, NetError?) -> Void) throws(NetError) {
|
|
openCompletion = completion
|
|
let error = tcpAPI.pointee.open.unsafelyUnwrapped(pointer, { connection, error, _ in
|
|
guard let wrapper = TCPConnection.wrapper(for: connection) else { return }
|
|
let completion = wrapper.openCompletion
|
|
wrapper.openCompletion = nil
|
|
completion?(wrapper, Network.optionalError(error))
|
|
}, nil)
|
|
try Network.check(error)
|
|
}
|
|
|
|
/// Closes the connection.
|
|
public func close() throws(NetError) {
|
|
try Network.check(tcpAPI.pointee.close.unsafelyUnwrapped(pointer))
|
|
}
|
|
|
|
/// Called when the connection closes, with the reason if it closed
|
|
/// due to an error.
|
|
public func setConnectionClosedCallback(_ callback: ((TCPConnection, NetError?) -> Void)?) {
|
|
connectionClosedCallback = callback
|
|
if callback != nil {
|
|
tcpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, { connection, error in
|
|
guard let wrapper = TCPConnection.wrapper(for: connection) else { return }
|
|
wrapper.connectionClosedCallback?(wrapper, Network.optionalError(error))
|
|
})
|
|
} else {
|
|
tcpAPI.pointee.setConnectionClosedCallback.unsafelyUnwrapped(pointer, nil)
|
|
}
|
|
}
|
|
|
|
/// The time to wait for incoming data, in milliseconds.
|
|
public func setReadTimeout(milliseconds: Int) {
|
|
tcpAPI.pointee.setReadTimeout.unsafelyUnwrapped(pointer, Int32(milliseconds))
|
|
}
|
|
|
|
/// The size of the connection's read buffer, in bytes.
|
|
public func setReadBufferSize(bytes: Int) {
|
|
tcpAPI.pointee.setReadBufferSize.unsafelyUnwrapped(pointer, Int32(bytes))
|
|
}
|
|
|
|
/// The number of bytes available to read.
|
|
public var bytesAvailable: Int {
|
|
Int(tcpAPI.pointee.getBytesAvailable.unsafelyUnwrapped(pointer))
|
|
}
|
|
|
|
/// The number of written bytes not yet sent on the wire.
|
|
public var sentBytesPending: Int {
|
|
Int(tcpAPI.pointee.getSentBytesPending.unsafelyUnwrapped(pointer))
|
|
}
|
|
|
|
/// Reads up to `buffer.count` bytes, waiting up to the read timeout.
|
|
/// Returns the number of bytes read.
|
|
public func read(into buffer: UnsafeMutableRawBufferPointer) throws(NetError) -> Int {
|
|
let result = tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
|
|
if result < 0 {
|
|
throw NetError(rawValue: result) ?? .unknown
|
|
}
|
|
return Int(result)
|
|
}
|
|
|
|
/// Reads up to `length` bytes, waiting up to the read timeout.
|
|
public func read(length: Int) throws(NetError) -> [UInt8] {
|
|
var bytes = [UInt8](repeating: 0, count: length)
|
|
let result = bytes.withUnsafeMutableBytes { buffer in
|
|
tcpAPI.pointee.read.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
|
|
}
|
|
if result < 0 {
|
|
throw NetError(rawValue: result) ?? .unknown
|
|
}
|
|
bytes.removeLast(length - Int(result))
|
|
return bytes
|
|
}
|
|
|
|
/// Writes the buffer to the connection. Returns the number of bytes
|
|
/// accepted.
|
|
@discardableResult
|
|
public func write(_ buffer: UnsafeRawBufferPointer) throws(NetError) -> Int {
|
|
let result = tcpAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
|
|
if result < 0 {
|
|
throw NetError(rawValue: result) ?? .unknown
|
|
}
|
|
return Int(result)
|
|
}
|
|
|
|
/// Writes the bytes to the connection. Returns the number of bytes
|
|
/// accepted.
|
|
@discardableResult
|
|
public func write(_ bytes: [UInt8]) throws(NetError) -> Int {
|
|
let result = bytes.withUnsafeBytes { buffer in
|
|
tcpAPI.pointee.write.unsafelyUnwrapped(pointer, buffer.baseAddress, buffer.count)
|
|
}
|
|
if result < 0 {
|
|
throw NetError(rawValue: result) ?? .unknown
|
|
}
|
|
return Int(result)
|
|
}
|
|
}
|
|
}
|