commit
9e2fdd4c5b
@ -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="/">←</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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
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,'<')}</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
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
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("<"),
|
||||||
|
'>' => result.push_str(">"),
|
||||||
|
'&' => result.push_str("&"),
|
||||||
|
'"' => result.push_str("""),
|
||||||
|
_ => 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="/">←</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">▶</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…
Reference in new issue