# frozen_string_literal: true require "json" module QuicProChat # High-level quicprochat client for Ruby. # # Wraps +libquicprochat_ffi+ via the +ffi+ gem. # # client = QuicProChat::Client.new("127.0.0.1:5001", ca_cert: "ca.pem") # client.login("alice", "secret") # client.send("bob", "hello") # messages = client.receive(timeout_ms: 5000) # client.disconnect # # Or use the block form for automatic cleanup: # # QuicProChat::Client.open("127.0.0.1:5001", ca_cert: "ca.pem") do |c| # c.login("alice", "secret") # c.send("bob", "hello") # end class Client # Connect to a quicprochat server. # # @param server [String] Server address as +host:port+. # @param ca_cert [String] Path to PEM-encoded CA certificate. # @param server_name [String] TLS SNI server name (defaults to host). # @raise [ConnectionError] if the connection fails. def initialize(server, ca_cert:, server_name: nil) sn = server_name || server.split(":").first @handle = FFIBindings.qpq_connect(server, ca_cert, sn) raise ConnectionError, "qpq_connect failed for #{server}" if @handle.null? end # Connect and yield the client, disconnecting when the block returns. def self.open(server, **opts) client = new(server, **opts) begin yield client ensure client.disconnect end end # Authenticate with OPAQUE credentials. # # @param username [String] # @param password [String] # @raise [AuthError] on bad credentials. def login(username, password) check_connected! code = FFIBindings.qpq_login(@handle, username, password) check_status!(code) end # Send a message to a recipient by username. # # @param recipient [String] Recipient username. # @param message [String] Message payload (UTF-8 string or binary). def send(recipient, message) check_connected! msg_bytes = message.encode("BINARY") buf = FFI::MemoryPointer.new(:uint8, msg_bytes.bytesize) buf.put_bytes(0, msg_bytes) code = FFIBindings.qpq_send(@handle, recipient, buf, msg_bytes.bytesize) check_status!(code) end # Receive pending messages, blocking up to +timeout_ms+ milliseconds. # # @param timeout_ms [Integer] Timeout in milliseconds (default 5000). # @return [Array] Message strings (UTF-8). def receive(timeout_ms: 5000) check_connected! out_ptr = FFI::MemoryPointer.new(:pointer) code = FFIBindings.qpq_receive(@handle, timeout_ms, out_ptr) check_status!(code) json_ptr = out_ptr.read_pointer return [] if json_ptr.null? json_str = json_ptr.read_string FFIBindings.qpq_free_string(json_ptr) JSON.parse(json_str) end # Disconnect from the server and release resources. def disconnect return if @handle.nil? || @handle.null? FFIBindings.qpq_disconnect(@handle) @handle = nil end # Whether the client is connected. def connected? !@handle.nil? && !@handle.null? end private def check_connected! raise NotConnectedError, "not connected" unless connected? end def check_status!(code) return if code == FFIBindings::QPQ_OK msg = FFIBindings.qpq_last_error(@handle) || "unknown error" case code when FFIBindings::QPQ_AUTH_FAILED raise AuthError, msg when FFIBindings::QPQ_TIMEOUT raise TimeoutError, msg when FFIBindings::QPQ_NOT_CONNECTED raise NotConnectedError, msg else raise Error, msg end end end end