feat: add Python (PyO3) and Ruby (Magnus) native bindings

Introduces three crates:
- quicnprotochat-bindings: shared Rust API returning structured data
  instead of printing to stdout; explicit per-call auth (no global
  OnceLock); QPCE state files fully interoperable with the CLI.
- quicnprotochat-python: PyO3 0.22 extension; GIL released during
  all blocking QUIC calls via py.allow_threads.
- quicnprotochat-ruby: Magnus 0.7 extension; Rakefile build task.

Core/proto crates referenced via git dep on the main repo.
This commit is contained in:
2026-02-22 18:56:27 +01:00
commit f511903a5d
11 changed files with 1523 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
[package]
name = "quicnprotochat-ruby"
version = "0.1.0"
edition = "2021"
description = "Ruby bindings for quicnprotochat (Magnus)."
license = "MIT"
[lib]
name = "quicnprotochat_ruby"
crate-type = ["cdylib"]
[dependencies]
quicnprotochat-bindings = { path = "../quicnprotochat-bindings" }
magnus = { version = "0.7" }

View File

@@ -0,0 +1,39 @@
require "fileutils"
WORKSPACE_ROOT = File.expand_path("../..", __dir__)
TARGET_DIR = File.join(WORKSPACE_ROOT, "target")
LIB_DIR = File.join(__dir__, "lib")
# Detect the shared library extension for the current platform.
SO_EXT = case RUBY_PLATFORM
when /darwin/ then "dylib"
when /mingw|mswin/ then "dll"
else "so"
end
LIB_SRC = File.join(TARGET_DIR, "release", "libquicnprotochat_ruby.#{SO_EXT}")
LIB_DEST = File.join(LIB_DIR, "quicnprotochat_ruby.#{SO_EXT}")
desc "Build the native extension (release)"
task :build do
sh "cargo build --release --manifest-path #{File.join(__dir__, "Cargo.toml")}"
FileUtils.mkdir_p(LIB_DIR)
FileUtils.cp(LIB_SRC, LIB_DEST)
puts "Copied #{LIB_DEST}"
end
desc "Build the native extension (debug)"
task :build_dev do
sh "cargo build --manifest-path #{File.join(__dir__, "Cargo.toml")}"
lib_src = File.join(TARGET_DIR, "debug", "libquicnprotochat_ruby.#{SO_EXT}")
FileUtils.mkdir_p(LIB_DIR)
FileUtils.cp(lib_src, LIB_DEST)
puts "Copied #{LIB_DEST} (debug)"
end
desc "Remove build artefacts"
task :clean do
FileUtils.rm_f(LIB_DEST)
end
task default: :build

View File

@@ -0,0 +1,170 @@
use magnus::{
class, define_module, exception, function, method,
prelude::*,
Error, Ruby,
};
use quicnprotochat_bindings::Client;
use std::sync::{Arc, Mutex};
fn to_rb(e: anyhow::Error) -> Error {
Error::new(exception::runtime_error(), e.to_string())
}
// ── RbClient ──────────────────────────────────────────────────────────────────
/// Ruby wrapper around `Client`. Wrapped in `Arc<Mutex<>>` so that Ruby's GC
/// can safely reference-count it while the GVL serialises all calls.
#[magnus::wrap(class = "QuicNProtoChat::Client", free_immediately, size)]
struct RbClient(Arc<Mutex<Client>>);
impl RbClient {
fn new(
ruby: &Ruby,
server: String,
ca_cert: String,
server_name: String,
state_path: String,
access_token: String,
state_password: Option<String>,
device_id: Option<String>,
) -> Result<Self, Error> {
let inner = Client::new(
&server,
&ca_cert,
&server_name,
&state_path,
&access_token,
state_password,
device_id,
)
.map_err(to_rb)?;
Ok(RbClient(Arc::new(Mutex::new(inner))))
}
fn whoami(ruby: &Ruby, rb_self: &RbClient) -> Result<magnus::RHash, Error> {
let info = rb_self.0.lock().unwrap().whoami().map_err(to_rb)?;
let h = ruby.hash_new();
h.aset(ruby.str_new("identity_key"), ruby.str_new(&info.identity_key))?;
h.aset(ruby.str_new("fingerprint"), ruby.str_new(&info.fingerprint))?;
h.aset(ruby.str_new("hybrid_key"), info.hybrid_key)?;
h.aset(ruby.str_new("has_group"), info.has_group)?;
Ok(h)
}
fn health(ruby: &Ruby, rb_self: &RbClient) -> Result<magnus::RHash, Error> {
let info = rb_self.0.lock().unwrap().health().map_err(to_rb)?;
let h = ruby.hash_new();
h.aset(ruby.str_new("status"), ruby.str_new(&info.status))?;
h.aset(ruby.str_new("rtt_ms"), info.rtt_ms)?;
Ok(h)
}
fn register_user(
_ruby: &Ruby,
rb_self: &RbClient,
username: String,
password: String,
) -> Result<(), Error> {
rb_self
.0
.lock()
.unwrap()
.register_user(&username, &password)
.map_err(to_rb)
}
fn login(
_ruby: &Ruby,
rb_self: &RbClient,
username: String,
password: String,
) -> Result<String, Error> {
rb_self
.0
.lock()
.unwrap()
.login(&username, &password)
.map_err(to_rb)
}
fn register_state(_ruby: &Ruby, rb_self: &RbClient) -> Result<String, Error> {
rb_self.0.lock().unwrap().register_state().map_err(to_rb)
}
fn check_key(_ruby: &Ruby, rb_self: &RbClient, peer_hex: String) -> Result<bool, Error> {
rb_self
.0
.lock()
.unwrap()
.check_key(&peer_hex)
.map_err(to_rb)
}
fn create_group(_ruby: &Ruby, rb_self: &RbClient, group_id: String) -> Result<(), Error> {
rb_self
.0
.lock()
.unwrap()
.create_group(&group_id)
.map_err(to_rb)
}
fn invite(_ruby: &Ruby, rb_self: &RbClient, peer_hex: String) -> Result<(), Error> {
rb_self.0.lock().unwrap().invite(&peer_hex).map_err(to_rb)
}
fn join(_ruby: &Ruby, rb_self: &RbClient) -> Result<(), Error> {
rb_self.0.lock().unwrap().join().map_err(to_rb)
}
fn send_message(
_ruby: &Ruby,
rb_self: &RbClient,
peer_hex: String,
text: String,
) -> Result<(), Error> {
rb_self
.0
.lock()
.unwrap()
.send_message(&peer_hex, &text)
.map_err(to_rb)
}
fn recv(ruby: &Ruby, rb_self: &RbClient, wait_ms: u64) -> Result<magnus::RArray, Error> {
let msgs = rb_self.0.lock().unwrap().recv(wait_ms).map_err(to_rb)?;
let arr = ruby.ary_new_capa(msgs.len());
for m in msgs {
let h = ruby.hash_new();
h.aset(ruby.str_new("plaintext"), ruby.str_new(&m.plaintext))?;
arr.push(h)?;
}
Ok(arr)
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
#[magnus::init]
fn init(ruby: &Ruby) -> Result<(), Error> {
let module = ruby.define_module("QuicNProtoChat")?;
let cls = module.define_class("Client", ruby.class_object())?;
// QuicNProtoChat::Client.new(server, ca_cert, server_name, state_path,
// access_token, state_password=nil, device_id=nil)
cls.define_singleton_method("new", method!(RbClient::new, 7))?;
cls.define_method("whoami", method!(RbClient::whoami, 0))?;
cls.define_method("health", method!(RbClient::health, 0))?;
cls.define_method("register_user", method!(RbClient::register_user, 2))?;
cls.define_method("login", method!(RbClient::login, 2))?;
cls.define_method("register_state", method!(RbClient::register_state, 0))?;
cls.define_method("check_key", method!(RbClient::check_key, 1))?;
cls.define_method("create_group", method!(RbClient::create_group, 1))?;
cls.define_method("invite", method!(RbClient::invite, 1))?;
cls.define_method("join", method!(RbClient::join, 0))?;
cls.define_method("send_message", method!(RbClient::send_message, 2))?;
cls.define_method("recv", method!(RbClient::recv, 1))?;
Ok(())
}