feat(sdk): add Swift and Kotlin mobile client foundations with push token proto
Swift SDK: Swift Package wrapping libquicproquo_ffi with QpqClient class (connect, login, send, receive, disconnect) for iOS 15+ / macOS 13+. Kotlin SDK: JNI bridge to libquicproquo_ffi with QpqClient class for Android (aarch64, armv7) and JVM, Gradle build configuration. Adds RegisterPushToken RPC (method ID 710) to device.proto for APNs/FCM/WebPush device push token registration.
This commit is contained in:
32
sdks/swift/Package.swift
Normal file
32
sdks/swift/Package.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
// swift-tools-version: 5.9
|
||||
// QuicProQuo Swift SDK — wraps libquicproquo_ffi for iOS/macOS.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "QuicProQuo",
|
||||
platforms: [
|
||||
.iOS(.v15),
|
||||
.macOS(.v13),
|
||||
],
|
||||
products: [
|
||||
.library(name: "QuicProQuo", targets: ["QuicProQuo"]),
|
||||
],
|
||||
targets: [
|
||||
.systemLibrary(
|
||||
name: "CQuicProQuo",
|
||||
pkgConfig: nil,
|
||||
providers: []
|
||||
),
|
||||
.target(
|
||||
name: "QuicProQuo",
|
||||
dependencies: ["CQuicProQuo"],
|
||||
path: "Sources/QuicProQuo"
|
||||
),
|
||||
.testTarget(
|
||||
name: "QuicProQuoTests",
|
||||
dependencies: ["QuicProQuo"],
|
||||
path: "Tests/QuicProQuoTests"
|
||||
),
|
||||
]
|
||||
)
|
||||
104
sdks/swift/README.md
Normal file
104
sdks/swift/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# QuicProQuo Swift SDK
|
||||
|
||||
Swift wrapper over `libquicproquo_ffi` for iOS and macOS.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Xcode 15+ / Swift 5.9+
|
||||
- iOS 15+ or macOS 13+
|
||||
- `libquicproquo_ffi` built for the target architecture
|
||||
|
||||
## Building the Native Library
|
||||
|
||||
```sh
|
||||
# iOS (device)
|
||||
cargo build --release -p quicproquo-ffi --target aarch64-apple-ios
|
||||
|
||||
# iOS Simulator (Apple Silicon)
|
||||
cargo build --release -p quicproquo-ffi --target aarch64-apple-ios-sim
|
||||
|
||||
# macOS
|
||||
cargo build --release -p quicproquo-ffi --target aarch64-apple-darwin
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Swift Package Manager
|
||||
|
||||
Add to your `Package.swift`:
|
||||
|
||||
```swift
|
||||
dependencies: [
|
||||
.package(path: "sdks/swift"),
|
||||
]
|
||||
```
|
||||
|
||||
Or add the library search path to your Xcode project:
|
||||
|
||||
```
|
||||
LIBRARY_SEARCH_PATHS = $(PROJECT_DIR)/../target/release
|
||||
OTHER_LDFLAGS = -lquicproquo_ffi
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```swift
|
||||
import QuicProQuo
|
||||
|
||||
let client = try QpqClient(
|
||||
server: "127.0.0.1:5001",
|
||||
caCertPath: Bundle.main.path(forResource: "ca", ofType: "pem")!
|
||||
)
|
||||
|
||||
try client.login(username: "alice", password: "secret")
|
||||
try client.send(to: "bob", message: "hello".data(using: .utf8)!)
|
||||
|
||||
let messages = try client.receive(timeoutMs: 5000)
|
||||
for msg in messages {
|
||||
print("Received: \(msg)")
|
||||
}
|
||||
|
||||
client.disconnect()
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `QpqClient(server:caCertPath:serverName:)` | Connect to server |
|
||||
| `client.login(username:password:)` | OPAQUE authentication |
|
||||
| `client.send(to:message:)` | Send message by username |
|
||||
| `client.receive(timeoutMs:)` | Receive pending messages |
|
||||
| `client.disconnect()` | Disconnect |
|
||||
| `client.isConnected` | Connection status |
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors are thrown as `QpqError`:
|
||||
|
||||
```swift
|
||||
do {
|
||||
try client.login(username: "alice", password: "wrong")
|
||||
} catch QpqError.authFailed(let msg) {
|
||||
print("Authentication failed: \(msg)")
|
||||
} catch QpqError.connectionFailed(let msg) {
|
||||
print("Connection failed: \(msg)")
|
||||
}
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `Sources/CQuicProQuo/` -- C module map and FFI header
|
||||
- `Sources/QuicProQuo/QpqClient.swift` -- High-level Swift client
|
||||
- `Sources/QuicProQuo/QpqError.swift` -- Error types
|
||||
|
||||
## Cross-Compilation
|
||||
|
||||
For iOS devices, add the Rust target and build:
|
||||
|
||||
```sh
|
||||
rustup target add aarch64-apple-ios
|
||||
cargo build --release -p quicproquo-ffi --target aarch64-apple-ios
|
||||
```
|
||||
|
||||
Copy the library to your Xcode project's framework search path.
|
||||
5
sdks/swift/Sources/CQuicProQuo/module.modulemap
Normal file
5
sdks/swift/Sources/CQuicProQuo/module.modulemap
Normal file
@@ -0,0 +1,5 @@
|
||||
module CQuicProQuo {
|
||||
header "quicproquo_ffi.h"
|
||||
link "quicproquo_ffi"
|
||||
export *
|
||||
}
|
||||
52
sdks/swift/Sources/CQuicProQuo/quicproquo_ffi.h
Normal file
52
sdks/swift/Sources/CQuicProQuo/quicproquo_ffi.h
Normal file
@@ -0,0 +1,52 @@
|
||||
// quicproquo_ffi.h — C header for libquicproquo_ffi.
|
||||
//
|
||||
// Mirrors the extern "C" functions from crates/quicproquo-ffi/src/lib.rs.
|
||||
|
||||
#ifndef QUICPROQUO_FFI_H
|
||||
#define QUICPROQUO_FFI_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Status codes.
|
||||
#define QPQ_OK 0
|
||||
#define QPQ_ERROR 1
|
||||
#define QPQ_AUTH_FAILED 2
|
||||
#define QPQ_TIMEOUT 3
|
||||
#define QPQ_NOT_CONNECTED 4
|
||||
|
||||
// Opaque handle type.
|
||||
typedef struct QpqHandle QpqHandle;
|
||||
|
||||
// Connect to a quicproquo server. Returns NULL on failure.
|
||||
QpqHandle* qpq_connect(const char* server, const char* ca_cert, const char* server_name);
|
||||
|
||||
// Authenticate with OPAQUE credentials.
|
||||
int qpq_login(QpqHandle* handle, const char* username, const char* password);
|
||||
|
||||
// Send a message to a recipient (by username).
|
||||
int qpq_send(QpqHandle* handle, const char* recipient,
|
||||
const uint8_t* message, size_t message_len);
|
||||
|
||||
// Receive pending messages (blocking). On success, *out_json is a
|
||||
// heap-allocated JSON string that must be freed with qpq_free_string.
|
||||
int qpq_receive(QpqHandle* handle, uint32_t timeout_ms, char** out_json);
|
||||
|
||||
// Disconnect and free the handle.
|
||||
void qpq_disconnect(QpqHandle* handle);
|
||||
|
||||
// Return the last error message, or NULL. Do NOT free the result.
|
||||
const char* qpq_last_error(const QpqHandle* handle);
|
||||
|
||||
// Free a string returned by qpq_receive.
|
||||
void qpq_free_string(char* ptr);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif // QUICPROQUO_FFI_H
|
||||
155
sdks/swift/Sources/QuicProQuo/QpqClient.swift
Normal file
155
sdks/swift/Sources/QuicProQuo/QpqClient.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
import Foundation
|
||||
import CQuicProQuo
|
||||
|
||||
/// High-level quicproquo client for iOS/macOS.
|
||||
///
|
||||
/// Wraps ``libquicproquo_ffi`` to provide a Swift-native API for
|
||||
/// connecting, authenticating, and messaging.
|
||||
///
|
||||
/// ```swift
|
||||
/// let client = try QpqClient(
|
||||
/// server: "127.0.0.1:5001",
|
||||
/// caCertPath: "/path/to/ca.pem"
|
||||
/// )
|
||||
/// try client.login(username: "alice", password: "secret")
|
||||
/// try client.send(to: "bob", message: "hello".data(using: .utf8)!)
|
||||
/// let messages = try client.receive(timeoutMs: 5000)
|
||||
/// client.disconnect()
|
||||
/// ```
|
||||
public final class QpqClient: @unchecked Sendable {
|
||||
private var handle: OpaquePointer?
|
||||
private let lock = NSLock()
|
||||
|
||||
/// Connect to a quicproquo server.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - server: Server address as ``host:port``.
|
||||
/// - caCertPath: Path to the PEM-encoded CA certificate.
|
||||
/// - serverName: TLS SNI server name (defaults to host from *server*).
|
||||
/// - Throws: ``QpqError/connectionFailed(_:)`` if the connection fails.
|
||||
public init(server: String, caCertPath: String, serverName: String? = nil) throws {
|
||||
let sn = serverName ?? server.components(separatedBy: ":").first ?? server
|
||||
|
||||
let h = qpq_connect(server, caCertPath, sn)
|
||||
guard let h else {
|
||||
throw QpqError.connectionFailed("qpq_connect returned NULL for \(server)")
|
||||
}
|
||||
self.handle = h
|
||||
}
|
||||
|
||||
deinit {
|
||||
disconnect()
|
||||
}
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
/// Authenticate with the server using OPAQUE (username + password).
|
||||
///
|
||||
/// - Throws: ``QpqError/authFailed(_:)`` on bad credentials.
|
||||
public func login(username: String, password: String) throws {
|
||||
try withHandle { h in
|
||||
let code = qpq_login(h, username, password)
|
||||
try checkStatus(code, handle: h)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Messaging
|
||||
|
||||
/// Send a message to a recipient by username.
|
||||
///
|
||||
/// The message is encrypted via MLS before delivery.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - recipient: Recipient username.
|
||||
/// - message: Message payload (arbitrary bytes).
|
||||
public func send(to recipient: String, message: Data) throws {
|
||||
try withHandle { h in
|
||||
let code = message.withUnsafeBytes { buffer -> Int32 in
|
||||
guard let ptr = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||
return QPQ_ERROR
|
||||
}
|
||||
return qpq_send(h, recipient, ptr, buffer.count)
|
||||
}
|
||||
try checkStatus(code, handle: h)
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive pending messages, blocking up to ``timeoutMs`` milliseconds.
|
||||
///
|
||||
/// - Returns: An array of message strings (UTF-8).
|
||||
public func receive(timeoutMs: UInt32 = 5000) throws -> [String] {
|
||||
try withHandle { h in
|
||||
var jsonPtr: UnsafeMutablePointer<CChar>?
|
||||
let code = qpq_receive(h, timeoutMs, &jsonPtr)
|
||||
try checkStatus(code, handle: h)
|
||||
|
||||
guard let jsonPtr else {
|
||||
return []
|
||||
}
|
||||
defer { qpq_free_string(jsonPtr) }
|
||||
|
||||
let jsonString = String(cString: jsonPtr)
|
||||
|
||||
guard let data = jsonString.data(using: .utf8),
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [String]
|
||||
else {
|
||||
return []
|
||||
}
|
||||
return array
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Disconnect from the server and release resources.
|
||||
public func disconnect() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
if let h = handle {
|
||||
qpq_disconnect(h)
|
||||
handle = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the client is currently connected.
|
||||
public var isConnected: Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return handle != nil
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
private func withHandle<T>(_ body: (OpaquePointer) throws -> T) throws -> T {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
|
||||
guard let h = handle else {
|
||||
throw QpqError.notConnected
|
||||
}
|
||||
return try body(h)
|
||||
}
|
||||
|
||||
private func checkStatus(_ code: Int32, handle: OpaquePointer) throws {
|
||||
guard code != QPQ_OK else { return }
|
||||
|
||||
let msg: String
|
||||
if let errPtr = qpq_last_error(handle) {
|
||||
msg = String(cString: errPtr)
|
||||
} else {
|
||||
msg = "unknown error"
|
||||
}
|
||||
|
||||
switch code {
|
||||
case QPQ_AUTH_FAILED:
|
||||
throw QpqError.authFailed(msg)
|
||||
case QPQ_TIMEOUT:
|
||||
throw QpqError.timeout(msg)
|
||||
case QPQ_NOT_CONNECTED:
|
||||
throw QpqError.notConnected
|
||||
default:
|
||||
throw QpqError.ffiError(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
sdks/swift/Sources/QuicProQuo/QpqError.swift
Normal file
23
sdks/swift/Sources/QuicProQuo/QpqError.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
/// Errors returned by the QuicProQuo SDK.
|
||||
public enum QpqError: Error, Sendable, CustomStringConvertible {
|
||||
/// Connection to the server failed.
|
||||
case connectionFailed(String)
|
||||
/// OPAQUE authentication failed (bad credentials).
|
||||
case authFailed(String)
|
||||
/// The operation timed out.
|
||||
case timeout(String)
|
||||
/// The client is not connected.
|
||||
case notConnected
|
||||
/// A generic error from the FFI layer.
|
||||
case ffiError(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .connectionFailed(let msg): return "connection failed: \(msg)"
|
||||
case .authFailed(let msg): return "auth failed: \(msg)"
|
||||
case .timeout(let msg): return "timeout: \(msg)"
|
||||
case .notConnected: return "not connected"
|
||||
case .ffiError(let msg): return "FFI error: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user