feat: 5 new packs + dotenvy + regen icon.ico + shortcuts redo

Iron Man icon regenerated from resources/icons/icon.png at sizes
16/24/32/48/64/128/256 via Pillow (make_ico.py committed alongside);
icon.ico is now a clean 83 KB multi-res for sharp display at any
scale instead of whatever placeholder Tauri originally emitted.

dotenvy added to workspace deps. jarvis-app/main.rs and jarvis-gui/
main.rs both walk up from current_exe() looking for dev.env (up to
5 parent levels), so the Desktop shortcut can launch the exe
directly without a wrapper .bat to pre-set GROQ_TOKEN. Falls back
to dotenvy::dotenv() (cwd lookup) if nothing found near the binary.

5 new portable Lua command packs. Bumps total from 21 to 26.
cargo test -p jarvis-core --lib commands::tests still passes 3/3.

reminders/ — set_reminder. Parses "напомни через N (секунд|минут|
часов) <body>" or English equivalents; understands one Russian word
numerals (один, две, пять, десять, пятнадцать, ...) for the count
when speech recognition returns words instead of digits. Defaults to
5 minutes if number/unit missing. Spawns a detached PowerShell that
Start-Sleeps then fires: BurntToast if installed, System.Speech
SAPI (ru-RU voice preference), and a WScript.Shell.Popup as the
guaranteed-visible last resort.

date_query/ — today / tomorrow / yesterday. Single answer.lua keyed
by command_id, computes the offset via PowerShell Get-Date.AddDays()
in ru-RU culture so the weekday/month come out in Russian, then
speaks via SAPI.

voice_type/ — type_text. Strips the trigger, drops the remainder to
clipboard via jarvis.system.clipboard.set, then synthesises Ctrl+V
through user32!keybd_event so it lands in whatever window currently
has focus. Works in any text field (note app, browser, IDE,
Telegram, etc.).

dice/ — coin_flip / roll_dice / random_number. Single roll.lua,
seeds math.random with os.time + jitter, speaks the result. "1-6"
for dice, "1-100" for random.

stopwatch/ — start / check / stop. Uses jarvis.state.get/set
(persisted via jarvis-core's settings DB) to remember the start
timestamp, computes elapsed via jarvis.context.time.timestamp,
formats as "X сек" / "X мин Y сек" / "X ч Y мин Z сек".

All new packs follow the established patterns (sandbox=full, PS-via-
exec helper where needed, USERPROFILE/SystemDrive everywhere, no
hardcoded paths).
This commit is contained in:
Bossiara13 2026-05-15 12:28:44 +03:00
parent ae279aeef7
commit dcee98bddf
18 changed files with 516 additions and 1 deletions

9
Cargo.lock generated
View file

@ -1554,6 +1554,12 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "1.2.1"
@ -3264,6 +3270,7 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
name = "jarvis-app"
version = "0.1.0"
dependencies = [
"dotenvy",
"glib 0.21.5",
"gtk",
"image",
@ -3278,6 +3285,7 @@ dependencies = [
"tray-icon",
"winapi",
"winit",
"winrt-notification",
]
[[package]]
@ -3339,6 +3347,7 @@ dependencies = [
name = "jarvis-gui"
version = "0.1.0"
dependencies = [
"dotenvy",
"jarvis-core",
"lazy_static",
"log",

View file

@ -54,4 +54,5 @@ tokenizers = { version = "0.22", default-features = false }
regex = "1"
sys-locale = "0.3"
webrtc-vad = "0.4"
dotenvy = "0.15"

View file

@ -11,6 +11,7 @@ jarvis-core = { path = "../jarvis-core", features = ["intent"] }
once_cell.workspace = true
log.workspace = true
simple-log = "2.4"
dotenvy.workspace = true
tray-icon = "0.21"
winit = "0.30"
image.workspace = true

View file

@ -28,6 +28,8 @@ mod tray;
static SHOULD_STOP: AtomicBool = AtomicBool::new(false);
fn main() -> Result<(), String> {
load_dotenv_near_exe();
eprintln!("[jarvis-app] step: init_dirs");
config::init_dirs()?;
@ -175,6 +177,27 @@ pub fn should_stop() -> bool {
SHOULD_STOP.load(Ordering::SeqCst)
}
// Look for dev.env next to the exe, then walk parents until we find one.
// Lets the user double-click jarvis-gui.exe / jarvis-app.exe without needing
// a wrapper .bat to pre-load GROQ_TOKEN and similar secrets.
fn load_dotenv_near_exe() {
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(|p| p.to_path_buf());
for _ in 0..5 {
if let Some(d) = &dir {
let candidate = d.join("dev.env");
if candidate.is_file() {
let _ = dotenvy::from_path(&candidate);
eprintln!("[jarvis-app] loaded env: {}", candidate.display());
return;
}
dir = d.parent().map(|p| p.to_path_buf());
}
}
}
let _ = dotenvy::dotenv();
}
#[cfg(target_os = "windows")]
fn notify_mic_problem() {
use winrt_notification::{Toast, Duration as ToastDuration};

View file

@ -21,6 +21,7 @@ sysinfo.workspace = true
once_cell.workspace = true
log.workspace = true
simple-log = "2.4"
dotenvy.workspace = true
serde.workspace = true
serde_json.workspace = true
platform-dirs.workspace = true

View file

@ -16,6 +16,8 @@ pub struct AppState {
}
fn main() {
load_dotenv_near_exe();
config::init_dirs().expect("Failed to init dirs");
// basic logging setup (simpler for GUI)
@ -102,3 +104,22 @@ fn main() {
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// Pull dev.env into the process env so secrets like GROQ_TOKEN are picked up
// when the user launches the exe directly (no wrapper .bat required).
fn load_dotenv_near_exe() {
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(|p| p.to_path_buf());
for _ in 0..5 {
if let Some(d) = &dir {
let candidate = d.join("dev.env");
if candidate.is_file() {
let _ = dotenvy::from_path(&candidate);
return;
}
dir = d.parent().map(|p| p.to_path_buf());
}
}
}
let _ = dotenvy::dotenv();
}

8
make_ico.py Normal file
View file

@ -0,0 +1,8 @@
from PIL import Image
src = r"C:\Jarvis\rust\resources\icons\icon.png"
dst = r"C:\Jarvis\rust\resources\icons\icon.ico"
img = Image.open(src).convert("RGBA")
sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
img.save(dst, format="ICO", sizes=sizes)
print(f"wrote {dst} from {src} ({img.size})")

View file

@ -0,0 +1,37 @@
local lang = jarvis.context.language
local cmd_id = jarvis.context.command_id
local offsets = { today = 0, tomorrow = 1, yesterday = -1 }
local offset = offsets[cmd_id] or 0
local ps = string.format(
[[$ru = [System.Globalization.CultureInfo]::GetCultureInfo('ru-RU'); $d = (Get-Date).AddDays(%d); $d.ToString('dddd, dd MMMM yyyy', $ru)]],
offset
)
local res = jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"')))
local text = (res.stdout or ""):gsub("[\r\n]+$", "")
if text == "" then
jarvis.audio.play_error()
return { chain = false }
end
local labels = {
today = lang == "ru" and "Сегодня" or "Today",
tomorrow = lang == "ru" and "Завтра" or "Tomorrow",
yesterday = lang == "ru" and "Вчера" or "Yesterday",
}
local prefix = labels[cmd_id] or ""
jarvis.system.notify(prefix, text)
local say = prefix .. ", " .. text
local sapi = say:gsub("'", "''")
local sps = string.format(
[[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]],
sapi
)
jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', sps:gsub('"', '\\"')))
jarvis.audio.play_ok()
return { chain = false }

View file

@ -0,0 +1,37 @@
[[commands]]
id = "today"
type = "lua"
script = "answer.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["какой сегодня день", "какое сегодня число", "сегодня какое число", "что сегодня"]
en = ["what day is today", "what's today", "today date"]
ua = ["який сьогодні день", "яке сьогодні число"]
[[commands]]
id = "tomorrow"
type = "lua"
script = "answer.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["какой день завтра", "что завтра", "завтра какое число", "какое число завтра"]
en = ["what day is tomorrow", "what's tomorrow", "tomorrow date"]
ua = ["який день завтра", "яке число завтра"]
[[commands]]
id = "yesterday"
type = "lua"
script = "answer.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["какой был вчера день", "что было вчера", "вчера какое число было"]
en = ["what day was yesterday", "yesterday date"]
ua = ["який був вчора день"]

View file

@ -0,0 +1,37 @@
[[commands]]
id = "coin_flip"
type = "lua"
script = "roll.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["подбрось монетку", "брось монетку", "орёл или решка", "орел или решка"]
en = ["flip a coin", "toss a coin", "heads or tails"]
ua = ["підкинь монетку", "орел чи решка"]
[[commands]]
id = "roll_dice"
type = "lua"
script = "roll.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["брось кубик", "подбрось кубик", "кинь кубик"]
en = ["roll a dice", "roll the dice"]
ua = ["кинь кубик"]
[[commands]]
id = "random_number"
type = "lua"
script = "roll.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["случайное число", "выбери случайное число", "рандомное число"]
en = ["random number", "pick a random number"]
ua = ["випадкове число"]

View file

@ -0,0 +1,35 @@
local lang = jarvis.context.language
local cmd_id = jarvis.context.command_id
math.randomseed(os.time() + math.floor(jarvis.context.time.second * 7919))
local title, text, say
if cmd_id == "coin_flip" then
local r = math.random(2)
text = (r == 1) and (lang == "ru" and "Орёл" or "Heads") or (lang == "ru" and "Решка" or "Tails")
title = lang == "ru" and "Монетка" or "Coin"
say = text
elseif cmd_id == "roll_dice" then
local r = math.random(1, 6)
text = tostring(r)
title = lang == "ru" and "Кубик" or "Dice"
say = (lang == "ru" and "Выпало " or "Rolled ") .. r
else
local r = math.random(1, 100)
text = tostring(r)
title = lang == "ru" and "Случайное число" or "Random number"
say = (lang == "ru" and "Число " or "Number ") .. r
end
jarvis.system.notify(title, text)
local sapi = say:gsub("'", "''")
local sps = string.format(
[[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]],
sapi
)
jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', sps:gsub('"', '\\"')))
jarvis.audio.play_ok()
return { chain = false }

View file

@ -0,0 +1,27 @@
[[commands]]
id = "set_reminder"
type = "lua"
script = "set.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"напомни через",
"напомни мне через",
"поставь напоминание через",
"установи таймер на",
"таймер на",
"напомни",
]
en = [
"remind me in",
"set reminder in",
"set timer for",
"timer for",
]
ua = [
"нагадай через",
"постав нагадування через",
"таймер на",
]

View file

@ -0,0 +1,119 @@
local lang = jarvis.context.language
local phrase = (jarvis.context.phrase or ""):lower()
local triggers = {
"поставь напоминание через", "установи таймер на",
"напомни мне через", "напомни через", "напомни",
"remind me in", "set reminder in", "set timer for", "timer for",
"постав нагадування через", "нагадай через", "таймер на",
}
local rest = phrase
for _, t in ipairs(triggers) do
local s, e = string.find(rest, t, 1, true)
if s == 1 then
rest = rest:sub(e + 1)
break
end
end
rest = rest:gsub("^%s+", "")
local unit_words = {
["секунд"] = 1, ["секунду"] = 1, ["секунды"] = 1,
["second"] = 1, ["seconds"] = 1, ["sec"] = 1,
["минут"] = 60, ["минуту"] = 60, ["минуты"] = 60, ["мин"] = 60,
["minute"] = 60, ["minutes"] = 60, ["min"] = 60,
["час"] = 3600, ["часа"] = 3600, ["часов"] = 3600, ["часик"] = 3600,
["hour"] = 3600, ["hours"] = 3600, ["hr"] = 3600,
}
local rus_numerals = {
["один"] = 1, ["одну"] = 1, ["одна"] = 1, ["полторы"] = 1, ["полтора"] = 1,
["два"] = 2, ["две"] = 2, ["три"] = 3, ["четыре"] = 4, ["пять"] = 5,
["шесть"] = 6, ["семь"] = 7, ["восемь"] = 8, ["девять"] = 9, ["десять"] = 10,
["пятнадцать"] = 15, ["двадцать"] = 20, ["тридцать"] = 30, ["сорок"] = 40,
["пятьдесят"] = 50, ["час"] = 60, -- "через час" without number
}
local number, unit, body = nil, nil, nil
local num_str, rest_after_num = rest:match("^(%d+)%s+(.+)")
if num_str then
number = tonumber(num_str)
rest = rest_after_num
else
local first_word, after = rest:match("^(%S+)%s*(.*)")
if first_word and rus_numerals[first_word] then
number = rus_numerals[first_word]
rest = after or ""
end
end
if rest and #rest > 0 then
local first_word, after = rest:match("^(%S+)%s*(.*)")
if first_word then
for w, secs in pairs(unit_words) do
if first_word:sub(1, #w) == w then
unit = secs
body = after or ""
break
end
end
end
end
if not number then
number = 5
if not unit then unit = 60 end
body = rest
elseif not unit then
unit = 60
body = rest
end
local total_seconds = number * unit
if total_seconds < 1 then total_seconds = 1 end
if total_seconds > 86400 then total_seconds = 86400 end
body = (body or ""):gsub("^%s+", ""):gsub("%s+$", "")
if body == "" then
body = lang == "ru" and "Напоминание!" or "Reminder!"
end
local function human_time(sec)
if sec < 60 then return sec .. " сек" end
if sec < 3600 then return math.floor(sec/60) .. " мин" end
return string.format("%d ч %d мин", math.floor(sec/3600), math.floor((sec%3600)/60))
end
local title_esc = (lang == "ru" and "Напоминание J.A.R.V.I.S." or "J.A.R.V.I.S. reminder"):gsub("'", "''")
local body_esc = body:gsub("'", "''")
local sapi_text = ((lang == "ru" and "Сэр, напоминаю: " or "Reminder: ") .. body):gsub("'", "''"):gsub("\r?\n", " ")
local ps = string.format(
[[Start-Sleep -Seconds %d; ]]
.. [[$null = New-Object -ComObject Shell.Application; ]]
.. [[try { ]]
.. [[ Import-Module BurntToast -ErrorAction SilentlyContinue; ]]
.. [[ New-BurntToastNotification -Text '%s', '%s' -ErrorAction SilentlyContinue ]]
.. [[} catch {}; ]]
.. [[Add-Type -AssemblyName System.Speech; ]]
.. [[$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; ]]
.. [[foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } }; ]]
.. [[$s.Speak('%s'); ]]
.. [[$wsh = New-Object -ComObject WScript.Shell; $wsh.Popup('%s', 30, '%s', 64) | Out-Null]],
total_seconds, title_esc, body_esc, sapi_text, body_esc, title_esc
)
local cmd = string.format(
'powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -Command "%s"',
ps:gsub('"', '\\"')
)
jarvis.system.exec("start /b " .. cmd)
jarvis.system.notify(
lang == "ru" and "Таймер установлен" or "Timer set",
string.format(lang == "ru" and "Через %s: %s" or "In %s: %s", human_time(total_seconds), body)
)
jarvis.log("info", string.format("reminder in %ds: %s", total_seconds, body))
jarvis.audio.play_ok()
return { chain = false }

View file

@ -0,0 +1,37 @@
[[commands]]
id = "stopwatch_start"
type = "lua"
script = "ctl.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["засеки время", "запусти секундомер", "старт секундомер", "включи секундомер"]
en = ["start stopwatch", "start timer"]
ua = ["запусти секундомір"]
[[commands]]
id = "stopwatch_check"
type = "lua"
script = "ctl.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["сколько прошло", "сколько времени прошло", "проверь секундомер"]
en = ["how long has it been", "check stopwatch"]
ua = ["скільки пройшло"]
[[commands]]
id = "stopwatch_stop"
type = "lua"
script = "ctl.lua"
sandbox = "full"
timeout = 3000
[commands.phrases]
ru = ["стоп секундомер", "останови секундомер", "выключи секундомер"]
en = ["stop stopwatch", "stop timer"]
ua = ["стоп секундомір"]

View file

@ -0,0 +1,53 @@
local lang = jarvis.context.language
local cmd_id = jarvis.context.command_id
local function speak(text)
local sapi = text:gsub("'", "''"):gsub("\r?\n", " ")
local ps = string.format(
[[Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; foreach ($v in $s.GetInstalledVoices()) { if ($v.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'ru') { try { $s.SelectVoice($v.VoiceInfo.Name); break } catch {} } } $s.Speak('%s')]],
sapi
)
jarvis.system.exec(string.format('powershell -NoProfile -Command "%s"', ps:gsub('"', '\\"')))
end
local function format_elapsed(sec)
sec = math.floor(sec)
if sec < 60 then return sec .. (lang == "ru" and " секунд" or " seconds") end
if sec < 3600 then
local m = math.floor(sec / 60); local s = sec % 60
return string.format(lang == "ru" and "%d мин %d сек" or "%d min %d sec", m, s)
end
local h = math.floor(sec / 3600); local m = math.floor((sec % 3600) / 60); local s = sec % 60
return string.format(lang == "ru" and "%d ч %d мин %d сек" or "%d h %d m %d s", h, m, s)
end
if cmd_id == "stopwatch_start" then
jarvis.state.set("stopwatch_start_ts", jarvis.context.time.timestamp)
jarvis.system.notify("Stopwatch", lang == "ru" and "Старт" or "Started")
speak(lang == "ru" and "Секундомер запущен" or "Stopwatch started")
jarvis.audio.play_ok()
return { chain = false }
end
local started = jarvis.state.get("stopwatch_start_ts")
if not started then
jarvis.system.notify("Stopwatch", lang == "ru" and "Не запущен" or "Not running")
speak(lang == "ru" and "Секундомер не запущен" or "Stopwatch is not running")
jarvis.audio.play_not_found()
return { chain = false }
end
local elapsed = jarvis.context.time.timestamp - started
local human = format_elapsed(elapsed)
if cmd_id == "stopwatch_stop" then
jarvis.state.set("stopwatch_start_ts", nil)
jarvis.system.notify("Stopwatch", (lang == "ru" and "Прошло: " or "Elapsed: ") .. human)
speak((lang == "ru" and "Прошло " or "Elapsed ") .. human)
else
jarvis.system.notify("Stopwatch", (lang == "ru" and "Прошло: " or "Elapsed: ") .. human)
speak((lang == "ru" and "Прошло " or "Elapsed ") .. human)
end
jarvis.audio.play_ok()
return { chain = false }

View file

@ -0,0 +1,23 @@
[[commands]]
id = "type_text"
type = "lua"
script = "type.lua"
sandbox = "full"
timeout = 5000
[commands.phrases]
ru = [
"напечатай",
"набери текст",
"введи текст",
"впиши",
]
en = [
"type",
"type text",
"write text",
]
ua = [
"надрукуй",
"введи текст",
]

View file

@ -0,0 +1,46 @@
local lang = jarvis.context.language
local phrase = (jarvis.context.phrase or "")
local triggers = {
"набери текст", "введи текст",
"напечатай", "впиши",
"type text", "write text", "type",
"надрукуй", "введи текст",
}
local lower = phrase:lower()
local text = phrase
for _, t in ipairs(triggers) do
local s, e = string.find(lower, t, 1, true)
if s == 1 then
text = phrase:sub(e + 1)
break
end
end
text = text:gsub("^%s+", ""):gsub("%s+$", "")
if text == "" then
jarvis.system.notify("Type", lang == "ru" and "Что напечатать?" or "What to type?")
jarvis.audio.play_error()
return { chain = false }
end
jarvis.system.clipboard.set(text)
jarvis.sleep(120)
local ps = [[Add-Type -Name K -Namespace W -MemberDefinition '[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte sc, uint flags, System.UIntPtr extra);'; ]]
.. [[[W.K]::keybd_event(0x11, 0, 0, [UIntPtr]::Zero); ]]
.. [[[W.K]::keybd_event(0x56, 0, 0, [UIntPtr]::Zero); ]]
.. [[Start-Sleep -Milliseconds 30; ]]
.. [[[W.K]::keybd_event(0x56, 0, 2, [UIntPtr]::Zero); ]]
.. [[[W.K]::keybd_event(0x11, 0, 2, [UIntPtr]::Zero)]]
local cmd = string.format('powershell -NoProfile -ExecutionPolicy Bypass -Command "%s"', ps:gsub('"', '\\"'))
local res = jarvis.system.exec(cmd)
if res.success then
jarvis.audio.play_ok()
else
jarvis.log("error", "voice type failed: " .. tostring(res.stderr))
jarvis.audio.play_error()
end
return { chain = false }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Before After
Before After