From 1cb1ffc9960179298e0d3751587ba25b36f43bb2 Mon Sep 17 00:00:00 2001 From: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com> Date: Thu, 29 Jan 2026 01:38:08 +0700 Subject: [PATCH] Nim FFI Wrapper for LibChat (#25) * remove unneeded code * Update comment style * Update Nim side to support safer_ffi * Fix Introduction from impl * Updates --- .gitignore | 3 + conversations/Cargo.toml | 2 +- conversations/src/api.rs | 3 +- conversations/src/inbox/introduction.rs | 7 +- nim-bindings/README.md | 8 ++ nim-bindings/conversations_example.nimble | 21 +++ nim-bindings/examples/pingpong.nim | 22 +++ nim-bindings/src/bindings.nim | 155 ++++++++++++++++++++++ nim-bindings/src/libchat.nim | 88 ++++++++++++ 9 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 nim-bindings/README.md create mode 100644 nim-bindings/conversations_example.nimble create mode 100644 nim-bindings/examples/pingpong.nim create mode 100644 nim-bindings/src/bindings.nim create mode 100644 nim-bindings/src/libchat.nim diff --git a/.gitignore b/.gitignore index 1fca2de..d395ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ target # Compiled binary **/ffi_nim_example +/nim-bindings/examples/pingpong +/nim-bindings/libchat # Temporary data folder tmp + diff --git a/conversations/Cargo.toml b/conversations/Cargo.toml index 2809a58..0fd90ed 100644 --- a/conversations/Cargo.toml +++ b/conversations/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [lib] -crate-type = ["staticlib"] +crate-type = ["staticlib","dylib"] [dependencies] blake2.workspace = true diff --git a/conversations/src/api.rs b/conversations/src/api.rs index 399c45d..9fd7c09 100644 --- a/conversations/src/api.rs +++ b/conversations/src/api.rs @@ -71,8 +71,7 @@ pub fn create_new_private_convo( content: c_slice::Ref<'_, u8>, ) -> NewConvoResult { // Convert input bundle to Introduction - let s = String::from_utf8_lossy(&bundle).to_string(); - let Ok(intro) = Introduction::try_from(s) else { + let Ok(intro) = Introduction::try_from(bundle.as_slice()) else { return NewConvoResult { error_code: ErrorCode::BadIntro as i32, convo_id: 0, diff --git a/conversations/src/inbox/introduction.rs b/conversations/src/inbox/introduction.rs index 2966293..0955e11 100644 --- a/conversations/src/inbox/introduction.rs +++ b/conversations/src/inbox/introduction.rs @@ -31,12 +31,11 @@ impl Into> for Introduction { } } -impl TryFrom> for Introduction { +impl TryFrom<&[u8]> for Introduction { type Error = ChatError; - fn try_from(value: Vec) -> Result { - let str_value = - String::from_utf8(value).map_err(|_| ChatError::BadParsing("Introduction"))?; + fn try_from(value: &[u8]) -> Result { + let str_value = String::from_utf8_lossy(value); let parts: Vec<&str> = str_value.splitn(3, ':').collect(); if parts[0] != "Bundle" { diff --git a/nim-bindings/README.md b/nim-bindings/README.md new file mode 100644 index 0000000..b97671b --- /dev/null +++ b/nim-bindings/README.md @@ -0,0 +1,8 @@ +# Nim-bindings + +A Nim wrapping class that exposes LibChat functionality. + + +## Getting Started + +`nimble pingpong` - Run the pingpong example. \ No newline at end of file diff --git a/nim-bindings/conversations_example.nimble b/nim-bindings/conversations_example.nimble new file mode 100644 index 0000000..3670d1a --- /dev/null +++ b/nim-bindings/conversations_example.nimble @@ -0,0 +1,21 @@ +# Package + +version = "0.1.0" +author = "libchat" +description = "Nim Bindings for LibChat" +license = "MIT" +srcDir = "src" +bin = @["libchat"] + + +# Dependencies + +requires "nim >= 2.2.4" +requires "results" + +# Build Rust library before compiling Nim +before build: + exec "cargo build --release --manifest-path ../Cargo.toml" + +task pingpong, "Run pingpong example": + exec "nim c -r --path:src examples/pingpong.nim" \ No newline at end of file diff --git a/nim-bindings/examples/pingpong.nim b/nim-bindings/examples/pingpong.nim new file mode 100644 index 0000000..72b43ce --- /dev/null +++ b/nim-bindings/examples/pingpong.nim @@ -0,0 +1,22 @@ +import results + +import ../src/libchat + +proc pingpong() = + + var raya = newConversationsContext() + var saro = newConversationsContext() + + + # Perform out of band Introduction + let intro = raya.createIntroductionBundle().expect("[Raya] Couldn't create intro bundle") + echo "Raya's Intro Bundle: ",intro + + var (convo_sr, payload) = saro.createNewPrivateConvo(intro,"Hey Raya").expect("[Saro] Couldn't create convo") + echo "ConvoHandle:: ", convo_sr + echo "Payload:: ", payload + + + +when isMainModule: + pingpong() diff --git a/nim-bindings/src/bindings.nim b/nim-bindings/src/bindings.nim new file mode 100644 index 0000000..0b3fd26 --- /dev/null +++ b/nim-bindings/src/bindings.nim @@ -0,0 +1,155 @@ +# Nim FFI bindings for libchat conversations library + +import std/[os] + +# Dynamic library path resolution +# Can be overridden at compile time with -d:CONVERSATIONS_LIB:"path/to/lib" +# Or at runtime via LIBCHAT_LIB environment variable +when defined(macosx): + const DEFAULT_LIB_NAME = "liblogos_chat.dylib" +elif defined(linux): + const DEFAULT_LIB_NAME = "liblogos_chat.so" +elif defined(windows): + const DEFAULT_LIB_NAME = "logos_chat.dll" +else: + const DEFAULT_LIB_NAME = "logos_chat" + +# Try to find the library relative to the source file location at compile time +const + thisDir = currentSourcePath().parentDir() + projectRoot = thisDir.parentDir().parentDir() + releaseLibPath = projectRoot / "target" / "release" / DEFAULT_LIB_NAME + debugLibPath = projectRoot / "target" / "debug" / DEFAULT_LIB_NAME + +# Default to release path, can be overridden with -d:CONVERSATIONS_LIB:"..." +const CONVERSATIONS_LIB* {.strdefine.} = releaseLibPath + +# Error codes (must match Rust ErrorCode enum) +const + ErrNone* = 0'i32 + ErrBadPtr* = -1'i32 + ErrBadConvoId* = -2'i32 + ErrBadIntro* = -3'i32 + ErrNotImplemented* = -4'i32 + ErrBufferExceeded* = -5'i32 + ErrUnknownError* = -6'i32 + +# Opaque handle type for Context +type ContextHandle* = pointer +type ConvoHandle* = uint32 + +type + ## Slice for passing byte arrays to safer_ffi functions + SliceUint8* = object + `ptr`*: ptr uint8 + len*: csize_t + + ## Vector type returned by safer_ffi functions (must be freed) + VecUint8* = object + `ptr`*: ptr uint8 + len*: csize_t + cap*: csize_t + + ## repr_c::String type from safer_ffi + ReprCString* = object + `ptr`*: ptr char + len*: csize_t + cap*: csize_t + + ## Payload structure for FFI (matches Rust Payload struct) + Payload* = object + address*: ReprCString + data*: VecUint8 + + ## Vector of Payloads returned by safer_ffi functions + VecPayload* = object + `ptr`*: ptr Payload + len*: csize_t + cap*: csize_t + + ## Result structure for create_intro_bundle + ## error_code is 0 on success, negative on error (see ErrorCode) + PayloadResult* = object + error_code*: int32 + payloads*: VecPayload + + ## Result from create_new_private_convo + ## error_code is 0 on success, negative on error (see ErrorCode) + NewConvoResult* = object + error_code*: int32 + convo_id*: uint32 + payloads*: VecPayload + +# FFI function imports + +## Creates a new libchat Context +## Returns: Opaque handle to the context. Must be freed with destroy_context() +proc create_context*(): ContextHandle {.importc, dynlib: CONVERSATIONS_LIB.} + +## Destroys a context and frees its memory +## - handle must be a valid pointer from create_context() +## - handle must not be used after this call +proc destroy_context*(ctx: ContextHandle) {.importc, dynlib: CONVERSATIONS_LIB.} + +## Creates an intro bundle for sharing with other users +## Returns: Number of bytes written to bundle_out, or negative error code +proc create_intro_bundle*( + ctx: ContextHandle, + bundle_out: SliceUint8, +): int32 {.importc, dynlib: CONVERSATIONS_LIB.} + +## Creates a new private conversation +## Returns: NewConvoResult struct - check error_code field (0 = success, negative = error) +## The result must be freed with destroy_convo_result() +proc create_new_private_convo*( + ctx: ContextHandle, + bundle: SliceUint8, + content: SliceUint8, +): NewConvoResult {.importc, dynlib: CONVERSATIONS_LIB.} + +## Free the result from create_new_private_convo +proc destroy_convo_result*(result: NewConvoResult) {.importc, dynlib: CONVERSATIONS_LIB.} + +## Free the PayloadResult +proc destroy_payload_result*(result: PayloadResult) {.importc, dynlib: CONVERSATIONS_LIB.} + +# ============================================================================ +# Helper functions +# ============================================================================ + +## Create a SliceRefUint8 from a string +proc toSlice*(s: string): SliceUint8 = + if s.len == 0: + SliceUint8(`ptr`: nil, len: 0) + else: + SliceUint8(`ptr`: cast[ptr uint8](unsafeAddr s[0]), len: csize_t(s.len)) + +## Create a SliceRefUint8 from a seq[byte] +proc toSlice*(s: seq[byte]): SliceUint8 = + if s.len == 0: + SliceUint8(`ptr`: nil, len: 0) + else: + SliceUint8(`ptr`: cast[ptr uint8](unsafeAddr s[0]), len: csize_t(s.len)) + +## Convert a ReprCString to a Nim string +proc `$`*(s: ReprCString): string = + if s.ptr == nil or s.len == 0: + return "" + result = newString(s.len) + copyMem(addr result[0], s.ptr, s.len) + +## Convert a VecUint8 to a seq[byte] +proc toSeq*(v: VecUint8): seq[byte] = + if v.ptr == nil or v.len == 0: + return @[] + result = newSeq[byte](v.len) + copyMem(addr result[0], v.ptr, v.len) + +## Access payloads from VecPayload +proc `[]`*(v: VecPayload, i: int): Payload = + assert i >= 0 and csize_t(i) < v.len + cast[ptr UncheckedArray[Payload]](v.ptr)[i] + +## Get length of VecPayload +proc len*(v: VecPayload): int = + int(v.len) diff --git a/nim-bindings/src/libchat.nim b/nim-bindings/src/libchat.nim new file mode 100644 index 0000000..c0f3f6a --- /dev/null +++ b/nim-bindings/src/libchat.nim @@ -0,0 +1,88 @@ +import results +import bindings + +type + LibChat* = object + handle: ContextHandle + buffer_size: int + + PayloadResult* = object + address*: string + data*: seq[uint8] + +## Create a new conversations context +proc newConversationsContext*(): LibChat = + + result.handle = create_context() + result.buffer_size = 256 + if result.handle.isNil: + raise newException(IOError, "Failed to create context") + +## Destroy the context and free resources +proc destroy*(ctx: var LibChat) = + + if not ctx.handle.isNil: + destroy_context(ctx.handle) + ctx.handle = nil + +## Helper proc to create buffer of sufficient size +proc getBuffer*(ctx: LibChat): seq[byte] = + newSeq[byte](ctx.buffer_size) + +## Generate a Introduction Bundle +proc createIntroductionBundle*(ctx: LibChat): Result[string, string] = + if ctx.handle == nil: + return err("Context handle is nil") + + var buffer = ctx.getBuffer() + var slice = buffer.toSlice() + let len = create_intro_bundle(ctx.handle, slice) + + if len < 0: + return err("Failed to create intro bundle: " & $len) + + buffer.setLen(len) + return ok(cast[string](buffer)) + +## Create a Private Convo +proc createNewPrivateConvo*(ctx: LibChat, bundle: string, content: string): Result[(ConvoHandle, seq[PayloadResult]), string] = + if ctx.handle == nil: + return err("Context handle is nil") + + if bundle.len == 0: + return err("bundle is zero length") + if content.len == 0: + return err("content is zero length") + + let res = bindings.create_new_private_convo( + ctx.handle, + bundle.toSlice(), + content.toSlice() + ) + + if res.error_code != 0: + result = err("Failed to create private convo: " & $res.error_code) + destroy_convo_result(res) + return + + # Convert payloads to Nim types + var payloads = newSeq[PayloadResult](res.payloads.len) + for i in 0 ..< res.payloads.len: + let p = res.payloads[int(i)] + payloads[int(i)] = PayloadResult( + address: $p.address, + data: p.data.toSeq() + ) + + let convoId = res.convo_id + + # Free the result + destroy_convo_result(res) + + return ok((convoId, payloads)) + + +proc `=destroy`(x: var LibChat) = + # Automatically free handle when the destructor is called + if x.handle != nil: + x.destroy()