From 9e2fdd4c5b29ab2ae0a14f256d6717006ab7aa4f Mon Sep 17 00:00:00 2001 From: Regela Date: Sat, 28 Mar 2026 06:54:08 +0300 Subject: [PATCH] INITIAL COMMIT --- .gitignore | 1 + http_framework/modules/chat/init.lua | 35 + http_framework/modules/chat/static/index.html | 124 ++ http_framework/modules/console/init.lua | 99 ++ .../modules/console/static/index.html | 145 +++ http_framework/ws_server.lua | 1057 +++++++++++++++ rust_core/.gitignore | 1 + rust_core/Cargo.lock | 1138 +++++++++++++++++ rust_core/Cargo.toml | 22 + rust_core/build.rs | 11 + rust_core/src/bridge.rs | 148 +++ rust_core/src/db.rs | 396 ++++++ rust_core/src/events.rs | 76 ++ rust_core/src/lib.rs | 168 +++ rust_core/src/logging.rs | 44 + rust_core/src/server.rs | 305 +++++ rust_core/static/index.html | 54 + rust_core/static/ui_page.html | 293 +++++ 18 files changed, 4117 insertions(+) create mode 100644 .gitignore create mode 100644 http_framework/modules/chat/init.lua create mode 100644 http_framework/modules/chat/static/index.html create mode 100644 http_framework/modules/console/init.lua create mode 100644 http_framework/modules/console/static/index.html create mode 100644 http_framework/ws_server.lua create mode 100644 rust_core/.gitignore create mode 100644 rust_core/Cargo.lock create mode 100644 rust_core/Cargo.toml create mode 100644 rust_core/build.rs create mode 100644 rust_core/src/bridge.rs create mode 100644 rust_core/src/db.rs create mode 100644 rust_core/src/events.rs create mode 100644 rust_core/src/lib.rs create mode 100644 rust_core/src/logging.rs create mode 100644 rust_core/src/server.rs create mode 100644 rust_core/static/index.html create mode 100644 rust_core/static/ui_page.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc063ad --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +more_modules diff --git a/http_framework/modules/chat/init.lua b/http_framework/modules/chat/init.lua new file mode 100644 index 0000000..f180c10 --- /dev/null +++ b/http_framework/modules/chat/init.lua @@ -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 diff --git a/http_framework/modules/chat/static/index.html b/http_framework/modules/chat/static/index.html new file mode 100644 index 0000000..cdfe8a8 --- /dev/null +++ b/http_framework/modules/chat/static/index.html @@ -0,0 +1,124 @@ + + + + + +SA-MP Chat + + + +
+ +

SA-MP Chat

+
+
+
+
+ + +
+ + + diff --git a/http_framework/modules/console/init.lua b/http_framework/modules/console/init.lua new file mode 100644 index 0000000..207df92 --- /dev/null +++ b/http_framework/modules/console/init.lua @@ -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 diff --git a/http_framework/modules/console/static/index.html b/http_framework/modules/console/static/index.html new file mode 100644 index 0000000..02ffe22 --- /dev/null +++ b/http_framework/modules/console/static/index.html @@ -0,0 +1,145 @@ + + + + + +Lua Console + + + +
+ +

Lua Console

+
+
+
Connected to game. Type Lua expressions or statements.
+
+
+
+ + +
+
+
+ + + diff --git a/http_framework/ws_server.lua b/http_framework/ws_server.lua new file mode 100644 index 0000000..0059ddb --- /dev/null +++ b/http_framework/ws_server.lua @@ -0,0 +1,1057 @@ +script_name("WS_FRAMEWORK") +script_author("Regela") + +local ffi = require("ffi") + +ffi.cdef[[ + int rgl_start(int port); + void rgl_stop(); + int rgl_hello(); + void rgl_log_init(const char* path); + void rgl_log(const char* level, const char* tag, const char* msg); + void rgl_db_init(const char* path); + char* rgl_db_get(const char* key); + int rgl_db_set(const char* key, const char* value); + int rgl_db_delete(const char* key); + void rgl_register_module(const char* name, const char* static_dir); + void rgl_register_command(const char* name, const char* owner); + char* rgl_get_commands(); + char* rgl_push_event(const char* event_name, const char* json_args); + char* rgl_poll(); + void rgl_respond(int request_id, const char* result_json); + char* rgl_db_list(const char* prefix); + int rgl_db_delete_prefix(const char* prefix); + unsigned int rgl_db_submit(const char* ops_json); + char* rgl_db_poll(unsigned int id); + void rgl_free(char* s); +]] + +local rust = nil +local sampev = nil +local api_handlers = {} +local event_interceptors = {} +local loaded_modules = {} +local module_states = {} -- persistent state per module for render() +local command_handlers = {} +local framework = {} +local _current_module = nil +admin_visible = false +local recent_logs = {} +local MAX_LOGS = 100 +local notifications = {} -- {text, level, time, start} +local module_errors = {} -- ["module_name"] = "last error" + +local function log(level, tag, ...) + if not rust then return print("[" .. level .. "][" .. tag .. "]", ...) end + local args = {...} + local parts = {} + for i, v in ipairs(args) do parts[i] = tostring(v) end + local msg = table.concat(parts, " ") + rust.rgl_log(level, tag, msg) + -- Keep recent logs for admin UI + recent_logs[#recent_logs + 1] = {level = level, tag = tag, msg = msg} + if #recent_logs > MAX_LOGS then table.remove(recent_logs, 1) end +end + +local function notify(text, level, duration) + notifications[#notifications + 1] = { + text = text, level = level or "ERROR", + time = duration or 5, start = os.clock() + } +end + +local function module_error(mod_name, context, err) + local msg = mod_name .. " [" .. context .. "]: " .. tostring(err) + log("ERROR", "MOD", msg) + notify(msg, "ERROR", 8) + module_errors[mod_name] = msg +end + +function main() + while not isSampAvailable() do wait(100) end + + local so_path = getWorkingDirectory() .. "/lib/libarz_core.so" + local ok, lib = pcall(ffi.load, so_path) + if not ok then print("RGL: Failed to load .so: " .. tostring(lib)); return end + rust = lib + pcall(function() rust.rgl_stop() end) + wait(300) + + rust.rgl_log_init("/sdcard/Download/ws_framework.log") + log("INFO", "INIT", "WS Framework starting...") + + if rust.rgl_start(8081) ~= 0 then log("ERROR", "RUST", "rgl_start failed"); return end + log("INFO", "RUST", "Server on :8081") + + rust.rgl_db_init(getWorkingDirectory() .. "/rgl_data.db") + log("INFO", "INIT", "DB initialized") + + setup_framework() + log("INFO", "INIT", "Framework ready") + register_admin() + log("INFO", "INIT", "Admin registered") + load_all_modules() + log("INFO", "INIT", "Modules loaded") + + sampev = require("samp.events") + setup_event_hooks() + + -- Global API handler + _G.__arz_handle_api = function(module, action, body) + local key = module .. "." .. action + local entry = api_handlers[key] + if entry then + local hok, result = pcall(entry.fn, body) + if hok then return result or '{"ok":true}' end + module_error(entry.owner, "api:" .. action, result) + local safe_err = tostring(result):gsub('"', '\\"') + return '{"error":"' .. safe_err .. '"}' + end + return '{"error":"not found: ' .. key .. '"}' + end + + log("INFO", "INIT", "Ready") + + while true do + local ptr = rust.rgl_poll() + if ptr ~= nil then + local json_str = ffi.string(ptr) + rust.rgl_free(ptr) + process_requests(json_str) + end + wait(0) + end +end + +---------------------------------------------------------------- +-- UI Builder (records widgets → JSON for web) +---------------------------------------------------------------- +function create_ui_builder(interactions) + interactions = interactions or {} + local widgets = {} + local id_n = 0 + local collapse_stack = {} + local ui = {} + + local function next_id(prefix) + id_n = id_n + 1 + return prefix .. id_n + end + + function ui.text(str) widgets[#widgets+1] = {t="text", text=tostring(str)} end + function ui.text_colored(r,g,b,a, str) widgets[#widgets+1] = {t="text_colored", r=r,g=g,b=b,a=a,text=tostring(str)} end + function ui.separator() widgets[#widgets+1] = {t="separator"} end + function ui.spacing() widgets[#widgets+1] = {t="spacing"} end + function ui.sameline() widgets[#widgets+1] = {t="sameline"} end + + function ui.button(label) + local id = next_id("b") + widgets[#widgets+1] = {t="button", id=id, label=label} + return interactions[id] == "click" + end + + function ui.input(label, value) + local id = next_id("i") + local new_val = interactions[id] + if new_val ~= nil then value = new_val end + widgets[#widgets+1] = {t="input", id=id, label=label, value=value or ""} + return value or "" + end + + function ui.checkbox(label, value) + local id = next_id("c") + local iv = interactions[id] + if iv ~= nil then value = (iv == "true") end + widgets[#widgets+1] = {t="checkbox", id=id, label=label, value=value} + return value + end + + function ui.slider_int(label, value, min, max) + local id = next_id("si") + local iv = interactions[id] + if iv ~= nil then value = tonumber(iv) or value end + widgets[#widgets+1] = {t="slider_int", id=id, label=label, value=value, min=min, max=max} + return value + end + + function ui.input_int(label, value) + local id = next_id("ii") + local iv = interactions[id] + if iv ~= nil then value = tonumber(iv) or value end + widgets[#widgets+1] = {t="input_int", id=id, label=label, value=value} + return value + end + + function ui.slider_float(label, value, min, max) + local id = next_id("sf") + local iv = interactions[id] + if iv ~= nil then value = tonumber(iv) or value end + widgets[#widgets+1] = {t="slider_float", id=id, label=label, value=value, min=min, max=max} + return value + end + + function ui.combo(label, selected, items) + local id = next_id("co") + local iv = interactions[id] + if iv ~= nil then selected = tonumber(iv) or selected end + widgets[#widgets+1] = {t="combo", id=id, label=label, value=selected, items=items} + return selected + end + + function ui.progress(fraction, label) + widgets[#widgets+1] = {t="progress", fraction=fraction, label=label or ""} + end + + function ui.collapsing(label) + local children = {} + widgets[#widgets+1] = {t="collapsing", label=label, open=true, children=children} + collapse_stack[#collapse_stack+1] = widgets + widgets = children + return true + end + + function ui.collapsing_end() + if #collapse_stack > 0 then + widgets = collapse_stack[#collapse_stack] + collapse_stack[#collapse_stack] = nil + end + end + + function ui.tab_bar(id) + widgets[#widgets+1] = {t="tab_bar", id=id, tabs={}} + collapse_stack[#collapse_stack+1] = widgets + return true + end + + function ui.tab_item(label) + -- Find parent tab_bar + local parent = collapse_stack[#collapse_stack] + if parent then + local tb = parent[#parent] + if tb and tb.t == "tab_bar" then + local tab = {label=label, children={}} + tb.tabs[#tb.tabs+1] = tab + collapse_stack[#collapse_stack+1] = widgets + widgets = tab.children + return true + end + end + return false + end + + function ui.tab_end() + if #collapse_stack > 0 then + widgets = collapse_stack[#collapse_stack] + collapse_stack[#collapse_stack] = nil + end + end + + function ui._get_widgets() return widgets end + return ui +end + +---------------------------------------------------------------- +-- mimgui wrapper (thin layer over imgui for in-game rendering) +---------------------------------------------------------------- +local imgui_loaded, imgui +do + local ok, result = pcall(require, "mimgui") + if ok then + imgui_loaded = true + imgui = result + else + imgui_loaded = false + end +end + +function create_ui_imgui() + if not imgui then return nil end + local ui = {} + local input_bufs = {} + + function ui.text(str) imgui.TextUnformatted(tostring(str)) end + function ui.text_colored(r,g,b,a, str) imgui.TextColored(imgui.ImVec4(r,g,b,a), tostring(str)) end + function ui.separator() imgui.Separator() end + function ui.spacing() imgui.Spacing() end + function ui.sameline() imgui.SameLine() end + + function ui.button(label) return imgui.Button(label) end + + function ui.input(label, value) + if not input_bufs[label] then + input_bufs[label] = ffi.new('char[256]') + end + local buf = input_bufs[label] + if ffi.string(buf) ~= (value or "") then + ffi.copy(buf, value or "") + end + imgui.InputText(label, buf, 256) + return ffi.string(buf) + end + + function ui.checkbox(label, value) + local b = ffi.new('bool[1]', value or false) + imgui.Checkbox(label, b) + return b[0] + end + + function ui.slider_int(label, value, min, max) + local v = ffi.new('int[1]', value or 0) + imgui.SliderInt(label, v, min, max) + return v[0] + end + + function ui.input_int(label, value) + local v = ffi.new('int[1]', value or 0) + imgui.InputInt(label, v) + return v[0] + end + + function ui.slider_float(label, value, min, max) + local v = ffi.new('float[1]', value or 0) + imgui.SliderFloat(label, v, min, max) + return v[0] + end + + function ui.combo(label, selected, items) + local v = ffi.new('int[1]', selected or 0) + local str = table.concat(items, "\0") .. "\0" + imgui.Combo(label, v, str) + return v[0] + end + + function ui.progress(fraction, label) + imgui.ProgressBar(fraction or 0, imgui.ImVec2(-1, 0), label or "") + end + + function ui.collapsing(label) return imgui.CollapsingHeader(label) end + function ui.collapsing_end() end + + function ui.tab_bar(id) return imgui.BeginTabBar(id) end + function ui.tab_item(label) return imgui.BeginTabItem(label) end + function ui.tab_end() + imgui.EndTabItem() + end + + return ui +end + +---------------------------------------------------------------- +-- Framework API +---------------------------------------------------------------- +function setup_framework() + local enc_ok, encoding = pcall(require, "encoding") + if enc_ok then + encoding.default = "CP1251" + local u8 = encoding.UTF8 + framework.to_utf8 = function(s) return u8:encode(s) end + framework.to_win1251 = function(s) return u8:decode(s) end + else + framework.to_utf8 = function(s) return s end + framework.to_win1251 = function(s) return s end + end + + local cjson_ok, cjson = pcall(require, "cjson") + if cjson_ok then + framework.json_encode = cjson.encode + framework.json_decode = cjson.decode + else + framework.json_decode = function(s) local t = {} for k,v in s:gmatch('"([^"]+)"%s*:%s*"([^"]*)"') do t[k]=v end return t end + framework.json_encode = function(t) local p = {} for k,v in pairs(t) do p[#p+1]='"'..k..'":"'..tostring(v)..'"' end return "{"..table.concat(p,",").."}" end + end + + framework.rust = rust + framework.log = log + framework.modules_dir = getWorkingDirectory() .. "/modules" + framework.register_module = function(name, dir) rust.rgl_register_module(name, dir) end + + framework.on_api = function(mod, action, handler) + api_handlers[mod.."."..action] = {fn = handler, owner = _current_module or mod} + end + + framework.push_event = function(name, json) rust.rgl_push_event(name, json) end + + framework.fire_event = function(event_name, ...) + local interceptors = event_interceptors[event_name] + if interceptors then + for _, entry in ipairs(interceptors) do + local iok, iresult = pcall(entry.fn, ...) + if not iok then + module_error(entry.owner, "event:" .. event_name, iresult) + elseif iresult == false then + return false + end + end + end + return true + end + + framework.on_event = function(event_name, handler, priority) + priority = priority or 50 + if not event_interceptors[event_name] then event_interceptors[event_name] = {} end + local list = event_interceptors[event_name] + list[#list+1] = {fn = handler, priority = priority, owner = _current_module or "unknown"} + table.sort(list, function(a, b) return a.priority < b.priority end) + end + + framework.db_get = function(key) + local full = (_current_module or "global") .. "." .. key + local ptr = rust.rgl_db_get(full) + if ptr == nil then return nil end + local val = ffi.string(ptr); rust.rgl_free(ptr); return val + end + framework.db_set = function(key, value) + return rust.rgl_db_set((_current_module or "global") .. "." .. key, tostring(value)) == 0 + end + framework.db_delete = function(key) + return rust.rgl_db_delete((_current_module or "global") .. "." .. key) == 0 + end + + -- Raw db access (for admin, no prefix) + framework.db_raw_list = function(prefix) + local ptr = rust.rgl_db_list(prefix) + if ptr == nil then return {} end + local json = ffi.string(ptr); rust.rgl_free(ptr) + local ok, result = pcall(framework.json_decode, json) + return ok and result or {} + end + framework.db_raw_set = function(key, value) + return rust.rgl_db_set(key, tostring(value)) == 0 + end + framework.db_raw_delete = function(key) + return rust.rgl_db_delete(key) == 0 + end + framework.db_raw_delete_prefix = function(prefix) + return rust.rgl_db_delete_prefix(prefix) + end + + -- Async batch DB API + framework.db_batch = function(operations) + local json = framework.json_encode(operations) + local id = rust.rgl_db_submit(json) + -- Check if we're in a coroutine (wait() only works in coroutines) + local in_coroutine = coroutine.running() ~= nil + while true do + if in_coroutine then wait(1) end + local ptr = rust.rgl_db_poll(id) + if ptr ~= nil then + local r = ffi.string(ptr); rust.rgl_free(ptr) + local ok, decoded = pcall(framework.json_decode, r) + return ok and decoded or {} + end + end + end + + -- Delayed one-shot (returns cancel function) + framework.timer_once = function(ms, callback) + local cancelled = false + lua_thread.create(function() + wait(ms) + if not cancelled then + local ok, err = pcall(callback) + if not ok then log("ERROR", "TIMER", tostring(err)) end + end + end) + return function() cancelled = true end + end + + -- Dialog response helper + framework.respond_dialog = function(id, button, listboxId, input) + sampSendDialogResponse(id, button, listboxId, input or "") + end + + -- Send chat/command helper + framework.chat = function(text) + sampProcessChatInput(text) + end + + -- Add chat message (local, only visible to player) + framework.add_chat_message = function(text, color) + sampAddChatMessage(text, color or -1) + end + + -- Dialog unlock: send /mm and auto-close the menu dialog to unblock inventory + local _waiting_unlock = false + framework.unlock_dialog = function() + _waiting_unlock = true + sampSendChat("/mm") + end + -- Internal interceptor for /mm dialog (id=722), priority 1 (before all modules) + if not event_interceptors["onShowDialog"] then event_interceptors["onShowDialog"] = {} end + table.insert(event_interceptors["onShowDialog"], 1, { + fn = function(id) + if _waiting_unlock and id == 722 then + _waiting_unlock = false + lua_thread.create(function() + wait(1) + sampSendDialogResponse(722, 0, 0, "") + end) + return false + end + end, + priority = 1, + owner = "__core", + }) + + framework.command = function(name, handler) + local owner = _current_module or "__core" + command_handlers[name] = {fn = handler, owner = owner} + sampRegisterChatCommand(name, function(args) + local ok2, err = pcall(handler, args) + if not ok2 then module_error(owner, "cmd:/" .. name, err) end + end) + rust.rgl_register_command(name, owner) + log("INFO", "CMD", "Registered /" .. name .. " (" .. owner .. ")") + end +end + +---------------------------------------------------------------- +-- Module lifecycle +---------------------------------------------------------------- +function load_single_module(name) + if loaded_modules[name] then unload_single_module(name) end + local path = framework.modules_dir .. "/" .. name .. "/init.lua" + local f = io.open(path); if not f then return false, "not found" end; f:close() + + _current_module = name + local ok, mod = pcall(dofile, path) + if not ok then _current_module = nil; return false, "load: " .. tostring(mod) end + if not mod or not mod.init then _current_module = nil; return false, "no init()" end + + -- Create per-module framework wrapper with bound db prefix + local mod_fw = setmetatable({}, {__index = framework}) + mod_fw.db_get = function(key) + local ptr = rust.rgl_db_get(name .. "." .. key) + if ptr == nil then return nil end + local val = ffi.string(ptr); rust.rgl_free(ptr); return val + end + mod_fw.db_set = function(key, value) + return rust.rgl_db_set(name .. "." .. key, tostring(value)) == 0 + end + mod_fw.db_delete = function(key) + return rust.rgl_db_delete(name .. "." .. key) == 0 + end + mod_fw.db_get_many = function(keys) + local ops = {} + for i, k in ipairs(keys) do ops[i] = {op = "get", key = name .. "." .. k} end + local results = framework.db_batch(ops) + local out = {} + for i, k in ipairs(keys) do + local r = results[i] + if r and type(r.v) == "string" then out[k] = r.v end + end + return out + end + mod_fw.db_set_many = function(kv) + local ops = {} + for k, v in pairs(kv) do + ops[#ops + 1] = {op = "set", key = name .. "." .. k, value = tostring(v)} + end + framework.db_batch(ops) + end + mod_fw.db_get_prefix = function(prefix) + local full = name .. "." .. prefix + local results = framework.db_batch({{op = "get_prefix", prefix = full}}) + local items = results[1] and results[1].items or {} + local out = {} + local strip = #name + 2 + for _, item in ipairs(items) do + out[item.key:sub(strip)] = item.value + end + return out + end + + local iok, ierr = pcall(mod.init, mod_fw) + _current_module = nil + if not iok then return false, "init: " .. tostring(ierr) end + + loaded_modules[name] = {mod = mod, status = "loaded"} + module_states[name] = module_states[name] or {} + register_module_render(name) + log("INFO", "MODS", "Loaded: " .. name) + return true +end + +function unload_single_module(name) + local entry = loaded_modules[name] + if not entry then return false, "not loaded" end + if entry.mod and entry.mod.unload then pcall(entry.mod.unload, framework) end + local rm = {} + for key, h in pairs(api_handlers) do if h.owner == name then rm[#rm+1] = key end end + for _, key in ipairs(rm) do api_handlers[key] = nil end + for ev, list in pairs(event_interceptors) do + local new = {} + for _, e in ipairs(list) do if e.owner ~= name then new[#new+1] = e end end + event_interceptors[ev] = new + end + rust.rgl_register_module(name, "") + loaded_modules[name] = nil + log("INFO", "MODS", "Unloaded: " .. name) + return true +end + +function load_all_modules() + local dir = framework.modules_dir + local ls = io.popen('ls "' .. dir .. '" 2>/dev/null') + if not ls then return end + for name in ls:lines() do + local path = dir .. "/" .. name .. "/init.lua" + local f = io.open(path) + if f then f:close(); local ok,err = load_single_module(name) + if not ok then log("ERROR", "MODS", name .. ": " .. err) end + end + end + ls:close() +end + +---------------------------------------------------------------- +-- Built-in Admin (render-based, works in web + imgui) +---------------------------------------------------------------- +function admin_render(ui, state) + if ui.tab_bar("AdminTabs") then + + if ui.tab_item("Modules") then + for name, entry in pairs(loaded_modules) do + if module_errors[name] then + ui.text_colored(1, 0.3, 0.3, 1, name .. " (error)") + else + ui.text(name) + end + ui.sameline() + if ui.button("Reload##" .. name) then + module_errors[name] = nil + unload_single_module(name) + load_single_module(name) + end + ui.sameline() + if ui.button("Unload##" .. name) then + module_errors[name] = nil + unload_single_module(name) + end + ui.sameline() + if ui.button("Reset##" .. name) then + module_errors[name] = nil + unload_single_module(name) + framework.db_raw_delete_prefix(name .. ".") + module_states[name] = {} + load_single_module(name) + log("INFO", "ADMIN", "Reset module: " .. name) + end + if module_errors[name] then + ui.text_colored(0.7, 0.3, 0.3, 1, " " .. module_errors[name]) + end + end + ui.separator() + local dir = framework.modules_dir + local ls = io.popen('ls "' .. dir .. '" 2>/dev/null') + if ls then + for name in ls:lines() do + if not loaded_modules[name] then + local path = dir .. "/" .. name .. "/init.lua" + local f = io.open(path) + if f then + f:close() + ui.text_colored(0.5, 0.5, 0.5, 1, name) + ui.sameline() + if ui.button("Load##" .. name) then load_single_module(name) end + end + end + end + ls:close() + end + ui.tab_end() + end + + if ui.tab_item("Commands") then + for name, entry in pairs(command_handlers) do + ui.text("/" .. name) + ui.sameline() + ui.text_colored(0.5, 0.5, 0.5, 1, entry.owner) + end + ui.tab_end() + end + + if ui.tab_item("Data") then + -- Collect module names that have db data + local all_mods = {} + for name in pairs(loaded_modules) do all_mods[#all_mods+1] = name end + table.sort(all_mods) + + if #all_mods == 0 then + ui.text_colored(0.5, 0.5, 0.5, 1, "No modules loaded") + else + for _, mod_name in ipairs(all_mods) do + local keys = framework.db_raw_list(mod_name .. ".") + if type(keys) == "table" and #keys > 0 then + if ui.collapsing(mod_name .. " (" .. #keys .. " keys)") then + for _, kv in ipairs(keys) do + local short_key = kv.key:sub(#mod_name + 2) -- strip "module." + ui.text(short_key .. " = " .. tostring(kv.value)) + ui.sameline() + if ui.button("Del##" .. kv.key) then + framework.db_raw_delete(kv.key) + end + end + ui.separator() + if ui.button("Delete all##" .. mod_name) then + framework.db_raw_delete_prefix(mod_name .. ".") + log("INFO", "ADMIN", "Deleted all data for " .. mod_name) + end + ui.collapsing_end() + end + end + end + end + ui.tab_end() + end + + if ui.tab_item("Logs") then + local start = math.max(1, #recent_logs - 80) + for i = start, #recent_logs do + local l = recent_logs[i] + if l.level == "ERROR" then + ui.text_colored(1, 0.3, 0.3, 1, "[" .. l.tag .. "] " .. l.msg) + elseif l.level == "WARN" then + ui.text_colored(1, 0.8, 0.2, 1, "[" .. l.tag .. "] " .. l.msg) + else + ui.text("[" .. l.tag .. "] " .. l.msg) + end + end + ui.tab_end() + end + + end +end + +function register_admin() + -- __render for web UI + api_handlers["admin.__render"] = {fn = function(body) + local interactions = {} + if body and #body > 2 then + pcall(function() interactions = framework.json_decode(body) end) + end + local ui = create_ui_builder(interactions) + admin_render(ui, {}) + return framework.json_encode({widgets = ui._get_widgets()}) + end, owner = "__admin"} + + -- Module management API (for backward compat) + api_handlers["admin.modules"] = {fn = function() + local list = {} + for name, entry in pairs(loaded_modules) do list[#list+1] = {name=name, status=entry.status} end + return framework.json_encode(list) + end, owner = "__admin"} + + api_handlers["admin.load"] = {fn = function(body) + local ok2,data = pcall(framework.json_decode, body) + if not ok2 or not data or not data.name then return '{"ok":false}' end + local ok,err = load_single_module(data.name) + return ok and '{"ok":true}' or framework.json_encode({ok=false,error=err}) + end, owner = "__admin"} + + api_handlers["admin.unload"] = {fn = function(body) + local ok2,data = pcall(framework.json_decode, body) + if not ok2 or not data or not data.name then return '{"ok":false}' end + local ok,err = unload_single_module(data.name) + return ok and '{"ok":true}' or framework.json_encode({ok=false,error=err}) + end, owner = "__admin"} + + api_handlers["admin.reload"] = {fn = function(body) + local ok2,data = pcall(framework.json_decode, body) + if not ok2 or not data or not data.name then return '{"ok":false}' end + unload_single_module(data.name); local ok,err = load_single_module(data.name) + return ok and '{"ok":true}' or framework.json_encode({ok=false,error=err}) + end, owner = "__admin"} + + api_handlers["admin.db_keys"] = {fn = function(body) + local ok2,data = pcall(framework.json_decode, body) + if not ok2 or not data or not data.module then return '[]' end + local ptr = rust.rgl_db_list(data.module .. ".") + if ptr == nil then return '[]' end + local json = ffi.string(ptr); rust.rgl_free(ptr) + return json + end, owner = "__admin"} + + api_handlers["admin.db_edit"] = {fn = function(body) + local ok2,data = pcall(framework.json_decode, body) + if not ok2 or not data or not data.key or not data.value then return '{"ok":false}' end + rust.rgl_db_set(data.key, data.value) + return '{"ok":true}' + end, owner = "__admin"} + + api_handlers["admin.db_delete_key"] = {fn = function(body) + local ok2,data = pcall(framework.json_decode, body) + if not ok2 or not data or not data.key then return '{"ok":false}' end + rust.rgl_db_delete(data.key) + return '{"ok":true}' + end, owner = "__admin"} + + api_handlers["admin.reset_module"] = {fn = function(body) + local ok2,data = pcall(framework.json_decode, body) + if not ok2 or not data or not data.name then return '{"ok":false}' end + unload_single_module(data.name) + rust.rgl_db_delete_prefix(data.name .. ".") + module_states[data.name] = {} + local ok,err = load_single_module(data.name) + log("INFO", "ADMIN", "Reset: " .. data.name) + return ok and '{"ok":true}' or framework.json_encode({ok=false,error=err}) + end, owner = "__admin"} + + -- Register /rgl_admin command + framework.command("rgl_admin", function() + admin_visible = not admin_visible + log("INFO", "ADMIN", "Toggled: " .. tostring(admin_visible)) + end) + +end + +---------------------------------------------------------------- +-- Module __render API (for web auto-UI) +---------------------------------------------------------------- +function setup_module_render_api() + -- Called when a new module with render() is loaded + -- api_handlers[module..".__render"] is set automatically +end + +-- Called from load_single_module after init +-- Check if module has render(), if so register __render API +function register_module_render(name) + local entry = loaded_modules[name] + if not entry or not entry.mod or not entry.mod.render then return end + + -- Register in Rust so it shows on dashboard and serves ui_page.html + rust.rgl_register_module(name, "__render__") + + api_handlers[name .. ".__render"] = {fn = function(body) + local interactions = {} + if body and #body > 2 then + pcall(function() interactions = framework.json_decode(body) end) + end + local ui = create_ui_builder(interactions) + local state = module_states[name] or {} + module_states[name] = state + + _current_module = name + local ok, err = pcall(entry.mod.render, ui, state) + _current_module = nil + if not ok then return framework.json_encode({error = tostring(err)}) end + + return framework.json_encode({widgets = ui._get_widgets()}) + end, owner = name} +end + + +---------------------------------------------------------------- +-- Request processing +---------------------------------------------------------------- +function process_requests(json_str) + local ok, requests = pcall(framework.json_decode, json_str) + if not ok or type(requests) ~= "table" then return end + for _, req in ipairs(requests) do + local code = req.code + if not code then goto continue end + local exec_ok, result = pcall(function() + local fn = loadstring(code) + if not fn then return nil end + return fn() + end) + local response = '"ok"' + if exec_ok and type(result) == "string" then response = result + elseif not exec_ok then log("ERROR", "EXEC", tostring(result)) end + rust.rgl_respond(req.id, response) + ::continue:: + end +end + +---------------------------------------------------------------- +-- Event system +---------------------------------------------------------------- +function setup_event_hooks() + local function ja(...) + local args = {...} + local p = {} + for _, v in ipairs(args) do + local t = type(v) + if t == "string" then p[#p+1] = '"' .. v:gsub('\\','\\\\'):gsub('"','\\"'):gsub('%c','') .. '"' + elseif t == "number" then p[#p+1] = tostring(v) + elseif t == "boolean" then p[#p+1] = v and "true" or "false" + elseif t == "table" then + local tp = {} + for k2,v2 in pairs(v) do + local vs + if type(v2) == "string" then vs = '"' .. v2:gsub('\\','\\\\'):gsub('"','\\"'):gsub('%c','') .. '"' + elseif type(v2) == "number" or type(v2) == "boolean" then vs = tostring(v2) + else vs = '"' .. tostring(v2) .. '"' end + tp[#tp+1] = '"' .. tostring(k2) .. '":' .. vs + end + p[#p+1] = "{" .. table.concat(tp,",") .. "}" + else p[#p+1] = "null" end + end + return "[" .. table.concat(p,",") .. "]" + end + + local function make_hook(event_name) + return function(...) + local interceptors = event_interceptors[event_name] + if interceptors and #interceptors > 0 then + for _, entry in ipairs(interceptors) do + local iok, iresult = pcall(entry.fn, ...) + if not iok then + module_error(entry.owner, "event:" .. event_name, iresult) + elseif iresult == false then + return false + end + end + end + rust.rgl_push_event(event_name, ja(...)) + end + end + + local interface = sampev.INTERFACE + local count = 0 + local function reg(tbl) + for _, entry in pairs(tbl) do + if type(entry) == "table" and entry[1] then + local names = entry[1] + if type(names) == "string" then names = {names} end + for _, name in ipairs(names) do + if type(name) == "string" and not sampev[name] then + sampev[name] = make_hook(name) + count = count + 1 + end + end + end + end + end + reg(interface.OUTCOMING_RPCS or {}) + reg(interface.INCOMING_RPCS or {}) + reg(interface.OUTCOMING_PACKETS or {}) + reg(interface.INCOMING_PACKETS or {}) + log("INFO", "EVENTS", "Hooked " .. count .. " events") +end + +function onScriptTerminate(s, quitGame) + if s.name == "WS_FRAMEWORK" and rust then + pcall(function() rust.rgl_stop() end) + end +end + +-- mimgui — top level, outside main() +if imgui_loaded and imgui then + local admin_window = imgui.new.bool() + local dpi = MONET_DPI_SCALE or 1 + + -- Admin window + imgui.OnFrame( + function() return admin_visible end, + function() + admin_window[0] = true + imgui.SetNextWindowSize(imgui.ImVec2(530 * dpi, 400 * dpi), imgui.Cond.FirstUseEver) + imgui.Begin("RGL Admin", admin_window, imgui.WindowFlags.NoCollapse) + + local ui = create_ui_imgui() + if ui then + local rok, rerr = pcall(admin_render, ui, {}) + if not rok then + imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:") + imgui.TextWrapped(tostring(rerr)) + end + imgui.EndTabBar() + end + + imgui.End() + if not admin_window[0] then admin_visible = false end + end + ) + + -- BTC module window (and any other module with render()) + local btc_window = imgui.new.bool() + imgui.OnFrame( + function() return btc_visible end, + function() + btc_window[0] = true + imgui.SetNextWindowSize(imgui.ImVec2(450 * dpi, 400 * dpi), imgui.Cond.FirstUseEver) + imgui.Begin("BTC Miner", btc_window, imgui.WindowFlags.NoCollapse) + + local mod = loaded_modules and loaded_modules["btc"] + if mod and mod.mod and mod.mod.render then + local ui = create_ui_imgui() + if ui then + local rok, rerr = pcall(mod.mod.render, ui, module_states["btc"] or {}) + if not rok then + imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:") + imgui.TextWrapped(tostring(rerr)) + end + imgui.EndTabBar() + end + else + imgui.Text("BTC module not loaded") + end + + imgui.End() + if not btc_window[0] then btc_visible = false end + end + ) + + -- Notification toasts (errors, warnings) + imgui.OnFrame( + function() return #notifications > 0 end, + function(self) + self.HideCursor = true + local resX, resY = getScreenResolution() + + imgui.SetNextWindowPos(imgui.ImVec2(resX - 10 * dpi, resY * 0.3), imgui.Cond.Always, imgui.ImVec2(1, 0)) + imgui.Begin("##rgl_notifications", nil, + imgui.WindowFlags.AlwaysAutoResize + + imgui.WindowFlags.NoTitleBar + + imgui.WindowFlags.NoResize + + imgui.WindowFlags.NoMove + + imgui.WindowFlags.NoBackground + + imgui.WindowFlags.NoFocusOnAppearing + + imgui.WindowFlags.NoNav + ) + + local now = os.clock() + local i = 1 + while i <= #notifications do + local n = notifications[i] + local elapsed = now - n.start + if elapsed > n.time then + table.remove(notifications, i) + else + -- Fade + local alpha = 1 + if elapsed < 0.3 then alpha = elapsed / 0.3 + elseif elapsed > n.time - 0.5 then alpha = (n.time - elapsed) / 0.5 end + + local color + if n.level == "ERROR" then color = imgui.ImVec4(1, 0.3, 0.3, alpha) + elseif n.level == "WARN" then color = imgui.ImVec4(1, 0.7, 0.2, alpha) + else color = imgui.ImVec4(0.3, 0.8, 1, alpha) end + + local bg = imgui.ImVec4(0.1, 0.1, 0.15, 0.9 * alpha) + local p = imgui.GetCursorScreenPos() + local textSize = imgui.CalcTextSize(n.text, nil, false, 280 * dpi) + local pad = 8 * dpi + + local dl = imgui.GetWindowDrawList() + dl:AddRectFilled( + imgui.ImVec2(p.x - pad, p.y - pad), + imgui.ImVec2(p.x + textSize.x + pad, p.y + textSize.y + pad), + imgui.ColorConvertFloat4ToU32(bg), 6 * dpi + ) + + imgui.PushTextWrapPos(imgui.GetCursorPosX() + 280 * dpi) + imgui.TextColored(color, n.text) + imgui.PopTextWrapPos() + imgui.Spacing() + + i = i + 1 + end + end + + imgui.End() + end + ) +end + diff --git a/rust_core/.gitignore b/rust_core/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust_core/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust_core/Cargo.lock b/rust_core/Cargo.lock new file mode 100644 index 0000000..f846ec5 --- /dev/null +++ b/rust_core/Cargo.lock @@ -0,0 +1,1138 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arz-core" +version = "0.1.0" +dependencies = [ + "axum", + "chrono", + "encoding_rs", + "serde", + "serde_json", + "tokio", + "tokio-rusqlite", + "tower-http", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rusqlite" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "302563ae4a2127f3d2c105f4f2f0bd7cae3609371755600ebc148e0ccd8510d6" +dependencies = [ + "crossbeam-channel", + "rusqlite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust_core/Cargo.toml b/rust_core/Cargo.toml new file mode 100644 index 0000000..5c5c9c1 --- /dev/null +++ b/rust_core/Cargo.toml @@ -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" diff --git a/rust_core/build.rs b/rust_core/build.rs new file mode 100644 index 0000000..99e0214 --- /dev/null +++ b/rust_core/build.rs @@ -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/"); +} diff --git a/rust_core/src/bridge.rs b/rust_core/src/bridge.rs new file mode 100644 index 0000000..91d41cd --- /dev/null +++ b/rust_core/src/bridge.rs @@ -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>, + + /// Results from Lua executions, keyed by request id + results: Mutex>, + results_ready: Condvar, + + /// Next request id + next_id: AtomicU32, + + /// Event broadcast sender (to WS clients via tokio) + event_tx: Mutex>>, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct EventMessage { + pub event: String, + pub args: String, // JSON string +} + +static BRIDGE: OnceLock = 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 { + let (tx, rx) = tokio::sync::broadcast::channel(256); + *state().event_tx.lock().unwrap() = Some(tx); + rx +} + +pub fn subscribe_events() -> Option> { + 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 { + 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 { + 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 { + let s = state(); + let mut pending = s.pending_requests.lock().unwrap(); + if pending.is_empty() { + return None; + } + let requests: Vec = 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 { + 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(); +} diff --git a/rust_core/src/db.rs b/rust_core/src/db.rs new file mode 100644 index 0000000..d9ce8cd --- /dev/null +++ b/rust_core/src/db.rs @@ -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 = OnceLock::new(); +static BATCH_RESULTS: OnceLock>> = 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( + f: impl FnOnce(&mut rusqlite::Connection) -> Result + Send + 'static, +) -> Option { + 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 { + 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 = 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 { + 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = serde_json::from_str(&result).unwrap(); + assert_eq!(results[0]["ok"], true); + assert_eq!(results[1]["v"], "hello"); + } +} diff --git a/rust_core/src/events.rs b/rust_core/src/events.rs new file mode 100644 index 0000000..dc15020 --- /dev/null +++ b/rust_core/src/events.rs @@ -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 { + 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(""); + } + result.push_str(&format!("")); + 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(""); + } + result +} diff --git a/rust_core/src/lib.rs b/rust_core/src/lib.rs new file mode 100644 index 0000000..9f945c1 --- /dev/null +++ b/rust_core/src/lib.rs @@ -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 +} diff --git a/rust_core/src/logging.rs b/rust_core/src/logging.rs new file mode 100644 index 0000000..af5d23b --- /dev/null +++ b/rust_core/src/logging.rs @@ -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>> = OnceLock::new(); + +fn log_file() -> &'static Mutex> { + 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); +} diff --git a/rust_core/src/server.rs b/rust_core/src/server.rs new file mode 100644 index 0000000..c558d60 --- /dev/null +++ b/rust_core/src/server.rs @@ -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>> = OnceLock::new(); +static RT_HANDLE: OnceLock = OnceLock::new(); + +pub fn runtime_handle() -> Option<&'static tokio::runtime::Handle> { + RT_HANDLE.get() +} + +/// Registered commands: name → owner module +static COMMANDS: OnceLock>> = OnceLock::new(); + +fn commands() -> &'static Mutex> { + COMMANDS.get_or_init(|| Mutex::new(Vec::new())) +} + +fn module_dirs() -> &'static Mutex> { + 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 { + 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 = 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 { + let html = include_str!("../static/index.html") + .replace("{{BUILD_TS}}", BUILD_TS); + Html(html) +} + +async fn admin_handler() -> Html { + 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::(&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, + _ => {} + } + } + } + } +} diff --git a/rust_core/static/index.html b/rust_core/static/index.html new file mode 100644 index 0000000..e37750a --- /dev/null +++ b/rust_core/static/index.html @@ -0,0 +1,54 @@ + + + + + +ARZ Web Helper + + + +

ARZ Web Helper

+

build {{BUILD_TS}}

+
Connecting...
+
Loading...
+ + + + diff --git a/rust_core/static/ui_page.html b/rust_core/static/ui_page.html new file mode 100644 index 0000000..5cf51bd --- /dev/null +++ b/rust_core/static/ui_page.html @@ -0,0 +1,293 @@ + + + + + +{{TITLE}} — ARZ + + + +
+ +

{{TITLE}}

+
+
+ + + +