2026-02-18 21:08:48 +05:00
|
|
|
use mlua::{Lua, Value, StdLib};
|
2026-01-17 05:46:38 +05:00
|
|
|
use std::path::PathBuf;
|
2026-02-18 21:08:48 +05:00
|
|
|
use std::time::Duration;
|
2026-01-17 05:46:38 +05:00
|
|
|
use std::fs;
|
|
|
|
|
|
|
|
|
|
use super::sandbox::SandboxLevel;
|
|
|
|
|
use super::error::LuaError;
|
|
|
|
|
use super::{CommandContext, CommandResult};
|
|
|
|
|
use super::api;
|
|
|
|
|
|
|
|
|
|
pub struct LuaEngine {
|
|
|
|
|
lua: Lua,
|
|
|
|
|
sandbox: SandboxLevel,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
impl LuaEngine {
|
|
|
|
|
pub fn new(sandbox: SandboxLevel) -> Result<Self, LuaError> {
|
|
|
|
|
// select which standard libraries to load based on sandbox access level
|
|
|
|
|
let std_libs = match sandbox {
|
|
|
|
|
SandboxLevel::Minimal => {
|
|
|
|
|
StdLib::TABLE | StdLib::STRING | StdLib::MATH
|
|
|
|
|
}
|
|
|
|
|
SandboxLevel::Standard => {
|
|
|
|
|
StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8
|
|
|
|
|
}
|
|
|
|
|
SandboxLevel::Full => {
|
|
|
|
|
StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8 | StdLib::OS
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let lua = Lua::new_with(std_libs, mlua::LuaOptions::default())
|
|
|
|
|
.map_err(|e| LuaError::InitError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// remove dangerous globals regardless of sandbox
|
|
|
|
|
{
|
|
|
|
|
let globals = lua.globals();
|
|
|
|
|
|
|
|
|
|
// always remove these
|
|
|
|
|
let _ = globals.set("loadfile", Value::Nil);
|
|
|
|
|
let _ = globals.set("dofile", Value::Nil);
|
|
|
|
|
let _ = globals.set("load", Value::Nil);
|
|
|
|
|
let _ = globals.set("loadstring", Value::Nil);
|
|
|
|
|
|
|
|
|
|
// remove io unless full sandbox
|
|
|
|
|
if !matches!(sandbox, SandboxLevel::Full) {
|
|
|
|
|
let _ = globals.set("io", Value::Nil);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// remove os.execute, os.exit, os.setlocale even in full mode
|
|
|
|
|
// for SECURITY REASONS!!!
|
|
|
|
|
if matches!(sandbox, SandboxLevel::Full) {
|
|
|
|
|
if let Ok(os) = globals.get::<mlua::Table>("os") {
|
|
|
|
|
let _ = os.set("execute", Value::Nil);
|
|
|
|
|
let _ = os.set("exit", Value::Nil);
|
|
|
|
|
let _ = os.set("remove", Value::Nil);
|
|
|
|
|
let _ = os.set("rename", Value::Nil);
|
|
|
|
|
let _ = os.set("setlocale", Value::Nil);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(Self { lua, sandbox })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Register all jarvis APIs
|
|
|
|
|
fn register_api(&self, context: &CommandContext) -> Result<(), LuaError> {
|
|
|
|
|
let globals = self.lua.globals();
|
|
|
|
|
|
|
|
|
|
// main jarvis table
|
|
|
|
|
let jarvis = self.lua.create_table()
|
|
|
|
|
.map_err(|e| LuaError::InitError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// always register core APIs
|
|
|
|
|
api::core::register(&self.lua, &jarvis)?;
|
|
|
|
|
api::audio::register(&self.lua, &jarvis)?;
|
|
|
|
|
api::context::register(&self.lua, &jarvis, context)?;
|
arch + features: Lua jarvis.speak() / jarvis.llm() API, /commands GUI page, games / mouse / random_choice packs
Architecture — DRY at the Lua API surface:
lua/api/tts.rs (new): jarvis.speak(text, opts?). opts.lang = ISO
two-letter code (default "ru"); auto-picks first installed voice
matching the culture. opts.async = true|false (default true). The
9 packs that previously inlined a 60-character PS-SAPI string for
every spoken reply can now reduce to one line. Refactored fun/,
dice/, clipboard_read/ as proof — fun/ask.lua went from 75 lines
of HTTP+SAPI boilerplate to ~28 lines of intent code.
lua/api/llm.rs (new): jarvis.llm(messages, opts?). messages is a
list of {role, content} tables, opts.max_tokens / .temperature /
.top_p. Returns the assistant string or nil. Gated by sandbox
allows_http() (so Standard+ packs only). Internally uses the
existing jarvis_core::llm::LlmClient::complete_with, no duplicated
HTTP plumbing.
llm/client.rs: split complete() into complete_with(messages,
max_tokens, temperature, top_p) for the new caller; old complete()
is now a thin wrapper at temp=0.7 top_p=1.0 for backward compat.
GUI — /commands page rewrite:
Replaces the "Раздел в разработке" placeholder. Calls Tauri command
get_commands_list (already existed in tauri_commands/commands.rs,
was wired but unused). Renders a search-filterable card grid:
- cmd id (monospace)
- type badge with colour by kind (lua=blue, ahk=red, cli=grey,
voice=green, terminate=red, stop_chaining=violet)
- sandbox badge
- description line (if non-empty)
- all phrases for currentLanguage, fallback to en, fallback to
first available; each phrase in a small inline pill
Toolbar: text input with magnifier icon + reload button (↻).
Counter line shows visible/total + "Скажи Джарвис + любую фразу"
hint. Counts down as you type the filter.
New packs (3, total 33 → 36):
games/ — launch_game / list_games. Reads or creates
%USERPROFILE%\Documents\jarvis-games.json (the sample preloads dota,
cs2, elden ring, witcher 3, factorio + minecraft launcher path +
fortnite epic URI). Trigger-strip + fuzzy name/alias match, then
launches via steam://rungameid/N (Steam), epic:// (Epic Games), or
start "" "path" (anything else). list_games speaks the configured
library.
mouse/ — left/right/middle/double click + scroll up/down. Single
dispatch.lua + _mouse_helper.ps1. PS P/Invokes user32!mouse_event
with the right flag pair (LEFTDOWN+LEFTUP / etc.), 30ms gap, doubles
do two LEFT cycles with a 50ms gap, wheel uses ±360 ticks.
random_choice/ — "выбери из X или Y или Z". Strips the trigger,
splits on " или " / " or " / ", " / " либо " / " чи ", math.random
picks one, speaks "Я выбираю N".
cargo test commands::tests still 3/3. 36 packs verified via
jarvis-gui startup log.
2026-05-15 12:58:16 +03:00
|
|
|
api::tts::register(&self.lua, &jarvis)?;
|
|
|
|
|
|
2026-01-17 05:46:38 +05:00
|
|
|
// sandbox-controlled APIs
|
|
|
|
|
if self.sandbox.allows_http() {
|
|
|
|
|
api::http::register(&self.lua, &jarvis)?;
|
arch + features: Lua jarvis.speak() / jarvis.llm() API, /commands GUI page, games / mouse / random_choice packs
Architecture — DRY at the Lua API surface:
lua/api/tts.rs (new): jarvis.speak(text, opts?). opts.lang = ISO
two-letter code (default "ru"); auto-picks first installed voice
matching the culture. opts.async = true|false (default true). The
9 packs that previously inlined a 60-character PS-SAPI string for
every spoken reply can now reduce to one line. Refactored fun/,
dice/, clipboard_read/ as proof — fun/ask.lua went from 75 lines
of HTTP+SAPI boilerplate to ~28 lines of intent code.
lua/api/llm.rs (new): jarvis.llm(messages, opts?). messages is a
list of {role, content} tables, opts.max_tokens / .temperature /
.top_p. Returns the assistant string or nil. Gated by sandbox
allows_http() (so Standard+ packs only). Internally uses the
existing jarvis_core::llm::LlmClient::complete_with, no duplicated
HTTP plumbing.
llm/client.rs: split complete() into complete_with(messages,
max_tokens, temperature, top_p) for the new caller; old complete()
is now a thin wrapper at temp=0.7 top_p=1.0 for backward compat.
GUI — /commands page rewrite:
Replaces the "Раздел в разработке" placeholder. Calls Tauri command
get_commands_list (already existed in tauri_commands/commands.rs,
was wired but unused). Renders a search-filterable card grid:
- cmd id (monospace)
- type badge with colour by kind (lua=blue, ahk=red, cli=grey,
voice=green, terminate=red, stop_chaining=violet)
- sandbox badge
- description line (if non-empty)
- all phrases for currentLanguage, fallback to en, fallback to
first available; each phrase in a small inline pill
Toolbar: text input with magnifier icon + reload button (↻).
Counter line shows visible/total + "Скажи Джарвис + любую фразу"
hint. Counts down as you type the filter.
New packs (3, total 33 → 36):
games/ — launch_game / list_games. Reads or creates
%USERPROFILE%\Documents\jarvis-games.json (the sample preloads dota,
cs2, elden ring, witcher 3, factorio + minecraft launcher path +
fortnite epic URI). Trigger-strip + fuzzy name/alias match, then
launches via steam://rungameid/N (Steam), epic:// (Epic Games), or
start "" "path" (anything else). list_games speaks the configured
library.
mouse/ — left/right/middle/double click + scroll up/down. Single
dispatch.lua + _mouse_helper.ps1. PS P/Invokes user32!mouse_event
with the right flag pair (LEFTDOWN+LEFTUP / etc.), 30ms gap, doubles
do two LEFT cycles with a 50ms gap, wheel uses ±360 ticks.
random_choice/ — "выбери из X или Y или Z". Strips the trigger,
splits on " или " / " or " / ", " / " либо " / " чи ", math.random
picks one, speaks "Я выбираю N".
cargo test commands::tests still 3/3. 36 packs verified via
jarvis-gui startup log.
2026-05-15 12:58:16 +03:00
|
|
|
api::llm::register(&self.lua, &jarvis)?;
|
2026-01-17 05:46:38 +05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if self.sandbox.allows_state() {
|
|
|
|
|
api::state::register(&self.lua, &jarvis, &context.command_path)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if self.sandbox.allows_fs() {
|
|
|
|
|
api::fs::register(&self.lua, &jarvis, &context.command_path, self.sandbox)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
api::system::register(&self.lua, &jarvis, self.sandbox)?;
|
|
|
|
|
|
|
|
|
|
globals.set("jarvis", jarvis)
|
|
|
|
|
.map_err(|e| LuaError::InitError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Main LUA executor
|
|
|
|
|
pub fn execute(
|
|
|
|
|
&self,
|
|
|
|
|
script_path: &PathBuf,
|
|
|
|
|
context: CommandContext,
|
|
|
|
|
timeout: Duration,
|
|
|
|
|
) -> Result<CommandResult, LuaError> {
|
|
|
|
|
// register APIs
|
|
|
|
|
self.register_api(&context)?;
|
|
|
|
|
|
|
|
|
|
// load script
|
|
|
|
|
let script_content = fs::read_to_string(script_path)
|
|
|
|
|
.map_err(|e| LuaError::LoadError(format!("{}: {}", script_path.display(), e)))?;
|
|
|
|
|
|
|
|
|
|
let script_name = script_path.file_name()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.to_string_lossy()
|
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// set up timeout hook
|
|
|
|
|
let start = std::time::Instant::now();
|
|
|
|
|
self.lua.set_hook(mlua::HookTriggers {
|
|
|
|
|
every_nth_instruction: Some(1000),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}, move |_lua, _debug| {
|
|
|
|
|
if start.elapsed() > timeout {
|
|
|
|
|
Err(mlua::Error::runtime("Script timeout"))
|
|
|
|
|
} else {
|
|
|
|
|
Ok(mlua::VmState::Continue)
|
|
|
|
|
}
|
|
|
|
|
}).map_err(|e| LuaError::InitError(e.to_string()))?;
|
|
|
|
|
|
|
|
|
|
// execute script
|
|
|
|
|
let result = self.lua.load(&script_content)
|
|
|
|
|
.set_name(&script_name)
|
|
|
|
|
.eval::<Value>();
|
|
|
|
|
|
|
|
|
|
// remove hook
|
|
|
|
|
let _ = self.lua.remove_hook();
|
|
|
|
|
|
|
|
|
|
// result
|
|
|
|
|
match result {
|
|
|
|
|
Ok(value) => Ok(self.parse_result(value)),
|
|
|
|
|
Err(e) => {
|
|
|
|
|
if e.to_string().contains("timeout") {
|
|
|
|
|
Err(LuaError::Timeout)
|
|
|
|
|
} else {
|
|
|
|
|
Err(LuaError::RuntimeError(e.to_string()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse Lua return value into CommandResult
|
|
|
|
|
fn parse_result(&self, value: Value) -> CommandResult {
|
|
|
|
|
match value {
|
|
|
|
|
// return { chain = false }
|
|
|
|
|
Value::Table(t) => {
|
|
|
|
|
let chain = t.get::<bool>("chain").unwrap_or(true);
|
|
|
|
|
CommandResult { chain }
|
|
|
|
|
}
|
|
|
|
|
// return false (shorthand for no chain)
|
|
|
|
|
Value::Boolean(chain) => CommandResult { chain },
|
|
|
|
|
// return nil or no return = chain
|
|
|
|
|
_ => CommandResult::default(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|