INITIAL COMMIT

main
Regela 1 day ago
commit 9e2fdd4c5b

1
.gitignore vendored

@ -0,0 +1 @@
more_modules

@ -0,0 +1,35 @@
-- Chat module for ARZ Web Helper
local M = {}
function M.init(fw)
-- Register static files
local static_dir = fw.modules_dir .. "/chat/static"
fw.register_module("chat", static_dir)
fw.log("INFO", "CHAT", "Module registered, static: " .. static_dir)
-- Register API handler
fw.on_api("chat", "send", function(body)
local ok, data = pcall(fw.json_decode, body)
if not ok or not data or not data.text then
return '{"ok":false,"error":"invalid body"}'
end
local text = data.text
if #text == 0 or #text > 144 then
return '{"ok":false,"error":"invalid length"}'
end
local win_text = fw.to_win1251(text)
sampProcessChatInput(win_text)
fw.log("INFO", "CHAT", "Sent: " .. text)
return '{"ok":true}'
end)
-- Intercept: block outgoing messages containing "spam"
fw.on_event("onSendChat", function(message)
if message:lower():find("spam") then
fw.log("WARN", "CHAT", "Blocked outgoing: " .. message)
return false
end
end, 10)
end
return M

@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>SA-MP Chat</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0e1a; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; height: 100vh; height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
.header { background: #111827; padding: 12px 16px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #1f2937; flex-shrink: 0; }
.header a { color: #6b7280; text-decoration: none; font-size: 20px; }
.header h1 { font-size: 16px; font-weight: 600; color: #f3f4f6; }
.status { margin-left: auto; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; transition: background 0.3s; }
.status.on { background: #22c55e; }
.messages { flex: 1; overflow-y: auto; padding: 8px 12px; display: flex; flex-direction: column; gap: 2px; }
.msg { padding: 4px 8px; border-radius: 4px; font-family: 'Consolas', 'SF Mono', monospace; font-size: 13px; line-height: 1.5; word-wrap: break-word; animation: fi 0.15s ease; }
.msg:hover { background: rgba(255,255,255,0.03); }
.msg .t { color: #4b5563; font-size: 11px; margin-right: 6px; user-select: none; }
.msg.me { border-left: 2px solid #3b82f6; background: rgba(59,130,246,0.06); }
@keyframes fi { from { opacity:0; transform:translateY(4px); } to { opacity:1; transform:translateY(0); } }
.input-area { background: #111827; padding: 10px 12px; border-top: 1px solid #1f2937; display: flex; gap: 8px; flex-shrink: 0; }
.input-area input { flex: 1; background: #1f2937; border: 1px solid #374151; border-radius: 8px; padding: 10px 14px; color: #f3f4f6; font-size: 14px; outline: none; }
.input-area input:focus { border-color: #3b82f6; }
.input-area input::placeholder { color: #6b7280; }
.input-area button { background: #3b82f6; color: white; border: none; border-radius: 8px; padding: 10px 20px; font-size: 14px; font-weight: 600; cursor: pointer; }
.input-area button:hover { background: #2563eb; }
.input-area button:active { background: #1d4ed8; }
.messages::-webkit-scrollbar { width: 6px; }
.messages::-webkit-scrollbar-track { background: transparent; }
.messages::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
</style>
</head>
<body>
<div class="header">
<a href="/">&#8592;</a>
<h1>SA-MP Chat</h1>
<div class="status" id="st"></div>
</div>
<div class="messages" id="msgs"></div>
<div class="input-area">
<input type="text" id="inp" placeholder="Type a message..." maxlength="144" autocomplete="off">
<button onclick="send()">Send</button>
</div>
<script>
const msgs = document.getElementById('msgs');
const inp = document.getElementById('inp');
const st = document.getElementById('st');
let ws, atBottom = true;
msgs.addEventListener('scroll', () => {
atBottom = msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 40;
});
function scroll() { if (atBottom) msgs.scrollTop = msgs.scrollHeight; }
function colorToCSS(c) {
let u = c >>> 0, r = (u>>24)&0xFF, g = (u>>16)&0xFF, b = (u>>8)&0xFF, a = u&0xFF;
if (!a) a = 255;
return `rgba(${r},${g},${b},${(a/255).toFixed(2)})`;
}
function parseColors(text) {
text = text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
let r = '', parts = text.split(/(\{[0-9A-Fa-f]{6}\})/), inSpan = false;
for (let p of parts) {
let m = p.match(/^\{([0-9A-Fa-f]{6})\}$/);
if (m) { if (inSpan) r += '</span>'; r += `<span style="color:#${m[1]}">`; inSpan = true; }
else r += p;
}
if (inSpan) r += '</span>';
return r;
}
function addMsg(ev, args) {
if (ev !== 'onServerMessage') return;
const [color, text] = args;
const d = document.createElement('div');
d.className = 'msg';
const now = new Date().toTimeString().slice(0,8);
const css = color ? ` style="color:${colorToCSS(color)}"` : '';
d.innerHTML = `<span class="t">${now}</span><span${css}>${parseColors(text)}</span>`;
msgs.appendChild(d);
while (msgs.children.length > 300) msgs.removeChild(msgs.firstChild);
scroll();
}
function addSent(text) {
const d = document.createElement('div');
d.className = 'msg me';
const now = new Date().toTimeString().slice(0,8);
d.innerHTML = `<span class="t">${now}</span><span style="color:#93c5fd">${text.replace(/</g,'&lt;')}</span>`;
msgs.appendChild(d);
scroll();
}
function send() {
const text = inp.value.trim();
if (!text) return;
fetch('/api/chat/send', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({text}) });
addSent(text);
inp.value = '';
inp.focus();
}
inp.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
function connect() {
ws = new WebSocket('ws://'+location.host+'/ws');
ws.onopen = () => st.className = 'status on';
ws.onclose = () => { st.className = 'status'; setTimeout(connect, 2000); };
ws.onerror = () => ws.close();
ws.onmessage = e => {
try {
const d = JSON.parse(e.data);
if (d.type === 'event') addMsg(d.event, d.args);
} catch(err) {}
};
}
connect();
inp.focus();
</script>
</body>
</html>

@ -0,0 +1,99 @@
-- Lua Console module — execute Lua code in the game's main thread
local M = {}
function M.init(fw)
local static_dir = fw.modules_dir .. "/console/static"
fw.register_module("console", static_dir)
fw.on_api("console", "exec", function(body)
local ok, data = pcall(fw.json_decode, body)
if not ok or not data or not data.code then
return '{"ok":false,"error":"invalid body"}'
end
local code = data.code
if #code == 0 then
return '{"ok":false,"error":"empty code"}'
end
-- Try as expression first (return value), then as statement
local fn, err = loadstring("return " .. code)
if not fn then
fn, err = loadstring(code)
end
if not fn then
return fw.json_encode({ok = false, error = "Compile: " .. tostring(err)})
end
local results = {pcall(fn)}
local success = table.remove(results, 1)
if not success then
return fw.json_encode({ok = false, error = tostring(results[1])})
end
-- Format results
local parts = {}
for i, v in ipairs(results) do
parts[i] = serialize(v, 0)
end
local output = table.concat(parts, ", ")
if #parts == 0 then output = "nil" end
fw.log("INFO", "CONSOLE", "Exec: " .. code:sub(1, 80) .. " => " .. output:sub(1, 80))
return fw.json_encode({ok = true, result = output, count = #parts})
end)
fw.log("INFO", "CONSOLE", "Module loaded")
end
-- Serialize a Lua value into a readable string
function serialize(val, depth)
depth = depth or 0
if depth > 4 then return "..." end
local t = type(val)
if t == "nil" then
return "nil"
elseif t == "string" then
if #val > 200 then
return '"' .. val:sub(1, 200):gsub('[\n\r]', '\\n') .. '..." [' .. #val .. ' chars]'
end
return '"' .. val:gsub('[\n\r]', '\\n'):gsub('"', '\\"') .. '"'
elseif t == "number" or t == "boolean" then
return tostring(val)
elseif t == "table" then
local items = {}
local n = 0
-- Array part
for i, v in ipairs(val) do
if n >= 20 then
items[#items + 1] = "... +" .. (#val - n) .. " more"
break
end
items[#items + 1] = serialize(v, depth + 1)
n = n + 1
end
-- Hash part
for k, v in pairs(val) do
if type(k) ~= "number" or k < 1 or k > #val or k ~= math.floor(k) then
if n >= 20 then
items[#items + 1] = "..."
break
end
local ks = type(k) == "string" and k or "[" .. tostring(k) .. "]"
items[#items + 1] = ks .. " = " .. serialize(v, depth + 1)
n = n + 1
end
end
if #items == 0 then return "{}" end
return "{ " .. table.concat(items, ", ") .. " }"
elseif t == "function" then
return "function: " .. tostring(val)
else
return tostring(val)
end
end
return M

@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Lua Console</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0e1a; color: #e0e0e0; font-family: 'Consolas', 'SF Mono', 'Fira Code', monospace; height: 100vh; height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
.header { background: #111827; padding: 12px 16px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #1f2937; flex-shrink: 0; }
.header a { color: #6b7280; text-decoration: none; font-size: 20px; }
.header h1 { font-size: 16px; font-weight: 600; color: #f3f4f6; font-family: 'Segoe UI', system-ui, sans-serif; }
.output { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 4px; }
.entry { padding: 6px 10px; border-radius: 6px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; animation: fi 0.12s ease; }
@keyframes fi { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; } }
.entry.input { color: #93c5fd; background: rgba(59,130,246,0.06); border-left: 2px solid #3b82f6; }
.entry.input::before { content: " "; color: #3b82f6; }
.entry.result { color: #a5f3c4; background: rgba(34,197,94,0.05); border-left: 2px solid #22c55e; }
.entry.error { color: #fca5a5; background: rgba(239,68,68,0.06); border-left: 2px solid #ef4444; }
.entry.info { color: #6b7280; font-size: 12px; }
.input-area { background: #111827; padding: 10px 12px; border-top: 1px solid #1f2937; flex-shrink: 0; }
.input-row { display: flex; gap: 8px; }
.input-area textarea { flex: 1; background: #1f2937; border: 1px solid #374151; border-radius: 8px; padding: 10px 14px; color: #f3f4f6; font-size: 14px; font-family: inherit; outline: none; resize: none; min-height: 40px; max-height: 120px; line-height: 1.5; }
.input-area textarea:focus { border-color: #3b82f6; }
.input-area textarea::placeholder { color: #6b7280; }
.input-area button { background: #3b82f6; color: white; border: none; border-radius: 8px; padding: 10px 16px; font-size: 14px; font-weight: 600; cursor: pointer; white-space: nowrap; align-self: flex-end; }
.input-area button:hover { background: #2563eb; }
.input-area button:active { background: #1d4ed8; }
.shortcuts { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.shortcuts button { background: #1f2937; border: 1px solid #374151; color: #9ca3af; border-radius: 6px; padding: 4px 10px; font-size: 11px; font-family: inherit; cursor: pointer; }
.shortcuts button:hover { background: #374151; color: #f3f4f6; }
.output::-webkit-scrollbar { width: 6px; }
.output::-webkit-scrollbar-track { background: transparent; }
.output::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
</style>
</head>
<body>
<div class="header">
<a href="/">&#8592;</a>
<h1>Lua Console</h1>
</div>
<div class="output" id="out">
<div class="entry info">Connected to game. Type Lua expressions or statements.</div>
</div>
<div class="input-area">
<div class="input-row">
<textarea id="inp" rows="1" placeholder="Lua code..." autocomplete="off"></textarea>
<button onclick="run()">Run</button>
</div>
<div class="shortcuts" id="shorts"></div>
</div>
<script>
const out = document.getElementById('out');
const inp = document.getElementById('inp');
let history = JSON.parse(localStorage.getItem('lua_history') || '[]');
let histIdx = -1;
const shortcuts = [
'getCharHealth(playerHandle)',
'sampGetPlayerNickname(select(2, sampGetPlayerIdByCharHandle(playerHandle)))',
'getAllVehicles()',
'sampSendChat("/time")',
'memory.hex(getModuleHandle("samp.dll"), 32)',
];
const shortsEl = document.getElementById('shorts');
shortcuts.forEach(s => {
const b = document.createElement('button');
b.textContent = s.length > 30 ? s.slice(0, 28) + '...' : s;
b.title = s;
b.onclick = () => { inp.value = s; run(); };
shortsEl.appendChild(b);
});
function addEntry(cls, text) {
const d = document.createElement('div');
d.className = 'entry ' + cls;
d.textContent = text;
out.appendChild(d);
while (out.children.length > 200) out.removeChild(out.firstChild);
out.scrollTop = out.scrollHeight;
}
async function run() {
const code = inp.value.trim();
if (!code) return;
// Save history
if (history[history.length - 1] !== code) {
history.push(code);
if (history.length > 50) history.shift();
localStorage.setItem('lua_history', JSON.stringify(history));
}
histIdx = -1;
addEntry('input', code);
inp.value = '';
autoResize();
try {
const res = await fetch('/api/console/exec', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code})
});
const data = await res.json();
if (data.ok) {
addEntry('result', data.result);
} else {
addEntry('error', data.error || 'Unknown error');
}
} catch(e) {
addEntry('error', 'Network error: ' + e.message);
}
inp.focus();
}
inp.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
run();
}
// History navigation
if (e.key === 'ArrowUp' && inp.value === '') {
e.preventDefault();
if (histIdx < 0) histIdx = history.length;
histIdx--;
if (histIdx >= 0) inp.value = history[histIdx];
}
if (e.key === 'ArrowDown' && histIdx >= 0) {
e.preventDefault();
histIdx++;
inp.value = histIdx < history.length ? history[histIdx] : '';
if (histIdx >= history.length) histIdx = -1;
}
});
function autoResize() {
inp.style.height = 'auto';
inp.style.height = Math.min(inp.scrollHeight, 120) + 'px';
}
inp.addEventListener('input', autoResize);
inp.focus();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1 @@
/target

1138
rust_core/Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,22 @@
[package]
name = "arz-core"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "net"] }
axum = { version = "0.8", features = ["ws"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
encoding_rs = "0.8"
tower-http = { version = "0.6", features = ["fs"] }
tokio-rusqlite = { version = "0.7", features = ["bundled"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
[profile.release]
lto = true
strip = true
opt-level = "s"

@ -0,0 +1,11 @@
fn main() {
// Auto-generate build timestamp so we don't need $(date) in the shell
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
println!("cargo:rustc-env=ARZ_BUILD_TS={ts}");
// Force rebuild when source changes
println!("cargo:rerun-if-changed=src/");
println!("cargo:rerun-if-changed=static/");
}

@ -0,0 +1,148 @@
//! Bridge between Rust (tokio) and Lua (main game thread).
//!
//! Two communication channels:
//! 1. Events: Lua → Rust (game events like onServerMessage)
//! 2. Requests: Rust → Lua (execute code in game thread, e.g. sampSendChat)
//!
//! Events use a crossbeam channel for minimal latency (no async overhead).
//! For cancelable events, Lua blocks until Rust responds via a condvar.
use std::collections::HashMap;
use std::sync::{
atomic::{AtomicU32, Ordering},
Condvar, Mutex, OnceLock,
};
/// A request from Rust to Lua (e.g., "execute sampSendChat('hello')")
#[derive(Debug, Clone, serde::Serialize)]
pub struct LuaRequest {
pub id: u32,
pub code: String,
}
/// Global state for the bridge
struct BridgeState {
/// Pending Lua execution requests (Rust → Lua)
pending_requests: Mutex<Vec<LuaRequest>>,
/// Results from Lua executions, keyed by request id
results: Mutex<HashMap<u32, String>>,
results_ready: Condvar,
/// Next request id
next_id: AtomicU32,
/// Event broadcast sender (to WS clients via tokio)
event_tx: Mutex<Option<tokio::sync::broadcast::Sender<EventMessage>>>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct EventMessage {
pub event: String,
pub args: String, // JSON string
}
static BRIDGE: OnceLock<BridgeState> = OnceLock::new();
fn state() -> &'static BridgeState {
BRIDGE.get_or_init(|| BridgeState {
pending_requests: Mutex::new(Vec::new()),
results: Mutex::new(HashMap::new()),
results_ready: Condvar::new(),
next_id: AtomicU32::new(1),
event_tx: Mutex::new(None),
})
}
/// Initialize the event broadcast channel. Called once from server::start.
pub fn init_event_channel() -> tokio::sync::broadcast::Receiver<EventMessage> {
let (tx, rx) = tokio::sync::broadcast::channel(256);
*state().event_tx.lock().unwrap() = Some(tx);
rx
}
pub fn subscribe_events() -> Option<tokio::sync::broadcast::Receiver<EventMessage>> {
state()
.event_tx
.lock()
.unwrap()
.as_ref()
.map(|tx| tx.subscribe())
}
/// Push a game event from Lua into Rust.
/// For now, broadcasts to all WS clients and returns None.
/// In the future, cancelable events will return a response.
pub fn push_event(event_name: &str, json_args: &str) -> Option<String> {
let s = state();
if let Some(tx) = s.event_tx.lock().unwrap().as_ref() {
let _ = tx.send(EventMessage {
event: event_name.to_string(),
args: json_args.to_string(),
});
}
None // fire-and-forget for now
}
/// Queue a Lua execution request (called from tokio/axum handlers).
pub fn request_lua_exec(code: String) -> u32 {
let s = state();
let id = s.next_id.fetch_add(1, Ordering::Relaxed);
eprintln!("arz: request_lua_exec id={id} code={}", &code[..code.len().min(80)]);
s.pending_requests.lock().unwrap().push(LuaRequest { id, code });
eprintln!("arz: pending_requests len={}", s.pending_requests.lock().unwrap().len());
id
}
/// Wait for a result of a previously queued request (blocking, with timeout).
pub fn request_lua_exec_sync_wait(id: u32, timeout: std::time::Duration) -> Option<String> {
let s = state();
let mut results = s.results.lock().unwrap();
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(result) = results.remove(&id) {
return Some(result);
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
return None;
}
let (guard, timeout_result) = s.results_ready.wait_timeout(results, remaining).unwrap();
results = guard;
if timeout_result.timed_out() {
return results.remove(&id);
}
}
}
/// Poll for pending requests (called from Lua main loop, must be fast).
pub fn poll_requests() -> Option<String> {
let s = state();
let mut pending = s.pending_requests.lock().unwrap();
if pending.is_empty() {
return None;
}
let requests: Vec<LuaRequest> = pending.drain(..).collect();
eprintln!("arz: poll_requests returning {} requests", requests.len());
Some(serde_json::to_string(&requests).unwrap_or_else(|_| "[]".to_string()))
}
/// Debug: return count of pending requests.
pub fn debug_pending_count() -> usize {
state().pending_requests.lock().unwrap().len()
}
/// Non-blocking check for a result (used by async polling in api_handler).
pub fn try_get_result(id: u32) -> Option<String> {
state().results.lock().unwrap().remove(&id)
}
/// Report result from Lua execution (called from Lua main loop).
pub fn respond(request_id: u32, result: &str) {
let s = state();
s.results
.lock()
.unwrap()
.insert(request_id, result.to_string());
s.results_ready.notify_all();
}

@ -0,0 +1,396 @@
//! Async SQLite key-value store using tokio-rusqlite.
//! Provides both sync wrappers (for backward compat FFI) and async batch API.
use tokio_rusqlite::Connection;
use tokio_rusqlite::rusqlite;
use std::sync::{Mutex, OnceLock};
use std::sync::atomic::{AtomicU32, Ordering};
use std::collections::HashMap;
use serde_json::Value;
use crate::server;
static DB: OnceLock<Connection> = OnceLock::new();
static BATCH_RESULTS: OnceLock<Mutex<HashMap<u32, String>>> = OnceLock::new();
static BATCH_NEXT_ID: AtomicU32 = AtomicU32::new(1);
// ----------------------------------------------------------------
// Init
// ----------------------------------------------------------------
/// Init — opens DB and creates table.
/// Must be called after tokio runtime is available (after rgl_start).
pub fn init(path: &str) {
let handle = server::runtime_handle().expect("tokio runtime not started");
let path = path.to_string();
// Use a oneshot channel to get the Connection back from the tokio task
let (tx, rx) = std::sync::mpsc::sync_channel(1);
handle.spawn(async move {
let conn = Connection::open(&path).await.expect("Failed to open DB");
conn.call(|conn| {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)"
)?;
Ok::<_, rusqlite::Error>(())
}).await.expect("Failed to create kv table");
tx.send(conn).ok();
});
let conn = rx.recv().expect("Failed to receive DB connection");
DB.set(conn).ok();
BATCH_RESULTS.set(Mutex::new(HashMap::new())).ok();
}
// ----------------------------------------------------------------
// Sync helpers (block_in_place + block_on for backward compat FFI)
// ----------------------------------------------------------------
fn with_conn_sync<T: Send + 'static>(
f: impl FnOnce(&mut rusqlite::Connection) -> Result<T, rusqlite::Error> + Send + 'static,
) -> Option<T> {
let conn = DB.get()?;
let handle = server::runtime_handle()?;
let (tx, rx) = std::sync::mpsc::sync_channel(1);
handle.spawn(async move {
let result = conn.call(f).await;
tx.send(result).ok();
});
rx.recv().ok()?.ok()
}
pub fn get(key: &str) -> Option<String> {
let key = key.to_string();
with_conn_sync(move |conn| {
Ok(conn.query_row(
"SELECT value FROM kv WHERE key = ?1", [&key], |row| row.get(0),
).ok())
})?
}
pub fn set(key: &str, value: &str) -> bool {
let key = key.to_string();
let value = value.to_string();
with_conn_sync(move |conn| {
Ok(conn.execute(
"INSERT OR REPLACE INTO kv (key, value) VALUES (?1, ?2)", [&key, &value],
).is_ok())
}).unwrap_or(false)
}
pub fn delete(key: &str) -> bool {
let key = key.to_string();
with_conn_sync(move |conn| {
Ok(conn.execute("DELETE FROM kv WHERE key = ?1", [&key]).is_ok())
}).unwrap_or(false)
}
pub fn list_keys_json(prefix: &str) -> String {
let prefix = prefix.to_string();
with_conn_sync(move |conn| {
let pattern = format!("{prefix}%");
let mut stmt = conn.prepare(
"SELECT key, value FROM kv WHERE key LIKE ?1 ORDER BY key"
)?;
let rows: Vec<(String, String)> = stmt
.query_map([&pattern], |row| Ok((row.get(0)?, row.get(1)?)))?
.filter_map(|r| r.ok())
.collect();
let items: Vec<String> = rows.iter()
.map(|(k, v)| {
let ek = k.replace('\\', "\\\\").replace('"', "\\\"");
let ev = v.replace('\\', "\\\\").replace('"', "\\\"");
format!(r#"{{"key":"{ek}","value":"{ev}"}}"#)
})
.collect();
Ok(format!("[{}]", items.join(",")))
}).unwrap_or_else(|| "[]".to_string())
}
pub fn delete_prefix(prefix: &str) -> usize {
let prefix = prefix.to_string();
with_conn_sync(move |conn| {
let pattern = format!("{prefix}%");
Ok(conn.execute("DELETE FROM kv WHERE key LIKE ?1", [&pattern]).unwrap_or(0))
}).unwrap_or(0)
}
// ----------------------------------------------------------------
// Async batch API
// ----------------------------------------------------------------
/// Submit batch — spawns tokio task via saved runtime handle, returns id instantly.
pub fn submit_batch(ops_json: &str) -> u32 {
let id = BATCH_NEXT_ID.fetch_add(1, Ordering::Relaxed);
let ops = ops_json.to_string();
if let Some(handle) = server::runtime_handle() {
handle.spawn(async move {
let result = execute_batch_async(&ops).await;
if let Some(results) = BATCH_RESULTS.get() {
results.lock().unwrap().insert(id, result);
}
});
}
id
}
/// Poll result — returns Some(json) if ready, None otherwise. Instant.
pub fn poll_result(id: u32) -> Option<String> {
BATCH_RESULTS.get()?.lock().ok()?.remove(&id)
}
/// Execute batch via tokio-rusqlite .call() — one trip to SQLite thread.
async fn execute_batch_async(ops_json: &str) -> String {
let ops: Vec<Value> = match serde_json::from_str(ops_json) {
Ok(v) => v,
Err(_) => return "[]".to_string(),
};
let Some(conn) = DB.get() else { return "[]".to_string() };
conn.call(move |conn| {
Ok::<_, rusqlite::Error>(execute_batch_on_conn(conn, &ops))
}).await.unwrap_or_else(|_| "[]".to_string())
}
/// Core batch execution — runs on the SQLite thread inside .call().
fn execute_batch_on_conn(conn: &mut rusqlite::Connection, ops: &[Value]) -> String {
let has_writes = ops.iter().any(|op|
matches!(op["op"].as_str(), Some("set" | "del" | "del_prefix"))
);
if has_writes {
let _ = conn.execute_batch("BEGIN");
}
let mut results = Vec::with_capacity(ops.len());
for op in ops {
let r = match op["op"].as_str() {
Some("get") => {
let key = op["key"].as_str().unwrap_or("");
let val: Option<String> = conn.query_row(
"SELECT value FROM kv WHERE key = ?1", [key], |row| row.get(0),
).ok();
match val {
Some(v) => serde_json::json!({"v": v}),
None => serde_json::json!({"v": null}),
}
}
Some("set") => {
let key = op["key"].as_str().unwrap_or("");
let value = op["value"].as_str().unwrap_or("");
let ok = conn.execute(
"INSERT OR REPLACE INTO kv (key, value) VALUES (?1, ?2)",
[key, value],
).is_ok();
serde_json::json!({"ok": ok})
}
Some("del") => {
let key = op["key"].as_str().unwrap_or("");
conn.execute("DELETE FROM kv WHERE key = ?1", [key]).ok();
serde_json::json!({"ok": true})
}
Some("get_prefix") => {
let prefix = op["prefix"].as_str().unwrap_or("");
let pattern = format!("{prefix}%");
let mut stmt = match conn.prepare(
"SELECT key, value FROM kv WHERE key LIKE ?1 ORDER BY key"
) {
Ok(s) => s,
Err(_) => {
results.push(serde_json::json!({"items": []}));
continue;
}
};
let items: Vec<Value> = stmt
.query_map([&pattern], |row| {
Ok(serde_json::json!({
"key": row.get::<_, String>(0)?,
"value": row.get::<_, String>(1)?
}))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default();
serde_json::json!({"items": items})
}
Some("del_prefix") => {
let prefix = op["prefix"].as_str().unwrap_or("");
let pattern = format!("{prefix}%");
let count = conn.execute(
"DELETE FROM kv WHERE key LIKE ?1", [&pattern]
).unwrap_or(0);
serde_json::json!({"count": count})
}
_ => serde_json::json!({"error": "unknown op"}),
};
results.push(r);
}
if has_writes {
let _ = conn.execute_batch("COMMIT");
}
serde_json::to_string(&results).unwrap_or_else(|_| "[]".to_string())
}
// ----------------------------------------------------------------
// Tests
// ----------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Helper: execute batch directly on a rusqlite::Connection (for testing)
fn setup_conn() -> rusqlite::Connection {
let mut conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
).unwrap();
conn
}
#[test]
fn test_batch_set_get() {
let mut conn = setup_conn();
let ops = serde_json::json!([
{"op": "set", "key": "a.x", "value": "1"},
{"op": "set", "key": "a.y", "value": "2"},
{"op": "set", "key": "a.z", "value": "3"},
{"op": "get", "key": "a.x"},
{"op": "get", "key": "a.y"},
{"op": "get", "key": "a.z"},
{"op": "get", "key": "a.missing"},
]);
let result_json = execute_batch_on_conn(&mut conn, ops.as_array().unwrap());
let results: Vec<Value> = serde_json::from_str(&result_json).unwrap();
assert_eq!(results.len(), 7);
assert_eq!(results[0]["ok"], true);
assert_eq!(results[1]["ok"], true);
assert_eq!(results[2]["ok"], true);
assert_eq!(results[3]["v"], "1");
assert_eq!(results[4]["v"], "2");
assert_eq!(results[5]["v"], "3");
assert!(results[6]["v"].is_null());
}
#[test]
fn test_batch_get_prefix() {
let mut conn = setup_conn();
// Set some keys
let set_ops = serde_json::json!([
{"op": "set", "key": "mod.h.1.name", "value": "house1"},
{"op": "set", "key": "mod.h.2.name", "value": "house2"},
{"op": "set", "key": "mod.h.1.cool", "value": "50"},
{"op": "set", "key": "mod.other", "value": "x"},
]);
execute_batch_on_conn(&mut conn, set_ops.as_array().unwrap());
// Get prefix
let get_ops = serde_json::json!([
{"op": "get_prefix", "prefix": "mod.h."},
]);
let result_json = execute_batch_on_conn(&mut conn, get_ops.as_array().unwrap());
let results: Vec<Value> = serde_json::from_str(&result_json).unwrap();
let items = results[0]["items"].as_array().unwrap();
assert_eq!(items.len(), 3);
// Should be sorted by key
assert_eq!(items[0]["key"], "mod.h.1.cool");
assert_eq!(items[0]["value"], "50");
assert_eq!(items[1]["key"], "mod.h.1.name");
assert_eq!(items[2]["key"], "mod.h.2.name");
}
#[test]
fn test_batch_del_and_del_prefix() {
let mut conn = setup_conn();
let ops = serde_json::json!([
{"op": "set", "key": "x.a", "value": "1"},
{"op": "set", "key": "x.b", "value": "2"},
{"op": "set", "key": "x.c", "value": "3"},
{"op": "set", "key": "y.a", "value": "4"},
]);
execute_batch_on_conn(&mut conn, ops.as_array().unwrap());
// Delete single + prefix
let del_ops = serde_json::json!([
{"op": "del", "key": "y.a"},
{"op": "del_prefix", "prefix": "x."},
]);
let result_json = execute_batch_on_conn(&mut conn, del_ops.as_array().unwrap());
let results: Vec<Value> = serde_json::from_str(&result_json).unwrap();
assert_eq!(results[0]["ok"], true);
assert_eq!(results[1]["count"], 3);
// Verify all gone
let check = serde_json::json!([
{"op": "get", "key": "x.a"},
{"op": "get", "key": "y.a"},
]);
let result_json = execute_batch_on_conn(&mut conn, check.as_array().unwrap());
let results: Vec<Value> = serde_json::from_str(&result_json).unwrap();
assert!(results[0]["v"].is_null());
assert!(results[1]["v"].is_null());
}
#[test]
fn test_batch_mixed() {
let mut conn = setup_conn();
let ops = serde_json::json!([
{"op": "set", "key": "btc.s.delay", "value": "5"},
{"op": "set", "key": "btc.h.100.listed", "value": "1"},
{"op": "set", "key": "btc.h.100.cool", "value": "80"},
{"op": "set", "key": "btc.h.200.listed", "value": "0"},
{"op": "get_prefix", "prefix": "btc.h."},
{"op": "get", "key": "btc.s.delay"},
{"op": "del", "key": "btc.h.200.listed"},
{"op": "get", "key": "btc.h.200.listed"},
]);
let result_json = execute_batch_on_conn(&mut conn, ops.as_array().unwrap());
let results: Vec<Value> = serde_json::from_str(&result_json).unwrap();
assert_eq!(results.len(), 8);
// get_prefix should return 3 items (h.100.listed, h.100.cool, h.200.listed)
assert_eq!(results[4]["items"].as_array().unwrap().len(), 3);
// get delay
assert_eq!(results[5]["v"], "5");
// after del, get should be null
assert!(results[7]["v"].is_null());
}
#[test]
fn test_batch_empty() {
let mut conn = setup_conn();
let result = execute_batch_on_conn(&mut conn, &[]);
assert_eq!(result, "[]");
}
#[test]
fn test_batch_unknown_op() {
let mut conn = setup_conn();
let ops = serde_json::json!([{"op": "nope"}]);
let result_json = execute_batch_on_conn(&mut conn, ops.as_array().unwrap());
let results: Vec<Value> = serde_json::from_str(&result_json).unwrap();
assert!(results[0]["error"].is_string());
}
#[tokio::test]
async fn test_async_batch() {
// Init with in-memory db
let conn = Connection::open_in_memory().await.unwrap();
conn.call(|c| {
c.execute_batch("CREATE TABLE kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)")?;
Ok::<_, rusqlite::Error>(())
}).await.unwrap();
DB.set(conn).ok();
BATCH_RESULTS.set(Mutex::new(HashMap::new())).ok();
let ops = r#"[{"op":"set","key":"t.a","value":"hello"},{"op":"get","key":"t.a"}]"#;
let result = execute_batch_async(ops).await;
let results: Vec<Value> = serde_json::from_str(&result).unwrap();
assert_eq!(results[0]["ok"], true);
assert_eq!(results[1]["v"], "hello");
}
}

@ -0,0 +1,76 @@
//! Event processing — converts raw SA-MP events into structured data
//! and handles Win-1251 → UTF-8 conversion.
use encoding_rs::WINDOWS_1251;
/// Convert Win-1251 bytes to UTF-8 string.
pub fn win1251_to_utf8(bytes: &[u8]) -> String {
let (cow, _, _) = WINDOWS_1251.decode(bytes);
cow.into_owned()
}
/// Convert UTF-8 string to Win-1251 bytes.
pub fn utf8_to_win1251(text: &str) -> Vec<u8> {
let (cow, _, _) = WINDOWS_1251.encode(text);
cow.into_owned()
}
/// Parse SA-MP color integer (RRGGBBAA as i32) to CSS rgba string.
pub fn samp_color_to_css(color: i64) -> String {
let unsigned = color as u32;
let r = (unsigned >> 24) & 0xFF;
let g = (unsigned >> 16) & 0xFF;
let b = (unsigned >> 8) & 0xFF;
let a = unsigned & 0xFF;
let a = if a == 0 { 255 } else { a };
format!("rgba({r},{g},{b},{:.2})", a as f64 / 255.0)
}
/// Parse SA-MP color codes {RRGGBB} in text, returning HTML with spans.
pub fn parse_samp_colors(text: &str) -> String {
let mut result = String::with_capacity(text.len() * 2);
let mut chars = text.chars().peekable();
let mut in_span = false;
while let Some(c) = chars.next() {
if c == '{' {
// Try to read 6 hex chars + '}'
let mut hex = String::with_capacity(6);
let mut valid = true;
for _ in 0..6 {
match chars.next() {
Some(h) if h.is_ascii_hexdigit() => hex.push(h),
_ => {
valid = false;
break;
}
}
}
if valid && chars.peek() == Some(&'}') {
chars.next(); // consume '}'
if in_span {
result.push_str("</span>");
}
result.push_str(&format!("<span style=\"color:#{hex}\">"));
in_span = true;
} else {
// Not a color code, output as-is
result.push('{');
result.push_str(&hex);
}
} else {
// HTML-escape
match c {
'<' => result.push_str("&lt;"),
'>' => result.push_str("&gt;"),
'&' => result.push_str("&amp;"),
'"' => result.push_str("&quot;"),
_ => result.push(c),
}
}
}
if in_span {
result.push_str("</span>");
}
result
}

@ -0,0 +1,168 @@
mod server;
mod bridge;
mod events;
mod logging;
mod db;
use std::ffi::{c_char, c_int, CStr, CString};
// --- Server ---
#[unsafe(no_mangle)]
pub extern "C" fn rgl_start(port: c_int) -> c_int {
match server::start(port as u16) {
Ok(()) => 0,
Err(_) => -1,
}
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_stop() {
server::stop();
}
// --- Logging ---
#[unsafe(no_mangle)]
pub extern "C" fn rgl_log_init(path: *const c_char) {
let path = unsafe { CStr::from_ptr(path) }.to_str().unwrap_or("");
logging::init(path);
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_log(level: *const c_char, tag: *const c_char, msg: *const c_char) {
let level = unsafe { CStr::from_ptr(level) }.to_str().unwrap_or("INFO");
let tag = unsafe { CStr::from_ptr(tag) }.to_str().unwrap_or("");
let msg_bytes = unsafe { CStr::from_ptr(msg) }.to_bytes();
let msg = events::win1251_to_utf8(msg_bytes);
logging::log(level, tag, &msg);
}
// --- Database ---
#[unsafe(no_mangle)]
pub extern "C" fn rgl_db_init(path: *const c_char) {
let path = unsafe { CStr::from_ptr(path) }.to_str().unwrap_or("");
db::init(path);
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_db_get(key: *const c_char) -> *mut c_char {
let key = unsafe { CStr::from_ptr(key) }.to_str().unwrap_or("");
match db::get(key) {
Some(v) => CString::new(v).unwrap_or_default().into_raw(),
None => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_db_set(key: *const c_char, value: *const c_char) -> c_int {
let key = unsafe { CStr::from_ptr(key) }.to_str().unwrap_or("");
let value = unsafe { CStr::from_ptr(value) }.to_str().unwrap_or("");
if db::set(key, value) { 0 } else { -1 }
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_db_delete(key: *const c_char) -> c_int {
let key = unsafe { CStr::from_ptr(key) }.to_str().unwrap_or("");
if db::delete(key) { 0 } else { -1 }
}
/// List all key-value pairs with prefix as JSON. Caller must free.
#[unsafe(no_mangle)]
pub extern "C" fn rgl_db_list(prefix: *const c_char) -> *mut c_char {
let prefix = unsafe { CStr::from_ptr(prefix) }.to_str().unwrap_or("");
let json = db::list_keys_json(prefix);
CString::new(json).unwrap_or_default().into_raw()
}
/// Delete all keys with prefix. Returns count deleted.
#[unsafe(no_mangle)]
pub extern "C" fn rgl_db_delete_prefix(prefix: *const c_char) -> c_int {
let prefix = unsafe { CStr::from_ptr(prefix) }.to_str().unwrap_or("");
db::delete_prefix(prefix) as c_int
}
/// Submit async batch. Returns request id instantly.
#[unsafe(no_mangle)]
pub extern "C" fn rgl_db_submit(ops: *const c_char) -> u32 {
let ops = unsafe { CStr::from_ptr(ops) }.to_str().unwrap_or("[]");
db::submit_batch(ops)
}
/// Poll batch result. Returns JSON string if ready (caller must free), null if pending.
#[unsafe(no_mangle)]
pub extern "C" fn rgl_db_poll(id: u32) -> *mut c_char {
match db::poll_result(id) {
Some(r) => CString::new(r).unwrap_or_default().into_raw(),
None => std::ptr::null_mut(),
}
}
// --- Modules ---
#[unsafe(no_mangle)]
pub extern "C" fn rgl_register_module(name: *const c_char, static_dir: *const c_char) {
let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("");
let dir = unsafe { CStr::from_ptr(static_dir) }.to_str().unwrap_or("");
server::register_module(name, dir);
}
/// Register a chat command (for tracking/web visibility).
#[unsafe(no_mangle)]
pub extern "C" fn rgl_register_command(name: *const c_char, owner: *const c_char) {
let name = unsafe { CStr::from_ptr(name) }.to_str().unwrap_or("");
let owner = unsafe { CStr::from_ptr(owner) }.to_str().unwrap_or("");
server::register_command(name, owner);
}
/// Get registered commands as JSON. Caller must free.
#[unsafe(no_mangle)]
pub extern "C" fn rgl_get_commands() -> *mut c_char {
let json = server::get_commands_json();
CString::new(json).unwrap_or_default().into_raw()
}
// --- Events ---
#[unsafe(no_mangle)]
pub extern "C" fn rgl_push_event(
event_name: *const c_char,
json_args: *const c_char,
) -> *mut c_char {
let name = unsafe { CStr::from_ptr(event_name) }.to_str().unwrap_or("");
let args_bytes = unsafe { CStr::from_ptr(json_args) }.to_bytes();
let args = events::win1251_to_utf8(args_bytes);
match bridge::push_event(name, &args) {
Some(response) => CString::new(response).unwrap_or_default().into_raw(),
None => std::ptr::null_mut(),
}
}
// --- Bridge ---
#[unsafe(no_mangle)]
pub extern "C" fn rgl_poll() -> *mut c_char {
match bridge::poll_requests() {
Some(json) => CString::new(json).unwrap_or_default().into_raw(),
None => std::ptr::null_mut(),
}
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_respond(request_id: c_int, result_json: *const c_char) {
let result = unsafe { CStr::from_ptr(result_json) }.to_str().unwrap_or("");
bridge::respond(request_id as u32, result);
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_free(s: *mut c_char) {
if !s.is_null() {
unsafe { drop(CString::from_raw(s)) };
}
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_hello() -> c_int {
42
}

@ -0,0 +1,44 @@
//! Logging — writes to file + broadcasts to WS clients via event system.
use std::fs::OpenOptions;
use std::io::Write;
use std::sync::Mutex;
use std::sync::OnceLock;
use crate::bridge;
static LOG_FILE: OnceLock<Mutex<Option<std::fs::File>>> = OnceLock::new();
fn log_file() -> &'static Mutex<Option<std::fs::File>> {
LOG_FILE.get_or_init(|| Mutex::new(None))
}
/// Initialize logging with a file path.
pub fn init(path: &str) {
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.ok();
*log_file().lock().unwrap() = file;
}
/// Write a log entry — to file + broadcast to WS.
pub fn log(level: &str, tag: &str, message: &str) {
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let line = format!("[{now}] [{level}][{tag}] {message}\n");
// Write to file
if let Ok(mut guard) = log_file().lock() {
if let Some(ref mut f) = *guard {
let _ = f.write_all(line.as_bytes());
let _ = f.flush();
}
}
// Broadcast to WS clients
let safe_msg = message.replace('\\', "\\\\").replace('"', "\\\"");
let safe_tag = tag.replace('"', "\\\"");
let json = format!("[\"{level}\",\"{safe_tag}\",\"{safe_msg}\"]");
bridge::push_event("__log", &json);
}

@ -0,0 +1,305 @@
//! Axum HTTP/WS server — admin UI is built-in, modules are Lua-side.
use axum::{
body::Body,
extract::{Path, ws::{Message, WebSocket, WebSocketUpgrade}},
http::{header, StatusCode},
response::{Html, IntoResponse, Response},
routing::{get, post},
Router,
};
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::{Mutex, OnceLock};
use std::collections::HashMap;
use crate::{bridge, logging};
const BUILD_TS: &str = match option_env!("ARZ_BUILD_TS") {
Some(v) => v,
None => "dev",
};
static SHUTDOWN: AtomicBool = AtomicBool::new(false);
static MODULE_DIRS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
static RT_HANDLE: OnceLock<tokio::runtime::Handle> = OnceLock::new();
pub fn runtime_handle() -> Option<&'static tokio::runtime::Handle> {
RT_HANDLE.get()
}
/// Registered commands: name → owner module
static COMMANDS: OnceLock<Mutex<Vec<(String, String)>>> = OnceLock::new();
fn commands() -> &'static Mutex<Vec<(String, String)>> {
COMMANDS.get_or_init(|| Mutex::new(Vec::new()))
}
fn module_dirs() -> &'static Mutex<HashMap<String, String>> {
MODULE_DIRS.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn register_module(name: &str, static_dir: &str) {
if static_dir.is_empty() {
module_dirs().lock().unwrap().remove(name);
} else {
module_dirs().lock().unwrap().insert(name.to_string(), static_dir.to_string());
}
}
pub fn unregister_module(name: &str) {
module_dirs().lock().unwrap().remove(name);
}
pub fn list_modules() -> Vec<String> {
module_dirs().lock().unwrap().keys().cloned().collect()
}
/// Check if a module has a static/index.html registered
pub fn module_has_static(name: &str) -> bool {
let dirs = module_dirs().lock().unwrap();
dirs.get(name).map(|d| !d.is_empty()).unwrap_or(false)
}
pub fn register_command(name: &str, owner: &str) {
let mut cmds = commands().lock().unwrap();
// Avoid duplicates
if !cmds.iter().any(|(n, _)| n == name) {
cmds.push((name.to_string(), owner.to_string()));
}
}
pub fn get_commands_json() -> String {
let cmds = commands().lock().unwrap();
let items: Vec<String> = cmds.iter()
.map(|(n, o)| format!(r#"{{"name":"{}","owner":"{}"}}"#, n, o))
.collect();
format!("[{}]", items.join(","))
}
pub fn start(port: u16) -> Result<(), String> {
let _initial_rx = bridge::init_event_channel();
let (rt_tx, rt_rx) = std::sync::mpsc::sync_channel(1);
std::thread::Builder::new()
.name("rgl-server".into())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => { rt_tx.send(false).ok(); return; },
};
RT_HANDLE.set(rt.handle().clone()).ok();
rt_tx.send(true).ok(); // Signal that runtime is ready
rt.block_on(async move {
let app = Router::new()
.route("/", get(index_handler))
.route("/admin", get(admin_handler))
.route("/ws", get(ws_handler))
.route("/api/modules", get(modules_list_handler))
.route("/api/commands", get(commands_list_handler))
.route("/api/{module}/{action}", post(api_handler))
.fallback(static_file_handler);
let addr = format!("0.0.0.0:{port}");
let socket = match tokio::net::TcpSocket::new_v4() {
Ok(s) => s,
Err(_) => return,
};
socket.set_reuseaddr(true).ok();
let bind_addr: std::net::SocketAddr = addr.parse().unwrap();
if socket.bind(bind_addr).is_err() { return; }
if let Ok(listener) = socket.listen(128) {
SHUTDOWN.store(false, AtomicOrdering::Relaxed);
let graceful = axum::serve(listener, app)
.with_graceful_shutdown(async {
while !SHUTDOWN.load(AtomicOrdering::Relaxed) {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
});
graceful.await.ok();
}
});
})
.map_err(|e| format!("thread spawn error: {e}"))?;
// Wait for tokio runtime to be ready before returning
match rt_rx.recv() {
Ok(true) => Ok(()),
_ => Err("runtime init failed".to_string()),
}
}
pub fn stop() {
SHUTDOWN.store(true, AtomicOrdering::Relaxed);
}
// --- Built-in pages ---
async fn index_handler() -> Html<String> {
let html = include_str!("../static/index.html")
.replace("{{BUILD_TS}}", BUILD_TS);
Html(html)
}
async fn admin_handler() -> Html<String> {
let html = include_str!("../static/ui_page.html")
.replace("{{MODULE}}", "admin")
.replace("{{TITLE}}", "Admin");
Html(html)
}
async fn commands_list_handler() -> impl IntoResponse {
let json = get_commands_json();
Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(json))
.unwrap()
}
// --- API ---
async fn modules_list_handler() -> impl IntoResponse {
axum::Json(serde_json::json!({
"modules": list_modules(),
"build": BUILD_TS,
}))
}
async fn api_handler(
Path((module, action)): Path<(String, String)>,
body: String,
) -> impl IntoResponse {
let code = format!(
"return __arz_handle_api([=[{module}]=], [=[{action}]=], [=[{body}]=])"
);
let id = bridge::request_lua_exec(code);
// Poll for result with async timeout — never blocks tokio workers
let result = tokio::time::timeout(
std::time::Duration::from_secs(2),
async {
loop {
if let Some(r) = bridge::try_get_result(id) {
return r;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
}
).await;
match result {
Ok(r) => Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r))
.unwrap(),
Err(_) => Response::builder()
.status(StatusCode::GATEWAY_TIMEOUT)
.body(Body::from(r#"{"error":"lua timeout"}"#))
.unwrap(),
}
}
// --- Static files ---
async fn static_file_handler(uri: axum::http::Uri) -> Response {
let path = uri.path();
let Some(rest) = path.strip_prefix("/m/") else {
return (StatusCode::NOT_FOUND, "not found").into_response();
};
let (module, file_path) = match rest.find('/') {
Some(i) => (&rest[..i], &rest[i+1..]),
None => (rest, ""),
};
let base_dir = {
let dirs = module_dirs().lock().unwrap();
match dirs.get(module) {
Some(d) if d == "__render__" => {
// Module uses render() API — serve auto-UI page
let html = include_str!("../static/ui_page.html")
.replace("{{MODULE}}", module)
.replace("{{TITLE}}", module);
return ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response();
}
Some(d) if !d.is_empty() => d.clone(),
_ => {
return (StatusCode::NOT_FOUND, "module not found").into_response();
}
}
};
let full_path = if file_path.is_empty() || file_path == "/" {
format!("{}/index.html", base_dir)
} else {
let clean = file_path.trim_start_matches('/');
if clean.contains("..") {
return (StatusCode::FORBIDDEN, "forbidden").into_response();
}
format!("{}/{}", base_dir, clean)
};
match tokio::fs::read(&full_path).await {
Ok(contents) => {
let ct = match full_path.rsplit('.').next() {
Some("html") => "text/html; charset=utf-8",
Some("css") => "text/css",
Some("js") => "application/javascript",
Some("json") => "application/json",
Some("png") => "image/png",
Some("svg") => "image/svg+xml",
_ => "application/octet-stream",
};
([(header::CONTENT_TYPE, ct)], contents).into_response()
}
Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(),
}
}
// --- WebSocket ---
async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws.on_upgrade(handle_ws)
}
async fn handle_ws(mut socket: WebSocket) {
let mut event_rx = match bridge::subscribe_events() {
Some(rx) => rx,
None => return,
};
loop {
tokio::select! {
Ok(event) = event_rx.recv() => {
let json = serde_json::json!({
"type": "event",
"event": event.event,
"args": serde_json::from_str::<serde_json::Value>(&event.args)
.unwrap_or(serde_json::Value::Null),
});
if socket.send(Message::Text(json.to_string().into())).await.is_err() {
break;
}
}
msg = socket.recv() => {
match msg {
Some(Ok(Message::Text(text))) => {
if text.as_str() == "ping" {
if socket.send(Message::Text("pong".into())).await.is_err() {
break;
}
}
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
}
}
}
}

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ARZ Web Helper</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0a0e1a; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 40px 16px; }
h1 { font-size: 22px; font-weight: 600; color: #f3f4f6; margin-bottom: 8px; }
.subtitle { color: #6b7280; font-size: 13px; margin-bottom: 32px; }
.status { display: flex; align-items: center; gap: 8px; margin-bottom: 32px; font-size: 13px; color: #9ca3af; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }
.dot.ok { background: #22c55e; }
.modules { display: flex; flex-direction: column; gap: 8px; width: 100%; max-width: 400px; }
a.mod { display: flex; align-items: center; gap: 12px; padding: 14px 18px; background: #111827; border: 1px solid #1f2937; border-radius: 10px; color: #f3f4f6; text-decoration: none; font-size: 15px; transition: all 0.15s; }
a.mod:hover { background: #1f2937; border-color: #374151; }
a.mod .label { flex: 1; text-transform: capitalize; }
a.mod .arrow { color: #4b5563; }
.empty { color: #4b5563; font-size: 14px; text-align: center; padding: 32px; }
.admin-link { margin-top: 24px; }
a.admin { color: #6b7280; font-size: 13px; text-decoration: none; padding: 8px 16px; border: 1px solid #1f2937; border-radius: 8px; }
a.admin:hover { color: #9ca3af; border-color: #374151; }
</style>
</head>
<body>
<h1>ARZ Web Helper</h1>
<p class="subtitle">build {{BUILD_TS}}</p>
<div class="status"><div class="dot" id="dot"></div><span id="st">Connecting...</span></div>
<div class="modules" id="mods"><div class="empty">Loading...</div></div>
<div class="admin-link"><a class="admin" href="/admin">Admin Panel</a></div>
<script>
const ws = new WebSocket('ws://'+location.host+'/ws');
ws.onopen = () => { document.getElementById('dot').className='dot ok'; document.getElementById('st').textContent='Connected'; };
ws.onclose = () => { document.getElementById('dot').className='dot'; document.getElementById('st').textContent='Disconnected'; };
async function loadModules() {
try {
const res = await fetch('/api/modules');
const data = await res.json();
const el = document.getElementById('mods');
const mods = data.modules || [];
if (!mods.length) { el.innerHTML = '<div class="empty">No modules loaded</div>'; return; }
el.innerHTML = '';
mods.sort();
for (const name of mods) {
el.innerHTML += '<a class="mod" href="/m/'+name+'/"><span class="label">'+name+'</span><span class="arrow"></span></a>';
}
} catch(e) {}
}
loadModules();
</script>
</body>
</html>

@ -0,0 +1,293 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{TITLE}} — ARZ</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#0a0e1a; color:#e0e0e0; font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; display:flex; flex-direction:column; }
.header { background:#111827; padding:12px 16px; display:flex; align-items:center; gap:12px; border-bottom:1px solid #1f2937; }
.header a { color:#6b7280; text-decoration:none; font-size:20px; }
.header h1 { font-size:16px; font-weight:600; color:#f3f4f6; text-transform:capitalize; }
.content { flex:1; padding:16px; max-width:600px; width:100%; margin:0 auto; overflow-y:auto; }
.loading { color:#6b7280; text-align:center; padding:40px; }
/* Widgets */
.w { margin-bottom:8px; }
.w-text { font-size:14px; line-height:1.6; color:#d1d5db; }
.w-text-colored { font-size:14px; line-height:1.6; }
.w-sep { border-top:1px solid #1f2937; margin:12px 0; }
.w-spacing { height:8px; }
.w-sameline { display:inline; }
.w-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
button.w-btn { background:#1f2937; border:1px solid #374151; color:#d1d5db; border-radius:6px; padding:8px 16px; font-size:13px; cursor:pointer; transition:all 0.1s; }
button.w-btn:hover { background:#374151; color:#f3f4f6; }
button.w-btn:active { background:#4b5563; }
.w-input label { display:block; font-size:12px; color:#9ca3af; margin-bottom:4px; }
.w-input input { background:#1f2937; border:1px solid #374151; border-radius:6px; padding:8px 12px; color:#f3f4f6; font-size:13px; width:100%; outline:none; }
.w-input input:focus { border-color:#3b82f6; }
.w-check { display:flex; align-items:center; gap:8px; font-size:14px; cursor:pointer; }
.w-check input { accent-color:#3b82f6; width:16px; height:16px; }
.w-slider label { display:block; font-size:12px; color:#9ca3af; margin-bottom:4px; }
.w-slider input[type=range] { width:100%; accent-color:#3b82f6; }
.w-slider .val { font-size:12px; color:#6b7280; float:right; }
.w-combo label { display:block; font-size:12px; color:#9ca3af; margin-bottom:4px; }
.w-combo select { background:#1f2937; border:1px solid #374151; border-radius:6px; padding:8px; color:#f3f4f6; font-size:13px; width:100%; outline:none; }
.w-progress { background:#1f2937; border-radius:4px; height:20px; overflow:hidden; position:relative; }
.w-progress .bar { background:#3b82f6; height:100%; transition:width 0.2s; }
.w-progress .lbl { position:absolute; top:0; left:0; right:0; text-align:center; font-size:11px; line-height:20px; color:#f3f4f6; }
.w-collapse { border:1px solid #1f2937; border-radius:6px; margin-bottom:8px; overflow:hidden; }
.w-collapse-hdr { background:#111827; padding:10px 14px; cursor:pointer; font-size:14px; font-weight:500; display:flex; align-items:center; gap:8px; }
.w-collapse-hdr:hover { background:#1f2937; }
.w-collapse-hdr .arrow { transition:transform 0.15s; font-size:10px; }
.w-collapse-hdr.open .arrow { transform:rotate(90deg); }
.w-collapse-body { padding:10px 14px; display:none; }
.w-collapse-body.open { display:block; }
.w-tabs { margin-bottom:8px; }
.w-tabs-nav { display:flex; gap:0; border-bottom:1px solid #1f2937; margin-bottom:12px; }
.w-tab-btn { background:none; border:none; border-bottom:2px solid transparent; color:#6b7280; padding:10px 16px; font-size:14px; cursor:pointer; }
.w-tab-btn:hover { color:#d1d5db; }
.w-tab-btn.active { color:#3b82f6; border-bottom-color:#3b82f6; }
.w-tab-panel { display:none; }
.w-tab-panel.active { display:block; }
</style>
</head>
<body>
<div class="header">
<a href="/">&#8592;</a>
<h1>{{TITLE}}</h1>
</div>
<div class="content" id="ui"></div>
<script>
const MODULE = '{{MODULE}}';
const uiEl = document.getElementById('ui');
let interactions = {};
async function render() {
try {
const res = await fetch('/api/' + MODULE + '/__render', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(interactions)
});
const data = await res.json();
if (data.widgets) {
renderWidgets(data.widgets);
} else if (data.error) {
uiEl.innerHTML = '<div class="loading">' + data.error + '</div>';
}
interactions = {};
} catch(e) {
uiEl.innerHTML = '<div class="loading">Connection error</div>';
}
}
function renderWidgets(widgets) {
uiEl.innerHTML = '';
let inRow = false;
let rowEl = null;
for (const w of widgets) {
if (w.t === 'sameline') {
inRow = true;
if (!rowEl) {
rowEl = document.createElement('div');
rowEl.className = 'w-row';
// Move last child into row
if (uiEl.lastChild) rowEl.appendChild(uiEl.lastChild);
uiEl.appendChild(rowEl);
}
continue;
}
const el = createWidget(w);
if (!el) continue;
if (inRow && rowEl) {
rowEl.appendChild(el);
} else {
inRow = false;
rowEl = null;
uiEl.appendChild(el);
}
}
}
function createWidget(w) {
const d = document.createElement('div');
d.className = 'w';
switch(w.t) {
case 'text':
d.className = 'w w-text';
d.textContent = w.text;
return d;
case 'text_colored':
d.className = 'w w-text-colored';
d.style.color = `rgba(${w.r*255|0},${w.g*255|0},${w.b*255|0},${w.a})`;
d.textContent = w.text;
return d;
case 'separator':
d.className = 'w-sep';
return d;
case 'spacing':
d.className = 'w-spacing';
return d;
case 'button': {
const btn = document.createElement('button');
btn.className = 'w-btn';
btn.textContent = w.label.replace(/##.*/, ''); // strip imgui ID suffix
btn.onclick = () => { interactions[w.id] = 'click'; render(); };
d.appendChild(btn);
return d;
}
case 'input': {
d.className = 'w w-input';
const lbl = document.createElement('label');
lbl.textContent = w.label;
const inp = document.createElement('input');
inp.type = 'text';
inp.value = w.value || '';
inp.onchange = () => { interactions[w.id] = inp.value; render(); };
d.appendChild(lbl);
d.appendChild(inp);
return d;
}
case 'checkbox': {
d.className = 'w';
const lbl = document.createElement('label');
lbl.className = 'w-check';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !!w.value;
cb.onchange = () => { interactions[w.id] = cb.checked ? 'true' : 'false'; render(); };
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(w.label));
d.appendChild(lbl);
return d;
}
case 'slider_int':
case 'slider_float': {
d.className = 'w w-slider';
const lbl = document.createElement('label');
lbl.innerHTML = w.label + ' <span class="val">' + w.value + '</span>';
const sl = document.createElement('input');
sl.type = 'range';
sl.min = w.min; sl.max = w.max;
sl.step = w.t === 'slider_int' ? 1 : 0.01;
sl.value = w.value;
sl.oninput = () => { lbl.querySelector('.val').textContent = sl.value; };
sl.onchange = () => { interactions[w.id] = sl.value; render(); };
d.appendChild(lbl);
d.appendChild(sl);
return d;
}
case 'combo': {
d.className = 'w w-combo';
const lbl = document.createElement('label');
lbl.textContent = w.label;
const sel = document.createElement('select');
(w.items || []).forEach((item, i) => {
const opt = document.createElement('option');
opt.value = i; opt.textContent = item;
if (i === w.value) opt.selected = true;
sel.appendChild(opt);
});
sel.onchange = () => { interactions[w.id] = sel.value; render(); };
d.appendChild(lbl);
d.appendChild(sel);
return d;
}
case 'progress': {
d.className = 'w w-progress';
const bar = document.createElement('div');
bar.className = 'bar';
bar.style.width = ((w.fraction || 0) * 100) + '%';
const lbl = document.createElement('div');
lbl.className = 'lbl';
lbl.textContent = w.label || '';
d.appendChild(bar);
d.appendChild(lbl);
return d;
}
case 'collapsing': {
const wrap = document.createElement('div');
wrap.className = 'w-collapse';
const hdr = document.createElement('div');
hdr.className = 'w-collapse-hdr' + (w.open ? ' open' : '');
hdr.innerHTML = '<span class="arrow">&#9654;</span> ' + w.label;
const body = document.createElement('div');
body.className = 'w-collapse-body' + (w.open ? ' open' : '');
if (w.children) {
for (const cw of w.children) {
const ce = createWidget(cw);
if (ce) body.appendChild(ce);
}
}
hdr.onclick = () => { hdr.classList.toggle('open'); body.classList.toggle('open'); };
wrap.appendChild(hdr);
wrap.appendChild(body);
return wrap;
}
case 'tab_bar': {
const wrap = document.createElement('div');
wrap.className = 'w-tabs';
const nav = document.createElement('div');
nav.className = 'w-tabs-nav';
const body = document.createElement('div');
body.className = 'w-tabs-body';
(w.tabs || []).forEach((tab, i) => {
const btn = document.createElement('button');
btn.className = 'w-tab-btn' + (i === 0 ? ' active' : '');
btn.textContent = tab.label;
const panel = document.createElement('div');
panel.className = 'w-tab-panel' + (i === 0 ? ' active' : '');
if (tab.children) {
for (const cw of tab.children) {
const ce = createWidget(cw);
if (ce) panel.appendChild(ce);
}
}
btn.onclick = () => {
nav.querySelectorAll('.w-tab-btn').forEach(b => b.classList.remove('active'));
body.querySelectorAll('.w-tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
panel.classList.add('active');
};
nav.appendChild(btn);
body.appendChild(panel);
});
wrap.appendChild(nav);
wrap.appendChild(body);
return wrap;
}
default:
return null;
}
}
render();
</script>
</body>
</html>
Loading…
Cancel
Save