feat: v2 Phase 1 — foundation, proto schemas, RPC framework, SDK skeleton
New workspace structure with 9 crates. Adds: - proto/qpq/v1/*.proto: 11 protobuf schemas covering all 33 RPC methods - quicproquo-proto: dual codegen (capnp legacy + prost v2) - quicproquo-rpc: QUIC RPC framework (framing, server, client, middleware) - quicproquo-sdk: client SDK (QpqClient, events, conversation store) - quicproquo-server/domain/: protocol-agnostic domain types and services - justfile: build commands Wire format: [method_id:u16][req_id:u32][len:u32][protobuf] per QUIC stream. All 151 existing tests pass. Backward compatible with v1 capnp code.
This commit is contained in:
193
crates/quicproquo-sdk/src/client.rs
Normal file
193
crates/quicproquo-sdk/src/client.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! `QpqClient` — the main entry point for the quicproquo SDK.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::info;
|
||||
|
||||
use crate::config::ClientConfig;
|
||||
use crate::conversation::ConversationStore;
|
||||
use crate::error::SdkError;
|
||||
use crate::events::ClientEvent;
|
||||
|
||||
/// The main SDK client. All state is contained within this struct — no globals.
|
||||
pub struct QpqClient {
|
||||
config: ClientConfig,
|
||||
rpc: Option<quicproquo_rpc::client::RpcClient>,
|
||||
event_tx: broadcast::Sender<ClientEvent>,
|
||||
/// The authenticated username, if logged in.
|
||||
username: Option<String>,
|
||||
/// The local identity key (Ed25519 public key, 32 bytes).
|
||||
identity_key: Option<Vec<u8>>,
|
||||
/// Session token from OPAQUE login.
|
||||
session_token: Option<Vec<u8>>,
|
||||
/// Local conversation store (SQLCipher).
|
||||
conv_store: Option<ConversationStore>,
|
||||
}
|
||||
|
||||
impl QpqClient {
|
||||
/// Create a new client with the given configuration.
|
||||
pub fn new(config: ClientConfig) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(256);
|
||||
Self {
|
||||
config,
|
||||
rpc: None,
|
||||
event_tx,
|
||||
username: None,
|
||||
identity_key: None,
|
||||
session_token: None,
|
||||
conv_store: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the server.
|
||||
pub async fn connect(&mut self) -> Result<(), SdkError> {
|
||||
let tls_config = build_tls_config(self.config.accept_invalid_certs)?;
|
||||
|
||||
let rpc_config = quicproquo_rpc::client::RpcClientConfig {
|
||||
server_addr: self.config.server_addr,
|
||||
server_name: self.config.server_name.clone(),
|
||||
tls_config: Arc::new(tls_config),
|
||||
alpn: self.config.alpn.clone(),
|
||||
};
|
||||
|
||||
let client = quicproquo_rpc::client::RpcClient::connect(rpc_config).await?;
|
||||
self.rpc = Some(client);
|
||||
|
||||
// Open local conversation store.
|
||||
let store = ConversationStore::open(
|
||||
&self.config.db_path,
|
||||
self.config.db_password.as_deref(),
|
||||
)?;
|
||||
self.conv_store = Some(store);
|
||||
|
||||
self.emit(ClientEvent::Connected);
|
||||
info!(server = %self.config.server_addr, "connected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Subscribe to client events. Returns a broadcast receiver.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<ClientEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Get the authenticated username, if logged in.
|
||||
pub fn username(&self) -> Option<&str> {
|
||||
self.username.as_deref()
|
||||
}
|
||||
|
||||
/// Get the local identity key.
|
||||
pub fn identity_key(&self) -> Option<&[u8]> {
|
||||
self.identity_key.as_deref()
|
||||
}
|
||||
|
||||
/// Whether the client is connected.
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.rpc.is_some()
|
||||
}
|
||||
|
||||
/// Whether the client is authenticated.
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.session_token.is_some()
|
||||
}
|
||||
|
||||
/// Get a reference to the RPC client (for direct calls).
|
||||
pub fn rpc(&self) -> Result<&quicproquo_rpc::client::RpcClient, SdkError> {
|
||||
self.rpc.as_ref().ok_or(SdkError::NotConnected)
|
||||
}
|
||||
|
||||
/// Get a reference to the conversation store.
|
||||
pub fn conversations(&self) -> Result<&ConversationStore, SdkError> {
|
||||
self.conv_store
|
||||
.as_ref()
|
||||
.ok_or(SdkError::NotConnected)
|
||||
}
|
||||
|
||||
/// Disconnect from the server.
|
||||
pub fn disconnect(&mut self) {
|
||||
if let Some(rpc) = self.rpc.take() {
|
||||
rpc.close();
|
||||
self.emit(ClientEvent::Disconnected {
|
||||
reason: "client closed".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&self, event: ClientEvent) {
|
||||
// Ignore send errors (no subscribers).
|
||||
let _ = self.event_tx.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for QpqClient {
|
||||
fn drop(&mut self) {
|
||||
self.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tls_config(accept_invalid_certs: bool) -> Result<rustls::ClientConfig, SdkError> {
|
||||
let builder = rustls::ClientConfig::builder();
|
||||
|
||||
if accept_invalid_certs {
|
||||
let config = builder
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(InsecureVerifier))
|
||||
.with_no_client_auth();
|
||||
Ok(config)
|
||||
} else {
|
||||
let roots = rustls::RootCertStore::empty();
|
||||
let config = builder
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// A TLS verifier that accepts any certificate (for dev mode only).
|
||||
#[derive(Debug)]
|
||||
struct InsecureVerifier;
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for InsecureVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
]
|
||||
}
|
||||
}
|
||||
44
crates/quicproquo-sdk/src/config.rs
Normal file
44
crates/quicproquo-sdk/src/config.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! Client configuration.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Configuration for a `QpqClient` instance.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientConfig {
|
||||
/// Server address (host:port).
|
||||
pub server_addr: SocketAddr,
|
||||
|
||||
/// Server hostname for TLS SNI.
|
||||
pub server_name: String,
|
||||
|
||||
/// Path to the local conversation database.
|
||||
pub db_path: PathBuf,
|
||||
|
||||
/// Password for encrypting the local database (SQLCipher).
|
||||
/// If `None`, the database is stored unencrypted.
|
||||
pub db_password: Option<String>,
|
||||
|
||||
/// Path to the local state file (identity key, MLS state).
|
||||
pub state_path: PathBuf,
|
||||
|
||||
/// Whether to accept self-signed TLS certificates (dev mode only).
|
||||
pub accept_invalid_certs: bool,
|
||||
|
||||
/// ALPN protocol identifier for the RPC service.
|
||||
pub alpn: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_addr: "127.0.0.1:7000".parse().expect("valid addr"),
|
||||
server_name: "localhost".to_string(),
|
||||
db_path: PathBuf::from("conversations.db"),
|
||||
db_password: None,
|
||||
state_path: PathBuf::from("client-state.bin"),
|
||||
accept_invalid_certs: false,
|
||||
alpn: b"qpq/2".to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
481
crates/quicproquo-sdk/src/conversation.rs
Normal file
481
crates/quicproquo-sdk/src/conversation.rs
Normal file
@@ -0,0 +1,481 @@
|
||||
//! Conversation management — create DMs, groups, send and receive messages.
|
||||
//!
|
||||
//! This is the SDK-side conversation store (migrated from v1 client).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 16-byte conversation identifier.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ConversationId(pub [u8; 16]);
|
||||
|
||||
impl ConversationId {
|
||||
pub fn from_slice(s: &[u8]) -> Option<Self> {
|
||||
if s.len() == 16 {
|
||||
let mut buf = [0u8; 16];
|
||||
buf.copy_from_slice(s);
|
||||
Some(Self(buf))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a conversation ID from a group name via SHA-256 truncation.
|
||||
pub fn from_group_name(name: &str) -> Self {
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(name.as_bytes());
|
||||
let mut buf = [0u8; 16];
|
||||
buf.copy_from_slice(&hash[..16]);
|
||||
Self(buf)
|
||||
}
|
||||
|
||||
pub fn hex(&self) -> String {
|
||||
hex::encode(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of conversation.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ConversationKind {
|
||||
/// 1:1 DM channel with a specific peer.
|
||||
Dm {
|
||||
peer_key: Vec<u8>,
|
||||
peer_username: Option<String>,
|
||||
},
|
||||
/// Named group with N members.
|
||||
Group { name: String },
|
||||
}
|
||||
|
||||
/// A conversation with its metadata and MLS state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Conversation {
|
||||
pub id: ConversationId,
|
||||
pub kind: ConversationKind,
|
||||
pub display_name: String,
|
||||
/// Serialized MLS group (bincode).
|
||||
pub mls_group_blob: Option<Vec<u8>>,
|
||||
/// Serialized keystore (bincode HashMap).
|
||||
pub keystore_blob: Option<Vec<u8>>,
|
||||
/// Member identity keys.
|
||||
pub member_keys: Vec<Vec<u8>>,
|
||||
pub unread_count: u32,
|
||||
pub last_activity_ms: u64,
|
||||
pub created_at_ms: u64,
|
||||
/// Whether this conversation uses hybrid (X25519 + ML-KEM-768) MLS keys.
|
||||
pub is_hybrid: bool,
|
||||
/// Highest server-side delivery sequence number seen.
|
||||
pub last_seen_seq: u64,
|
||||
}
|
||||
|
||||
/// A stored message.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StoredMessage {
|
||||
pub conversation_id: ConversationId,
|
||||
pub message_id: Option<[u8; 16]>,
|
||||
pub sender_key: Vec<u8>,
|
||||
pub sender_name: Option<String>,
|
||||
pub body: String,
|
||||
pub msg_type: String,
|
||||
pub ref_msg_id: Option<[u8; 16]>,
|
||||
pub timestamp_ms: u64,
|
||||
pub is_outgoing: bool,
|
||||
}
|
||||
|
||||
/// An entry in the offline outbox queue.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OutboxEntry {
|
||||
pub id: i64,
|
||||
pub conversation_id: ConversationId,
|
||||
pub recipient_key: Vec<u8>,
|
||||
pub payload: Vec<u8>,
|
||||
pub retry_count: u32,
|
||||
}
|
||||
|
||||
// ── ConversationStore ────────────────────────────────────────────────────────
|
||||
|
||||
/// SQLCipher-backed conversation and message store.
|
||||
pub struct ConversationStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl ConversationStore {
|
||||
/// Open or create the conversation database.
|
||||
pub fn open(db_path: &Path, password: Option<&str>) -> anyhow::Result<Self> {
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
let conn = Connection::open(db_path).context("open conversation db")?;
|
||||
|
||||
if let Some(pw) = password {
|
||||
let key = derive_db_key(pw, db_path)?;
|
||||
let hex_key = Zeroizing::new(hex::encode(&*key));
|
||||
conn.pragma_update(None, "key", format!("x'{}'", &*hex_key))
|
||||
.context("set SQLCipher key")?;
|
||||
}
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
|
||||
.context("set pragmas")?;
|
||||
Self::migrate(&conn)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
fn migrate(conn: &Connection) -> anyhow::Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS conversations (
|
||||
id BLOB PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
peer_key BLOB,
|
||||
peer_username TEXT,
|
||||
group_name TEXT,
|
||||
mls_group_blob BLOB,
|
||||
keystore_blob BLOB,
|
||||
member_keys BLOB,
|
||||
unread_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_activity_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at_ms INTEGER NOT NULL DEFAULT 0,
|
||||
is_hybrid INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen_seq INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id BLOB NOT NULL REFERENCES conversations(id),
|
||||
message_id BLOB,
|
||||
sender_key BLOB NOT NULL,
|
||||
sender_name TEXT,
|
||||
body TEXT NOT NULL,
|
||||
msg_type TEXT NOT NULL,
|
||||
ref_msg_id BLOB,
|
||||
timestamp_ms INTEGER NOT NULL,
|
||||
is_outgoing INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conv
|
||||
ON messages(conversation_id, timestamp_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id BLOB NOT NULL,
|
||||
recipient_key BLOB NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_status
|
||||
ON outbox(status, created_at_ms);",
|
||||
)
|
||||
.context("migrate conversation db")
|
||||
}
|
||||
|
||||
/// Save or upsert a conversation.
|
||||
pub fn save_conversation(&self, conv: &Conversation) -> anyhow::Result<()> {
|
||||
let (kind_str, peer_key, peer_username, group_name) = match &conv.kind {
|
||||
ConversationKind::Dm { peer_key, peer_username } => {
|
||||
("dm", Some(peer_key.as_slice()), peer_username.as_deref(), None)
|
||||
}
|
||||
ConversationKind::Group { name } => ("group", None, None, Some(name.as_str())),
|
||||
};
|
||||
let member_keys_blob =
|
||||
bincode::serialize(&conv.member_keys).context("serialize member_keys")?;
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO conversations
|
||||
(id, kind, display_name, peer_key, peer_username, group_name,
|
||||
mls_group_blob, keystore_blob, member_keys, unread_count,
|
||||
last_activity_ms, created_at_ms, is_hybrid, last_seen_seq)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
mls_group_blob = excluded.mls_group_blob,
|
||||
keystore_blob = excluded.keystore_blob,
|
||||
member_keys = excluded.member_keys,
|
||||
unread_count = excluded.unread_count,
|
||||
last_activity_ms = excluded.last_activity_ms,
|
||||
is_hybrid = excluded.is_hybrid,
|
||||
last_seen_seq = excluded.last_seen_seq",
|
||||
params![
|
||||
conv.id.0.as_slice(),
|
||||
kind_str,
|
||||
conv.display_name,
|
||||
peer_key,
|
||||
peer_username,
|
||||
group_name,
|
||||
conv.mls_group_blob,
|
||||
conv.keystore_blob,
|
||||
member_keys_blob,
|
||||
conv.unread_count,
|
||||
conv.last_activity_ms,
|
||||
conv.created_at_ms,
|
||||
conv.is_hybrid as i32,
|
||||
conv.last_seen_seq as i64,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a conversation by ID.
|
||||
pub fn load_conversation(&self, id: &ConversationId) -> anyhow::Result<Option<Conversation>> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT kind, display_name, peer_key, peer_username, group_name,
|
||||
mls_group_blob, keystore_blob, member_keys, unread_count,
|
||||
last_activity_ms, created_at_ms, is_hybrid, last_seen_seq
|
||||
FROM conversations WHERE id = ?1",
|
||||
params![id.0.as_slice()],
|
||||
|row| row_to_conversation(id, row),
|
||||
)
|
||||
.optional()
|
||||
.context("load conversation")
|
||||
}
|
||||
|
||||
/// List all conversations, most recent first.
|
||||
pub fn list_conversations(&self) -> anyhow::Result<Vec<Conversation>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, kind, display_name, peer_key, peer_username, group_name,
|
||||
mls_group_blob, keystore_blob, member_keys, unread_count,
|
||||
last_activity_ms, created_at_ms, is_hybrid, last_seen_seq
|
||||
FROM conversations ORDER BY last_activity_ms DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let id_blob: Vec<u8> = row.get(0)?;
|
||||
let id = ConversationId::from_slice(&id_blob).unwrap_or(ConversationId([0; 16]));
|
||||
row_to_conversation_full(&id, row)
|
||||
})?;
|
||||
let mut convs = Vec::new();
|
||||
for row in rows {
|
||||
convs.push(row?);
|
||||
}
|
||||
Ok(convs)
|
||||
}
|
||||
|
||||
/// Find a DM by peer identity key.
|
||||
pub fn find_dm_by_peer(&self, peer_key: &[u8]) -> anyhow::Result<Option<Conversation>> {
|
||||
let id_blob: Option<Vec<u8>> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT id FROM conversations WHERE kind = 'dm' AND peer_key = ?1",
|
||||
params![peer_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
match id_blob {
|
||||
Some(blob) => {
|
||||
let id = ConversationId::from_slice(&blob)
|
||||
.context("invalid conversation id")?;
|
||||
self.load_conversation(&id)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Save a message.
|
||||
pub fn save_message(&self, msg: &StoredMessage) -> anyhow::Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO messages
|
||||
(conversation_id, message_id, sender_key, sender_name, body,
|
||||
msg_type, ref_msg_id, timestamp_ms, is_outgoing)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![
|
||||
msg.conversation_id.0.as_slice(),
|
||||
msg.message_id.as_ref().map(|id| id.as_slice()),
|
||||
msg.sender_key,
|
||||
msg.sender_name,
|
||||
msg.body,
|
||||
msg.msg_type,
|
||||
msg.ref_msg_id.as_ref().map(|id| id.as_slice()),
|
||||
msg.timestamp_ms,
|
||||
msg.is_outgoing as i32,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load recent messages (newest first, then reversed to chronological).
|
||||
pub fn load_recent_messages(
|
||||
&self,
|
||||
conv_id: &ConversationId,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<StoredMessage>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT message_id, sender_key, sender_name, body, msg_type,
|
||||
ref_msg_id, timestamp_ms, is_outgoing
|
||||
FROM messages WHERE conversation_id = ?1
|
||||
ORDER BY timestamp_ms DESC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![conv_id.0.as_slice(), limit.min(u32::MAX as usize) as u32],
|
||||
|row| row_to_message(conv_id, row),
|
||||
)?;
|
||||
let mut msgs: Vec<StoredMessage> = rows.collect::<Result<_, _>>()?;
|
||||
msgs.reverse();
|
||||
Ok(msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn derive_db_key(password: &str, db_path: &Path) -> anyhow::Result<Zeroizing<[u8; 32]>> {
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
|
||||
let salt_path = db_path.with_extension("db-salt");
|
||||
let salt = if salt_path.exists() {
|
||||
std::fs::read(&salt_path).context("read db salt")?
|
||||
} else {
|
||||
let mut salt = vec![0u8; 16];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut salt);
|
||||
std::fs::write(&salt_path, &salt).context("write db salt")?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&salt_path, std::fs::Permissions::from_mode(0o600)).ok();
|
||||
}
|
||||
salt
|
||||
};
|
||||
|
||||
let params = Params::new(19 * 1024, 2, 1, Some(32))
|
||||
.map_err(|e| anyhow::anyhow!("argon2 params: {e}"))?;
|
||||
let argon2 = Argon2::new(Algorithm::Argon2id, Version::default(), params);
|
||||
let mut key = Zeroizing::new([0u8; 32]);
|
||||
argon2
|
||||
.hash_password_into(password.as_bytes(), &salt, &mut *key)
|
||||
.map_err(|e| anyhow::anyhow!("db key derivation: {e}"))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn row_to_conversation(
|
||||
id: &ConversationId,
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<Conversation> {
|
||||
let kind_str: String = row.get(0)?;
|
||||
let display_name: String = row.get(1)?;
|
||||
let peer_key: Option<Vec<u8>> = row.get(2)?;
|
||||
let peer_username: Option<String> = row.get(3)?;
|
||||
let group_name: Option<String> = row.get(4)?;
|
||||
let mls_group_blob: Option<Vec<u8>> = row.get(5)?;
|
||||
let keystore_blob: Option<Vec<u8>> = row.get(6)?;
|
||||
let member_keys_blob: Option<Vec<u8>> = row.get(7)?;
|
||||
let unread_count: u32 = row.get(8)?;
|
||||
let last_activity_ms: u64 = row.get(9)?;
|
||||
let created_at_ms: u64 = row.get(10)?;
|
||||
let is_hybrid_int: i32 = row.get(11)?;
|
||||
let last_seen_seq: i64 = row.get(12)?;
|
||||
|
||||
let kind = if kind_str == "dm" {
|
||||
ConversationKind::Dm {
|
||||
peer_key: peer_key.unwrap_or_default(),
|
||||
peer_username,
|
||||
}
|
||||
} else {
|
||||
ConversationKind::Group {
|
||||
name: group_name.unwrap_or_default(),
|
||||
}
|
||||
};
|
||||
let member_keys: Vec<Vec<u8>> = member_keys_blob
|
||||
.and_then(|b| bincode::deserialize(&b).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Conversation {
|
||||
id: id.clone(),
|
||||
kind,
|
||||
display_name,
|
||||
mls_group_blob,
|
||||
keystore_blob,
|
||||
member_keys,
|
||||
unread_count,
|
||||
last_activity_ms,
|
||||
created_at_ms,
|
||||
is_hybrid: is_hybrid_int != 0,
|
||||
last_seen_seq: last_seen_seq as u64,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_conversation_full(
|
||||
id: &ConversationId,
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<Conversation> {
|
||||
let kind_str: String = row.get(1)?;
|
||||
let display_name: String = row.get(2)?;
|
||||
let peer_key: Option<Vec<u8>> = row.get(3)?;
|
||||
let peer_username: Option<String> = row.get(4)?;
|
||||
let group_name: Option<String> = row.get(5)?;
|
||||
let mls_group_blob: Option<Vec<u8>> = row.get(6)?;
|
||||
let keystore_blob: Option<Vec<u8>> = row.get(7)?;
|
||||
let member_keys_blob: Option<Vec<u8>> = row.get(8)?;
|
||||
let unread_count: u32 = row.get(9)?;
|
||||
let last_activity_ms: u64 = row.get(10)?;
|
||||
let created_at_ms: u64 = row.get(11)?;
|
||||
let is_hybrid_int: i32 = row.get(12)?;
|
||||
let last_seen_seq: i64 = row.get(13)?;
|
||||
|
||||
let kind = if kind_str == "dm" {
|
||||
ConversationKind::Dm {
|
||||
peer_key: peer_key.unwrap_or_default(),
|
||||
peer_username,
|
||||
}
|
||||
} else {
|
||||
ConversationKind::Group {
|
||||
name: group_name.unwrap_or_default(),
|
||||
}
|
||||
};
|
||||
let member_keys: Vec<Vec<u8>> = member_keys_blob
|
||||
.and_then(|b| bincode::deserialize(&b).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(Conversation {
|
||||
id: id.clone(),
|
||||
kind,
|
||||
display_name,
|
||||
mls_group_blob,
|
||||
keystore_blob,
|
||||
member_keys,
|
||||
unread_count,
|
||||
last_activity_ms,
|
||||
created_at_ms,
|
||||
is_hybrid: is_hybrid_int != 0,
|
||||
last_seen_seq: last_seen_seq as u64,
|
||||
})
|
||||
}
|
||||
|
||||
fn to_16(v: &[u8]) -> Option<[u8; 16]> {
|
||||
if v.len() == 16 {
|
||||
let mut buf = [0u8; 16];
|
||||
buf.copy_from_slice(v);
|
||||
Some(buf)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_message(
|
||||
conv_id: &ConversationId,
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<StoredMessage> {
|
||||
let message_id: Option<Vec<u8>> = row.get(0)?;
|
||||
let sender_key: Vec<u8> = row.get(1)?;
|
||||
let sender_name: Option<String> = row.get(2)?;
|
||||
let body: String = row.get(3)?;
|
||||
let msg_type: String = row.get(4)?;
|
||||
let ref_msg_id: Option<Vec<u8>> = row.get(5)?;
|
||||
let timestamp_ms: u64 = row.get(6)?;
|
||||
let is_outgoing: i32 = row.get(7)?;
|
||||
|
||||
Ok(StoredMessage {
|
||||
conversation_id: conv_id.clone(),
|
||||
message_id: message_id.as_deref().and_then(to_16),
|
||||
sender_key,
|
||||
sender_name,
|
||||
body,
|
||||
msg_type,
|
||||
ref_msg_id: ref_msg_id.as_deref().and_then(to_16),
|
||||
timestamp_ms,
|
||||
is_outgoing: is_outgoing != 0,
|
||||
})
|
||||
}
|
||||
29
crates/quicproquo-sdk/src/error.rs
Normal file
29
crates/quicproquo-sdk/src/error.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! SDK error types.
|
||||
|
||||
/// Errors returned by SDK operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SdkError {
|
||||
#[error("not connected to server")]
|
||||
NotConnected,
|
||||
|
||||
#[error("not authenticated — call login() first")]
|
||||
NotAuthenticated,
|
||||
|
||||
#[error("authentication failed: {0}")]
|
||||
AuthFailed(String),
|
||||
|
||||
#[error("conversation not found: {0}")]
|
||||
ConversationNotFound(String),
|
||||
|
||||
#[error("crypto error: {0}")]
|
||||
Crypto(String),
|
||||
|
||||
#[error("RPC error: {0}")]
|
||||
Rpc(#[from] quicproquo_rpc::error::RpcError),
|
||||
|
||||
#[error("storage error: {0}")]
|
||||
Storage(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
56
crates/quicproquo-sdk/src/events.rs
Normal file
56
crates/quicproquo-sdk/src/events.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! Client event system — real-time notifications from the SDK.
|
||||
|
||||
/// Events emitted by the SDK to the UI layer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ClientEvent {
|
||||
/// Successfully connected to the server.
|
||||
Connected,
|
||||
|
||||
/// Disconnected from the server.
|
||||
Disconnected { reason: String },
|
||||
|
||||
/// Authentication succeeded.
|
||||
Authenticated { username: String },
|
||||
|
||||
/// A new message was received in a conversation.
|
||||
MessageReceived {
|
||||
conversation_id: [u8; 16],
|
||||
sender_key: Vec<u8>,
|
||||
sender_name: Option<String>,
|
||||
body: String,
|
||||
timestamp_ms: u64,
|
||||
},
|
||||
|
||||
/// A message was sent successfully.
|
||||
MessageSent {
|
||||
conversation_id: [u8; 16],
|
||||
seq: u64,
|
||||
},
|
||||
|
||||
/// A new conversation was created or discovered.
|
||||
ConversationCreated {
|
||||
conversation_id: [u8; 16],
|
||||
display_name: String,
|
||||
},
|
||||
|
||||
/// A member was added to a group conversation.
|
||||
MemberAdded {
|
||||
conversation_id: [u8; 16],
|
||||
member_key: Vec<u8>,
|
||||
},
|
||||
|
||||
/// A member was removed from a group conversation.
|
||||
MemberRemoved {
|
||||
conversation_id: [u8; 16],
|
||||
member_key: Vec<u8>,
|
||||
},
|
||||
|
||||
/// Server-push event received.
|
||||
PushEvent {
|
||||
event_type: u16,
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
|
||||
/// An error occurred in the background.
|
||||
Error { message: String },
|
||||
}
|
||||
10
crates/quicproquo-sdk/src/lib.rs
Normal file
10
crates/quicproquo-sdk/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! Client SDK for quicproquo v2.
|
||||
//!
|
||||
//! Provides `QpqClient` — a single entry point for connecting, authenticating,
|
||||
//! sending/receiving messages, and subscribing to real-time events.
|
||||
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod conversation;
|
||||
pub mod events;
|
||||
pub mod error;
|
||||
Reference in New Issue
Block a user