GURT protocol (lib, cli, gdextension, Flumi integration)

This commit is contained in:
Face
2025-08-14 20:29:19 +03:00
parent 65f3a21890
commit c117e602fe
46 changed files with 6559 additions and 89 deletions

25
protocol/gdextension/.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Rust
/target/
Cargo.lock
# Build outputs
/bin/
/addon/
# SCons
.sconf_temp/
.sconsign.dblite
config.log
# OS specific
.DS_Store
Thumbs.db
*.tmp
*.temp
# Editor specific
*.swp
*.swo
*~
.vscode/
.idea/

View File

@@ -0,0 +1,37 @@
[package]
name = "gurt-godot"
version = "0.1.0"
edition = "2021"
authors = ["FaceDev"]
license = "MIT"
repository = "https://github.com/outpoot/gurted"
description = "GURT protocol GDExtension for Godot"
[lib]
name = "gurt_godot"
crate-type = ["cdylib"]
[dependencies]
gurt = { path = "../library" }
godot = "0.1"
tokio = { version = "1.0", features = [
"net",
"io-util",
"rt",
"time"
] }
tokio-rustls = "0.26"
rustls-native-certs = "0.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
url = "2.5"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true

View File

@@ -0,0 +1,37 @@
GURT networking extension for Godot.
## Quick Start
1. **Build the extension:**
```bash
./build.sh
```
2. **Install in your Godot project:**
- Copy `addon/gurt-protocol/` to your project's `addons/` folder (e.g. `addons/gurt-protocol`)
- Enable the plugin in `Project Settings > Plugins`
3. **Use in your game:**
```gdscript
var client = GurtProtocolClient.new()
client.create_client(30) # 30s timeout
var response = client.request("gurt://127.0.0.1:4878", {"method": "GET"})
client.disconnect() # cleanup
if response.is_success:
print(response.body) // { "content": ..., "headers": {...}, ... }
else:
print("Error: ", response.status_code, " ", response.status_message)
```
## Build Options
```bash
./build.sh # Release build for current platform
./build.sh -t debug # Debug build
./build.sh -p windows # Build for Windows
./build.sh -p linux # Build for Linux
./build.sh -p macos # Build for macOS
```

View File

@@ -0,0 +1,153 @@
#!/bin/bash
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
TARGET="release"
PLATFORM=""
while [[ $# -gt 0 ]]; do
case $1 in
-t|--target)
TARGET="$2"
shift 2
;;
-p|--platform)
PLATFORM="$2"
shift 2
;;
-h|--help)
echo "GURT Godot Extension Build Script"
echo ""
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " -t, --target TARGET Build target (debug|release) [default: release]"
echo " -p, --platform PLATFORM Target platform (windows|linux|macos|current)"
echo " -h, --help Show this help message"
echo ""
exit 0
;;
*)
print_error "Unknown option: $1"
exit 1
;;
esac
done
if [[ "$TARGET" != "debug" && "$TARGET" != "release" ]]; then
print_error "Invalid target: $TARGET. Must be 'debug' or 'release'"
exit 1
fi
if [[ -z "$PLATFORM" ]]; then
case "$(uname -s)" in
Linux*) PLATFORM="linux";;
Darwin*) PLATFORM="macos";;
CYGWIN*|MINGW*|MSYS*) PLATFORM="windows";;
*) PLATFORM="current";;
esac
fi
print_info "GURT Godot Extension Build Script"
print_info "Target: $TARGET"
print_info "Platform: $PLATFORM"
print_info "Checking prerequisites..."
if ! command -v cargo >/dev/null 2>&1; then
print_error "Rust/Cargo not found. Please install Rust: https://rustup.rs/"
exit 1
fi
print_success "Prerequisites found"
case $PLATFORM in
windows)
RUST_TARGET="x86_64-pc-windows-msvc"
LIB_NAME="gurt_godot.dll"
;;
linux)
RUST_TARGET="x86_64-unknown-linux-gnu"
LIB_NAME="libgurt_godot.so"
;;
macos)
RUST_TARGET="x86_64-apple-darwin"
LIB_NAME="libgurt_godot.dylib"
;;
current)
RUST_TARGET=""
case "$(uname -s)" in
Linux*) LIB_NAME="libgurt_godot.so";;
Darwin*) LIB_NAME="libgurt_godot.dylib";;
CYGWIN*|MINGW*|MSYS*) LIB_NAME="gurt_godot.dll";;
*) print_error "Unsupported platform"; exit 1;;
esac
;;
*)
print_error "Unknown platform: $PLATFORM"
exit 1
;;
esac
# Create addon directory structure
ADDON_DIR="addon/gurt-protocol"
OUTPUT_DIR="$ADDON_DIR/bin/$PLATFORM"
mkdir -p "$OUTPUT_DIR"
BUILD_CMD="cargo build"
if [[ "$TARGET" == "release" ]]; then
BUILD_CMD="$BUILD_CMD --release"
fi
if [[ -n "$RUST_TARGET" ]]; then
print_info "Installing Rust target: $RUST_TARGET"
rustup target add "$RUST_TARGET"
BUILD_CMD="$BUILD_CMD --target $RUST_TARGET"
fi
print_info "Building with Cargo..."
$BUILD_CMD
if [[ -n "$RUST_TARGET" ]]; then
if [[ "$TARGET" == "release" ]]; then
BUILT_LIB="target/$RUST_TARGET/release/$LIB_NAME"
else
BUILT_LIB="target/$RUST_TARGET/debug/$LIB_NAME"
fi
else
if [[ "$TARGET" == "release" ]]; then
BUILT_LIB="target/release/$LIB_NAME"
else
BUILT_LIB="target/debug/$LIB_NAME"
fi
fi
if [[ -f "$BUILT_LIB" ]]; then
cp "$BUILT_LIB" "$OUTPUT_DIR/$LIB_NAME"
# Copy addon files
cp gurt_godot.gdextension "$ADDON_DIR/"
cp plugin.cfg "$ADDON_DIR/"
cp plugin.gd "$ADDON_DIR/"
print_success "Build completed: $OUTPUT_DIR/$LIB_NAME"
SIZE=$(du -h "$OUTPUT_DIR/$LIB_NAME" | cut -f1)
print_info "Library size: $SIZE"
else
print_error "Built library not found at: $BUILT_LIB"
exit 1
fi
print_success "Build process completed!"
print_info "Copy the 'addon/gurt-protocol' folder to your project's 'addons/' directory"

View File

@@ -0,0 +1,13 @@
[configuration]
entry_symbol = "gdext_rust_init"
compatibility_minimum = 4.1
[libraries]
macos.debug = "res://addons/gurt-protocol/bin/macos/libgurt_godot.dylib"
macos.release = "res://addons/gurt-protocol/bin/macos/libgurt_godot.dylib"
windows.debug.x86_64 = "res://addons/gurt-protocol/bin/windows/gurt_godot.dll"
windows.release.x86_64 = "res://addons/gurt-protocol/bin/windows/gurt_godot.dll"
linux.debug.x86_64 = "res://addons/gurt-protocol/bin/linux/libgurt_godot.so"
linux.release.x86_64 = "res://addons/gurt-protocol/bin/linux/libgurt_godot.so"

View File

@@ -0,0 +1,7 @@
[plugin]
name="GURT Protocol"
description="HTTP-like networking extension for Godot games using the GURT protocol"
author="FaceDev"
version="0.1.0"
script="plugin.gd"

View File

@@ -0,0 +1,8 @@
@tool
extends EditorPlugin
func _enter_tree():
print("GURT Protocol plugin enabled")
func _exit_tree():
print("GURT Protocol plugin disabled")

View File

@@ -0,0 +1,375 @@
use godot::prelude::*;
use gurt::prelude::*;
use gurt::{GurtMethod, GurtRequest};
use tokio::runtime::Runtime;
use std::sync::Arc;
use std::cell::RefCell;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
struct GurtGodotExtension;
#[gdextension]
unsafe impl ExtensionLibrary for GurtGodotExtension {}
#[derive(GodotClass)]
#[class(init)]
struct GurtProtocolClient {
base: Base<RefCounted>,
client: Arc<RefCell<Option<GurtClient>>>,
runtime: Arc<RefCell<Option<Runtime>>>,
}
#[derive(GodotClass)]
#[class(init)]
struct GurtGDResponse {
base: Base<RefCounted>,
#[var]
status_code: i32,
#[var]
status_message: GString,
#[var]
headers: Dictionary,
#[var]
is_success: bool,
#[var]
body: PackedByteArray, // Raw bytes
#[var]
text: GString, // Decoded text
}
#[godot_api]
impl GurtGDResponse {
#[func]
fn get_header(&self, key: GString) -> GString {
self.headers.get(key).map_or(GString::new(), |v| v.to::<GString>())
}
#[func]
fn is_binary(&self) -> bool {
let content_type = self.get_header("content-type".into()).to_string();
content_type.starts_with("image/") ||
content_type.starts_with("application/octet-stream") ||
content_type.starts_with("video/") ||
content_type.starts_with("audio/")
}
#[func]
fn is_text(&self) -> bool {
let content_type = self.get_header("content-type".into()).to_string();
content_type.starts_with("text/") ||
content_type.starts_with("application/json") ||
content_type.starts_with("application/xml") ||
content_type.is_empty()
}
#[func]
fn debug_info(&self) -> GString {
let content_length = self.get_header("content-length".into()).to_string();
let actual_size = self.body.len();
let content_type = self.get_header("content-type".into()).to_string();
let size_match = content_length.parse::<usize>().unwrap_or(0) == actual_size;
format!(
"Status: {} | Type: {} | Length: {} | Actual: {} | Match: {}",
self.status_code,
content_type,
content_length,
actual_size,
size_match
).into()
}
}
#[derive(GodotClass)]
#[class(init)]
struct GurtProtocolServer {
base: Base<RefCounted>,
}
#[godot_api]
impl GurtProtocolClient {
#[signal]
fn request_completed(response: Gd<GurtGDResponse>);
#[func]
fn create_client(&mut self, timeout_seconds: i32) -> bool {
let runtime = match Runtime::new() {
Ok(rt) => rt,
Err(e) => {
godot_print!("Failed to create runtime: {}", e);
return false;
}
};
let mut config = ClientConfig::default();
config.request_timeout = tokio::time::Duration::from_secs(timeout_seconds as u64);
let client = GurtClient::with_config(config);
*self.runtime.borrow_mut() = Some(runtime);
*self.client.borrow_mut() = Some(client);
true
}
#[func]
fn request(&self, url: GString, options: Dictionary) -> Option<Gd<GurtGDResponse>> {
let runtime_binding = self.runtime.borrow();
let runtime = match runtime_binding.as_ref() {
Some(rt) => rt,
None => {
godot_print!("No runtime available");
return None;
}
};
let url_str = url.to_string();
// Parse URL to get host and port
let parsed_url = match url::Url::parse(&url_str) {
Ok(u) => u,
Err(e) => {
godot_print!("Invalid URL: {}", e);
return None;
}
};
let host = match parsed_url.host_str() {
Some(h) => h,
None => {
godot_print!("URL must have a host");
return None;
}
};
let port = parsed_url.port().unwrap_or(4878);
let path = if parsed_url.path().is_empty() { "/" } else { parsed_url.path() };
let method_str = options.get("method").unwrap_or("GET".to_variant()).to::<String>();
let method = match method_str.to_uppercase().as_str() {
"GET" => GurtMethod::GET,
"POST" => GurtMethod::POST,
"PUT" => GurtMethod::PUT,
"DELETE" => GurtMethod::DELETE,
"PATCH" => GurtMethod::PATCH,
"HEAD" => GurtMethod::HEAD,
"OPTIONS" => GurtMethod::OPTIONS,
_ => {
godot_print!("Unsupported HTTP method: {}", method_str);
GurtMethod::GET
}
};
let response = match runtime.block_on(self.gurt_request_with_handshake(host, port, method, path)) {
Ok(resp) => resp,
Err(e) => {
godot_print!("GURT request failed: {}", e);
return None;
}
};
Some(self.convert_response(response))
}
async fn gurt_request_with_handshake(&self, host: &str, port: u16, method: GurtMethod, path: &str) -> gurt::Result<GurtResponse> {
let addr = format!("{}:{}", host, port);
let mut stream = TcpStream::connect(&addr).await?;
let handshake_request = GurtRequest::new(GurtMethod::HANDSHAKE, "/".to_string())
.with_header("Host", host)
.with_header("User-Agent", &format!("GURT-Client/{}", gurt::GURT_VERSION));
let handshake_data = handshake_request.to_string();
stream.write_all(handshake_data.as_bytes()).await?;
let mut buffer = Vec::new();
let mut temp_buffer = [0u8; 8192];
loop {
let bytes_read = stream.read(&mut temp_buffer).await?;
if bytes_read == 0 {
break;
}
buffer.extend_from_slice(&temp_buffer[..bytes_read]);
let separator = b"\r\n\r\n";
if buffer.windows(separator.len()).any(|w| w == separator) {
break;
}
}
let handshake_response = GurtResponse::parse_bytes(&buffer)?;
if handshake_response.status_code != 101 {
return Err(GurtError::handshake(format!("Handshake failed: {} {}",
handshake_response.status_code,
handshake_response.status_message)));
}
let tls_stream = self.create_secure_tls_connection(stream, host).await?;
let (mut reader, mut writer) = tokio::io::split(tls_stream);
let actual_request = GurtRequest::new(method, path.to_string())
.with_header("Host", host)
.with_header("User-Agent", &format!("GURT-Client/{}", gurt::GURT_VERSION))
.with_header("Accept", "*/*");
let request_data = actual_request.to_string();
writer.write_all(request_data.as_bytes()).await?;
let mut response_buffer = Vec::new();
let mut temp_buf = [0u8; 8192];
let mut headers_complete = false;
while !headers_complete {
let bytes_read = reader.read(&mut temp_buf).await?;
if bytes_read == 0 {
break;
}
response_buffer.extend_from_slice(&temp_buf[..bytes_read]);
let separator = b"\r\n\r\n";
if response_buffer.windows(separator.len()).any(|w| w == separator) {
headers_complete = true;
}
}
let response = GurtResponse::parse_bytes(&response_buffer)?;
let content_length = response.header("content-length")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let separator_pos = response_buffer.windows(4).position(|w| w == b"\r\n\r\n").unwrap_or(0) + 4;
let current_body_len = response_buffer.len().saturating_sub(separator_pos);
if content_length > current_body_len {
let remaining = content_length - current_body_len;
let mut remaining_buffer = vec![0u8; remaining];
match reader.read_exact(&mut remaining_buffer).await {
Ok(_) => {
response_buffer.extend_from_slice(&remaining_buffer);
}
Err(e) => {
godot_error!("Failed to read remaining {} bytes: {}", remaining, e);
// Don't fail completely, try to parse what we have
}
}
}
drop(reader);
drop(writer);
let final_response = GurtResponse::parse_bytes(&response_buffer)?;
Ok(final_response)
}
async fn create_secure_tls_connection(&self, stream: tokio::net::TcpStream, host: &str) -> gurt::Result<tokio_rustls::client::TlsStream<tokio::net::TcpStream>> {
use tokio_rustls::rustls::{ClientConfig, RootCertStore};
use std::sync::Arc;
let mut root_store = RootCertStore::empty();
let cert_result = rustls_native_certs::load_native_certs();
let mut system_cert_count = 0;
for cert in cert_result.certs {
if root_store.add(cert).is_ok() {
system_cert_count += 1;
}
}
if system_cert_count <= 0 {
godot_error!("No system certificates found. TLS connections will fail.");
}
let mut client_config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
client_config.alpn_protocols = vec![gurt::crypto::GURT_ALPN.to_vec()];
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
let server_name = match host {
"127.0.0.1" => "localhost",
"localhost" => "localhost",
_ => host
};
let domain = tokio_rustls::rustls::pki_types::ServerName::try_from(server_name.to_string())
.map_err(|e| GurtError::connection(format!("Invalid server name '{}': {}", server_name, e)))?;
match connector.connect(domain, stream).await {
Ok(tls_stream) => {
Ok(tls_stream)
}
Err(e) => {
godot_error!("TLS handshake failed: {}", e);
Err(GurtError::connection(format!("TLS handshake failed: {}", e)))
}
}
}
#[func]
fn disconnect(&mut self) {
*self.client.borrow_mut() = None;
*self.runtime.borrow_mut() = None;
}
#[func]
fn is_connected(&self) -> bool {
self.client.borrow().is_some()
}
#[func]
fn get_version(&self) -> GString {
gurt::GURT_VERSION.to_string().into()
}
#[func]
fn get_default_port(&self) -> i32 {
gurt::DEFAULT_PORT as i32
}
fn convert_response(&self, response: GurtResponse) -> Gd<GurtGDResponse> {
let mut gd_response = GurtGDResponse::new_gd();
gd_response.bind_mut().status_code = response.status_code as i32;
gd_response.bind_mut().status_message = response.status_message.clone().into();
gd_response.bind_mut().is_success = response.is_success();
let mut headers = Dictionary::new();
for (key, value) in &response.headers {
headers.set(key.clone(), value.clone());
}
gd_response.bind_mut().headers = headers;
let mut body = PackedByteArray::new();
body.resize(response.body.len());
for (i, byte) in response.body.iter().enumerate() {
body[i] = *byte;
}
gd_response.bind_mut().body = body;
match std::str::from_utf8(&response.body) {
Ok(text_str) => {
gd_response.bind_mut().text = text_str.into();
}
Err(_) => {
let content_type = response.headers.get("content-type").cloned().unwrap_or_default();
let size = response.body.len();
gd_response.bind_mut().text = format!("[Binary data: {} ({} bytes)]", content_type, size).into();
}
}
gd_response
}
}