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:
@@ -116,10 +116,11 @@ pub mod method_ids {
|
|||||||
pub const UPLOAD_BLOB: u16 = 600;
|
pub const UPLOAD_BLOB: u16 = 600;
|
||||||
pub const DOWNLOAD_BLOB: u16 = 601;
|
pub const DOWNLOAD_BLOB: u16 = 601;
|
||||||
|
|
||||||
// Device (700-702)
|
// Device (700-702, 710)
|
||||||
pub const REGISTER_DEVICE: u16 = 700;
|
pub const REGISTER_DEVICE: u16 = 700;
|
||||||
pub const LIST_DEVICES: u16 = 701;
|
pub const LIST_DEVICES: u16 = 701;
|
||||||
pub const REVOKE_DEVICE: u16 = 702;
|
pub const REVOKE_DEVICE: u16 = 702;
|
||||||
|
pub const REGISTER_PUSH_TOKEN: u16 = 710;
|
||||||
|
|
||||||
// P2P (800-802)
|
// P2P (800-802)
|
||||||
pub const PUBLISH_ENDPOINT: u16 = 800;
|
pub const PUBLISH_ENDPOINT: u16 = 800;
|
||||||
|
|||||||
@@ -32,3 +32,24 @@ message RevokeDeviceRequest {
|
|||||||
message RevokeDeviceResponse {
|
message RevokeDeviceResponse {
|
||||||
bool success = 1;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
111
sdks/kotlin/README.md
Normal file
111
sdks/kotlin/README.md
Normal file
@@ -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/<abi>/`:
|
||||||
|
- `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
|
||||||
|
```
|
||||||
23
sdks/kotlin/build.gradle.kts
Normal file
23
sdks/kotlin/build.gradle.kts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
117
sdks/kotlin/jni/dev_quicproquo_NativeBridge.c
Normal file
117
sdks/kotlin/jni/dev_quicproquo_NativeBridge.c
Normal file
@@ -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 <jni.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
52
sdks/kotlin/src/main/kotlin/dev/quicproquo/NativeBridge.kt
Normal file
52
sdks/kotlin/src/main/kotlin/dev/quicproquo/NativeBridge.kt
Normal file
@@ -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?
|
||||||
|
}
|
||||||
105
sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqClient.kt
Normal file
105
sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqClient.kt
Normal file
@@ -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<String> {
|
||||||
|
checkConnected()
|
||||||
|
val json = NativeBridge.nativeReceive(handle, timeoutMs) ?: return emptyList()
|
||||||
|
val listType = object : TypeToken<List<String>>() {}.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqError.kt
Normal file
14
sdks/kotlin/src/main/kotlin/dev/quicproquo/QpqError.kt
Normal file
@@ -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")
|
||||||
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