From f57dda3f36a0777cdd357a62377911883bede578 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 4 Mar 2026 20:58:23 +0100 Subject: [PATCH] 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. --- crates/quicproquo-proto/src/lib.rs | 3 +- proto/qpq/v1/device.proto | 21 +++ sdks/kotlin/README.md | 111 +++++++++++++ sdks/kotlin/build.gradle.kts | 23 +++ sdks/kotlin/jni/dev_quicproquo_NativeBridge.c | 117 +++++++++++++ .../kotlin/dev/quicproquo/NativeBridge.kt | 52 ++++++ .../main/kotlin/dev/quicproquo/QpqClient.kt | 105 ++++++++++++ .../main/kotlin/dev/quicproquo/QpqError.kt | 14 ++ sdks/swift/Package.swift | 32 ++++ sdks/swift/README.md | 104 ++++++++++++ .../Sources/CQuicProQuo/module.modulemap | 5 + .../Sources/CQuicProQuo/quicproquo_ffi.h | 52 ++++++ sdks/swift/Sources/QuicProQuo/QpqClient.swift | 155 ++++++++++++++++++ sdks/swift/Sources/QuicProQuo/QpqError.swift | 23 +++ 14 files changed, 816 insertions(+), 1 deletion(-) create mode 100644 sdks/kotlin/README.md create mode 100644 sdks/kotlin/build.gradle.kts create mode 100644 sdks/kotlin/jni/dev_quicproquo_NativeBridge.c create mode 100644 sdks/kotlin/src/main/kotlin/dev/quicproquo/NativeBridge.kt create mode 100644 sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqClient.kt create mode 100644 sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqError.kt create mode 100644 sdks/swift/Package.swift create mode 100644 sdks/swift/README.md create mode 100644 sdks/swift/Sources/CQuicProQuo/module.modulemap create mode 100644 sdks/swift/Sources/CQuicProQuo/quicproquo_ffi.h create mode 100644 sdks/swift/Sources/QuicProQuo/QpqClient.swift create mode 100644 sdks/swift/Sources/QuicProQuo/QpqError.swift diff --git a/crates/quicproquo-proto/src/lib.rs b/crates/quicproquo-proto/src/lib.rs index 39c178e..76fa83d 100644 --- a/crates/quicproquo-proto/src/lib.rs +++ b/crates/quicproquo-proto/src/lib.rs @@ -116,10 +116,11 @@ pub mod method_ids { pub const UPLOAD_BLOB: u16 = 600; pub const DOWNLOAD_BLOB: u16 = 601; - // Device (700-702) + // Device (700-702, 710) pub const REGISTER_DEVICE: u16 = 700; pub const LIST_DEVICES: u16 = 701; pub const REVOKE_DEVICE: u16 = 702; + pub const REGISTER_PUSH_TOKEN: u16 = 710; // P2P (800-802) pub const PUBLISH_ENDPOINT: u16 = 800; diff --git a/proto/qpq/v1/device.proto b/proto/qpq/v1/device.proto index 3915863..6adaf47 100644 --- a/proto/qpq/v1/device.proto +++ b/proto/qpq/v1/device.proto @@ -32,3 +32,24 @@ message RevokeDeviceRequest { message RevokeDeviceResponse { bool success = 1; } + +// Push notification token registration. +// Method ID: 710. +// Clients call this after login to register their device for push notifications. + +enum PushPlatform { + PUSH_PLATFORM_UNSPECIFIED = 0; + PUSH_PLATFORM_APNS = 1; // Apple Push Notification Service (iOS) + PUSH_PLATFORM_FCM = 2; // Firebase Cloud Messaging (Android) + PUSH_PLATFORM_WEB_PUSH = 3; // Web Push (browsers) +} + +message RegisterPushTokenRequest { + bytes device_id = 1; + PushPlatform platform = 2; + string token = 3; // Platform-specific push token +} + +message RegisterPushTokenResponse { + bool success = 1; +} diff --git a/sdks/kotlin/README.md b/sdks/kotlin/README.md new file mode 100644 index 0000000..1ce7b51 --- /dev/null +++ b/sdks/kotlin/README.md @@ -0,0 +1,111 @@ +# QuicProQuo Kotlin SDK + +Kotlin/JVM wrapper over `libquicproquo_ffi` via JNI for Android and JVM platforms. + +## Prerequisites + +- Kotlin 1.9+ / JDK 17+ +- `libquicproquo_ffi` built for the target architecture +- JNI bridge compiled (`jni/dev_quicproquo_NativeBridge.c`) + +## Building the Native Library + +```sh +# Linux (JVM) +cargo build --release -p quicproquo-ffi + +# Android (aarch64) +cargo build --release -p quicproquo-ffi --target aarch64-linux-android + +# Android (armv7) +cargo build --release -p quicproquo-ffi --target armv7-linux-androideabi +``` + +### Compiling the JNI Bridge + +```sh +cd jni +gcc -shared -fPIC -o libquicproquo_jni.so \ + -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" \ + dev_quicproquo_NativeBridge.c \ + -L ../../../../target/release -lquicproquo_ffi +``` + +## Installation + +### Gradle + +```kotlin +dependencies { + implementation(files("libs/quicproquo-0.1.0.jar")) +} +``` + +Or include as a local project module. + +## Usage + +```kotlin +import dev.quicproquo.QpqClient + +val client = QpqClient("127.0.0.1:5001", caCertPath = "ca.pem") + +client.login("alice", "secret") +client.send("bob", "hello".toByteArray()) + +val messages = client.receive(timeoutMs = 5000) +messages.forEach { println("Received: $it") } + +client.close() +``` + +## API + +| Method | Description | +|---|---| +| `QpqClient(server, caCertPath, serverName)` | Connect to server | +| `client.login(username, password)` | OPAQUE authentication | +| `client.send(recipient, message)` | Send message by username | +| `client.receive(timeoutMs)` | Receive pending messages | +| `client.close()` / `client.disconnect()` | Disconnect | +| `client.isConnected` | Connection status | + +## Error Handling + +```kotlin +try { + client.login("alice", "wrong") +} catch (e: QpqAuthException) { + println("Auth failed: ${e.message}") +} catch (e: QpqTimeoutException) { + println("Timeout: ${e.message}") +} catch (e: QpqException) { + println("Error: ${e.message}") +} +``` + +## Structure + +- `src/main/kotlin/dev/quicproquo/QpqClient.kt` -- High-level client +- `src/main/kotlin/dev/quicproquo/NativeBridge.kt` -- JNI declarations +- `src/main/kotlin/dev/quicproquo/QpqError.kt` -- Exception types +- `jni/dev_quicproquo_NativeBridge.c` -- JNI C bridge to FFI + +## Android Integration + +1. Add the native libraries to `src/main/jniLibs//`: + - `arm64-v8a/libquicproquo_ffi.so` + - `armeabi-v7a/libquicproquo_ffi.so` +2. Add the JNI bridge library alongside +3. The `NativeBridge` class loads the library via `System.loadLibrary` + +## Cross-Compilation + +```sh +# Install Android NDK targets +rustup target add aarch64-linux-android armv7-linux-androideabi + +# Build with the NDK linker +CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=aarch64-linux-android21-clang \ + cargo build --release -p quicproquo-ffi --target aarch64-linux-android +``` diff --git a/sdks/kotlin/build.gradle.kts b/sdks/kotlin/build.gradle.kts new file mode 100644 index 0000000..2130886 --- /dev/null +++ b/sdks/kotlin/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + kotlin("jvm") version "1.9.22" +} + +group = "dev.quicproquo" +version = "0.1.0" + +repositories { + mavenCentral() +} + +dependencies { + implementation("com.google.code.gson:gson:2.10.1") + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} + +kotlin { + jvmToolchain(17) +} diff --git a/sdks/kotlin/jni/dev_quicproquo_NativeBridge.c b/sdks/kotlin/jni/dev_quicproquo_NativeBridge.c new file mode 100644 index 0000000..ffa5dd9 --- /dev/null +++ b/sdks/kotlin/jni/dev_quicproquo_NativeBridge.c @@ -0,0 +1,117 @@ +/** + * JNI bridge between Kotlin/Java and libquicproquo_ffi. + * + * Compile with: + * gcc -shared -fPIC -o libquicproquo_jni.so \ + * -I"$JAVA_HOME/include" -I"$JAVA_HOME/include/linux" \ + * dev_quicproquo_NativeBridge.c \ + * -L ../../../../target/release -lquicproquo_ffi + */ + +#include +#include +#include + +/* Forward declarations for libquicproquo_ffi functions. */ +typedef struct QpqHandle QpqHandle; + +extern QpqHandle* qpq_connect(const char* server, const char* ca_cert, const char* server_name); +extern int qpq_login(QpqHandle* handle, const char* username, const char* password); +extern int qpq_send(QpqHandle* handle, const char* recipient, + const uint8_t* message, size_t message_len); +extern int qpq_receive(QpqHandle* handle, uint32_t timeout_ms, char** out_json); +extern void qpq_disconnect(QpqHandle* handle); +extern const char* qpq_last_error(const QpqHandle* handle); +extern void qpq_free_string(char* ptr); + +/* --- JNI exports --- */ + +JNIEXPORT jlong JNICALL +Java_dev_quicproquo_NativeBridge_nativeConnect( + JNIEnv* env, jclass cls, + jstring server, jstring caCert, jstring serverName) +{ + const char* s = (*env)->GetStringUTFChars(env, server, NULL); + const char* c = (*env)->GetStringUTFChars(env, caCert, NULL); + const char* n = (*env)->GetStringUTFChars(env, serverName, NULL); + + QpqHandle* h = qpq_connect(s, c, n); + + (*env)->ReleaseStringUTFChars(env, server, s); + (*env)->ReleaseStringUTFChars(env, caCert, c); + (*env)->ReleaseStringUTFChars(env, serverName, n); + + return (jlong)(uintptr_t)h; +} + +JNIEXPORT jint JNICALL +Java_dev_quicproquo_NativeBridge_nativeLogin( + JNIEnv* env, jclass cls, + jlong handle, jstring username, jstring password) +{ + QpqHandle* h = (QpqHandle*)(uintptr_t)handle; + const char* u = (*env)->GetStringUTFChars(env, username, NULL); + const char* p = (*env)->GetStringUTFChars(env, password, NULL); + + int code = qpq_login(h, u, p); + + (*env)->ReleaseStringUTFChars(env, username, u); + (*env)->ReleaseStringUTFChars(env, password, p); + return code; +} + +JNIEXPORT jint JNICALL +Java_dev_quicproquo_NativeBridge_nativeSend( + JNIEnv* env, jclass cls, + jlong handle, jstring recipient, jbyteArray message) +{ + QpqHandle* h = (QpqHandle*)(uintptr_t)handle; + const char* r = (*env)->GetStringUTFChars(env, recipient, NULL); + jsize len = (*env)->GetArrayLength(env, message); + jbyte* bytes = (*env)->GetByteArrayElements(env, message, NULL); + + int code = qpq_send(h, r, (const uint8_t*)bytes, (size_t)len); + + (*env)->ReleaseByteArrayElements(env, message, bytes, JNI_ABORT); + (*env)->ReleaseStringUTFChars(env, recipient, r); + return code; +} + +JNIEXPORT jstring JNICALL +Java_dev_quicproquo_NativeBridge_nativeReceive( + JNIEnv* env, jclass cls, + jlong handle, jint timeoutMs) +{ + QpqHandle* h = (QpqHandle*)(uintptr_t)handle; + char* json = NULL; + int code = qpq_receive(h, (uint32_t)timeoutMs, &json); + + if (code != 0 || json == NULL) { + if (json) qpq_free_string(json); + return NULL; + } + + jstring result = (*env)->NewStringUTF(env, json); + qpq_free_string(json); + return result; +} + +JNIEXPORT void JNICALL +Java_dev_quicproquo_NativeBridge_nativeDisconnect( + JNIEnv* env, jclass cls, jlong handle) +{ + QpqHandle* h = (QpqHandle*)(uintptr_t)handle; + if (h) { + qpq_disconnect(h); + } +} + +JNIEXPORT jstring JNICALL +Java_dev_quicproquo_NativeBridge_nativeLastError( + JNIEnv* env, jclass cls, jlong handle) +{ + QpqHandle* h = (QpqHandle*)(uintptr_t)handle; + const char* err = qpq_last_error(h); + if (!err) return NULL; + return (*env)->NewStringUTF(env, err); +} diff --git a/sdks/kotlin/src/main/kotlin/dev/quicproquo/NativeBridge.kt b/sdks/kotlin/src/main/kotlin/dev/quicproquo/NativeBridge.kt new file mode 100644 index 0000000..3e6730b --- /dev/null +++ b/sdks/kotlin/src/main/kotlin/dev/quicproquo/NativeBridge.kt @@ -0,0 +1,52 @@ +package dev.quicproquo + +/** + * JNI bridge to libquicproquo_ffi native library. + * + * Load the library with: + * ```kotlin + * System.loadLibrary("quicproquo_ffi") + * ``` + * + * Or set `java.library.path` to the directory containing the shared object. + */ +internal object NativeBridge { + + /** Status codes (mirrors crates/quicproquo-ffi/src/lib.rs). */ + const val QPQ_OK = 0 + const val QPQ_ERROR = 1 + const val QPQ_AUTH_FAILED = 2 + const val QPQ_TIMEOUT = 3 + const val QPQ_NOT_CONNECTED = 4 + + init { + System.loadLibrary("quicproquo_ffi") + } + + /** + * Connect to a server. Returns a native handle (pointer as long), + * or 0 on failure. + */ + @JvmStatic + external fun nativeConnect(server: String, caCert: String, serverName: String): Long + + /** Authenticate with OPAQUE. Returns a status code. */ + @JvmStatic + external fun nativeLogin(handle: Long, username: String, password: String): Int + + /** Send a message. Returns a status code. */ + @JvmStatic + external fun nativeSend(handle: Long, recipient: String, message: ByteArray): Int + + /** Receive messages. Returns JSON string or null. Throws on error. */ + @JvmStatic + external fun nativeReceive(handle: Long, timeoutMs: Int): String? + + /** Disconnect and free the handle. */ + @JvmStatic + external fun nativeDisconnect(handle: Long) + + /** Get last error message, or null. */ + @JvmStatic + external fun nativeLastError(handle: Long): String? +} diff --git a/sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqClient.kt b/sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqClient.kt new file mode 100644 index 0000000..7254c3e --- /dev/null +++ b/sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqClient.kt @@ -0,0 +1,105 @@ +package dev.quicproquo + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.Closeable + +/** + * High-level quicproquo client for Android/JVM. + * + * Wraps `libquicproquo_ffi` via JNI to provide a Kotlin-native API. + * + * ```kotlin + * val client = QpqClient("127.0.0.1:5001", caCertPath = "ca.pem") + * client.login("alice", "secret") + * client.send("bob", "hello".toByteArray()) + * val messages = client.receive(timeoutMs = 5000) + * client.close() + * ``` + */ +class QpqClient( + server: String, + caCertPath: String, + serverName: String = server.substringBefore(":") +) : Closeable { + + @Volatile + private var handle: Long + + private val gson = Gson() + + init { + val h = NativeBridge.nativeConnect(server, caCertPath, serverName) + if (h == 0L) { + throw QpqException("qpq_connect failed for $server") + } + handle = h + } + + /** Whether the client is connected. */ + val isConnected: Boolean + get() = handle != 0L + + /** + * Authenticate with OPAQUE credentials. + * + * @throws QpqAuthException on bad credentials. + */ + fun login(username: String, password: String) { + checkConnected() + val code = NativeBridge.nativeLogin(handle, username, password) + checkStatus(code) + } + + /** + * Send a message to a recipient by username. + * + * @param recipient Recipient username. + * @param message Message payload (arbitrary bytes). + */ + fun send(recipient: String, message: ByteArray) { + checkConnected() + val code = NativeBridge.nativeSend(handle, recipient, message) + checkStatus(code) + } + + /** + * Receive pending messages, blocking up to [timeoutMs] milliseconds. + * + * @return List of message strings (UTF-8). + */ + fun receive(timeoutMs: Int = 5000): List { + checkConnected() + val json = NativeBridge.nativeReceive(handle, timeoutMs) ?: return emptyList() + val listType = object : TypeToken>() {}.type + return gson.fromJson(json, listType) + } + + /** Disconnect from the server and release resources. */ + override fun close() { + val h = handle + if (h != 0L) { + handle = 0L + NativeBridge.nativeDisconnect(h) + } + } + + /** Alias for [close]. */ + fun disconnect() = close() + + private fun checkConnected() { + if (handle == 0L) throw QpqNotConnectedException() + } + + private fun checkStatus(code: Int) { + if (code == NativeBridge.QPQ_OK) return + + val msg = NativeBridge.nativeLastError(handle) ?: "unknown error" + throw when (code) { + NativeBridge.QPQ_AUTH_FAILED -> QpqAuthException(msg) + NativeBridge.QPQ_TIMEOUT -> QpqTimeoutException(msg) + NativeBridge.QPQ_NOT_CONNECTED -> QpqNotConnectedException() + else -> QpqException(msg) + } + } +} diff --git a/sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqError.kt b/sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqError.kt new file mode 100644 index 0000000..92bd399 --- /dev/null +++ b/sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqError.kt @@ -0,0 +1,14 @@ +package dev.quicproquo + +/** Base exception for quicproquo SDK errors. */ +open class QpqException(message: String, cause: Throwable? = null) : + RuntimeException(message, cause) + +/** OPAQUE authentication failed (bad credentials). */ +class QpqAuthException(message: String) : QpqException(message) + +/** Operation timed out. */ +class QpqTimeoutException(message: String) : QpqException(message) + +/** Client is not connected. */ +class QpqNotConnectedException : QpqException("not connected") diff --git a/sdks/swift/Package.swift b/sdks/swift/Package.swift new file mode 100644 index 0000000..2ca0fe0 --- /dev/null +++ b/sdks/swift/Package.swift @@ -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" + ), + ] +) diff --git a/sdks/swift/README.md b/sdks/swift/README.md new file mode 100644 index 0000000..dd72d38 --- /dev/null +++ b/sdks/swift/README.md @@ -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. diff --git a/sdks/swift/Sources/CQuicProQuo/module.modulemap b/sdks/swift/Sources/CQuicProQuo/module.modulemap new file mode 100644 index 0000000..3045953 --- /dev/null +++ b/sdks/swift/Sources/CQuicProQuo/module.modulemap @@ -0,0 +1,5 @@ +module CQuicProQuo { + header "quicproquo_ffi.h" + link "quicproquo_ffi" + export * +} diff --git a/sdks/swift/Sources/CQuicProQuo/quicproquo_ffi.h b/sdks/swift/Sources/CQuicProQuo/quicproquo_ffi.h new file mode 100644 index 0000000..0a3c397 --- /dev/null +++ b/sdks/swift/Sources/CQuicProQuo/quicproquo_ffi.h @@ -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 +#include + +#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 diff --git a/sdks/swift/Sources/QuicProQuo/QpqClient.swift b/sdks/swift/Sources/QuicProQuo/QpqClient.swift new file mode 100644 index 0000000..df47903 --- /dev/null +++ b/sdks/swift/Sources/QuicProQuo/QpqClient.swift @@ -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? + 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(_ 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) + } + } +} diff --git a/sdks/swift/Sources/QuicProQuo/QpqError.swift b/sdks/swift/Sources/QuicProQuo/QpqError.swift new file mode 100644 index 0000000..f57d669 --- /dev/null +++ b/sdks/swift/Sources/QuicProQuo/QpqError.swift @@ -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)" + } + } +}