Files
OpenJarvis/desktop/src-tauri/src/lib.rs
T
krypticmouseandClaude Opus 4.6 e3039c0d0a fix: Windows git detection in desktop app resolve_bin()
The Tauri desktop app's resolve_bin("git") function checked incorrect
paths for Git on Windows (e.g. {ProgramFiles}\git\git.exe) and had no
PATH fallback, causing "git not found" even when Git is installed.

Fixes:
- Add correct Git for Windows paths: {ProgramFiles}\Git\cmd\git.exe,
  {ProgramFiles(x86)}\Git\cmd\git.exe, {LOCALAPPDATA}\Programs\Git\cmd\
- Add Scoop package manager path: {home}\scoop\shims\git.exe
- Add PATH fallback using `where.exe` on Windows and `which` on Unix,
  so any binary on PATH is found even if not at a hardcoded location

Closes #128

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 23:39:35 +00:00

1293 lines
41 KiB
Rust

use std::sync::Arc;
use std::time::Duration;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher;
use tokio::sync::Mutex;
const OLLAMA_PORT: u16 = 11434;
const JARVIS_PORT: u16 = 8222;
/// Small, fast model pulled at startup so the app opens quickly.
const STARTUP_MODEL: &str = "qwen3.5:4b";
/// Tiny fallback model if even the startup model can't be pulled.
const FALLBACK_MODEL: &str = "qwen3:0.6b";
/// Qwen3.5 model variants, ordered smallest to largest.
/// Each entry is (ollama_tag, approximate_download_size_gb, min_ram_gb).
const QWEN35_MODELS: &[(&str, f64, f64)] = &[
("qwen3.5:0.8b", 1.0, 4.0),
("qwen3.5:2b", 2.7, 6.0),
("qwen3.5:4b", 3.4, 8.0),
("qwen3.5:9b", 6.6, 12.0),
("qwen3.5:27b", 17.0, 24.0),
("qwen3.5:35b", 24.0, 32.0),
("qwen3.5:122b", 81.0, 96.0),
];
/// Get total system RAM in GB.
fn total_ram_gb() -> f64 {
#[cfg(target_os = "macos")]
{
use std::process::Command;
if let Ok(output) = Command::new("sysctl").args(["-n", "hw.memsize"]).output() {
if let Ok(s) = String::from_utf8(output.stdout) {
if let Ok(bytes) = s.trim().parse::<u64>() {
return bytes as f64 / (1024.0 * 1024.0 * 1024.0);
}
}
}
}
#[cfg(target_os = "linux")]
{
if let Ok(contents) = std::fs::read_to_string("/proc/meminfo") {
for line in contents.lines() {
if line.starts_with("MemTotal:") {
if let Some(kb_str) = line.split_whitespace().nth(1) {
if let Ok(kb) = kb_str.parse::<u64>() {
return kb as f64 / (1024.0 * 1024.0);
}
}
}
}
}
}
#[cfg(target_os = "windows")]
{
use std::process::Command;
// wmic returns TotalVisibleMemorySize in KB
if let Ok(output) = Command::new("wmic")
.args(["OS", "get", "TotalVisibleMemorySize", "/value"])
.output()
{
if let Ok(s) = String::from_utf8(output.stdout) {
for line in s.lines() {
if let Some(val) = line.strip_prefix("TotalVisibleMemorySize=") {
if let Ok(kb) = val.trim().parse::<u64>() {
return kb as f64 / (1024.0 * 1024.0);
}
}
}
}
}
}
8.0
}
/// Return the list of Qwen3.5 models that fit on this machine, smallest first.
fn models_that_fit() -> Vec<&'static str> {
let ram = total_ram_gb();
QWEN35_MODELS
.iter()
.filter(|(_, _, min_ram)| ram >= *min_ram)
.map(|(tag, _, _)| *tag)
.collect()
}
/// Pick the default model — prefers STARTUP_MODEL if it fits, otherwise
/// falls back to the third-largest model that fits on this machine.
fn preferred_model() -> &'static str {
let fitting = models_that_fit();
// Prefer STARTUP_MODEL when it fits (fast, good quality)
if fitting.contains(&STARTUP_MODEL) {
return STARTUP_MODEL;
}
match fitting.len() {
0 => FALLBACK_MODEL,
1 => fitting[0],
2 => fitting[0],
n => fitting[n - 3], // third-largest
}
}
/// Get the user home directory, handling both Unix (HOME) and Windows (USERPROFILE).
fn home_dir() -> String {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default()
}
/// Resolve full path to a binary by checking common locations.
/// macOS .app bundles don't inherit the shell PATH, so we probe manually.
fn resolve_bin(name: &str) -> String {
let home = home_dir();
#[cfg(not(target_os = "windows"))]
let candidates = vec![
format!("/opt/homebrew/bin/{name}"),
format!("{home}/.local/bin/{name}"),
format!("{home}/.cargo/bin/{name}"),
format!("/usr/local/bin/{name}"),
format!("/usr/bin/{name}"),
];
#[cfg(target_os = "windows")]
let candidates = {
let localappdata = std::env::var("LOCALAPPDATA").unwrap_or_default();
let programfiles = std::env::var("ProgramFiles").unwrap_or_default();
let programfiles_x86 = std::env::var("ProgramFiles(x86)").unwrap_or_default();
vec![
// Git for Windows — standard install paths
format!("{programfiles}\\Git\\cmd\\{name}.exe"),
format!("{programfiles_x86}\\Git\\cmd\\{name}.exe"),
format!("{localappdata}\\Programs\\Git\\cmd\\{name}.exe"),
// Scoop package manager
format!("{home}\\scoop\\shims\\{name}.exe"),
// Cargo, local bin
format!("{home}\\.cargo\\bin\\{name}.exe"),
format!("{home}\\.local\\bin\\{name}.exe"),
// Generic program locations
format!("{localappdata}\\Programs\\{name}\\{name}.exe"),
format!("{programfiles}\\{name}\\{name}.exe"),
// Ollama installs to LOCALAPPDATA on Windows
format!("{localappdata}\\Programs\\Ollama\\{name}.exe"),
// uv installs via pip/pipx
format!("{home}\\AppData\\Roaming\\Python\\Scripts\\{name}.exe"),
]
};
for path in &candidates {
if std::path::Path::new(path).exists() {
return path.clone();
}
}
// Fallback: ask the OS to find it on PATH.
// On Windows this uses `where.exe`, on Unix `which`.
#[cfg(target_os = "windows")]
{
if let Ok(output) = std::process::Command::new("where")
.arg(format!("{name}.exe"))
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(first_line) = stdout.lines().next() {
let p = first_line.trim();
if !p.is_empty() && std::path::Path::new(p).exists() {
return p.to_string();
}
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
if let Ok(output) = std::process::Command::new("which").arg(name).output() {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(first_line) = stdout.lines().next() {
let p = first_line.trim();
if !p.is_empty() && std::path::Path::new(p).exists() {
return p.to_string();
}
}
}
}
}
name.to_string()
}
/// Find the OpenJarvis project root (contains pyproject.toml).
/// Checks OPENJARVIS_ROOT env var, walks up from the executable, then
/// probes common clone locations.
fn find_project_root() -> Option<std::path::PathBuf> {
// 1. Explicit env var override
if let Ok(root) = std::env::var("OPENJARVIS_ROOT") {
let path = std::path::PathBuf::from(&root);
if path.join("pyproject.toml").exists() {
return Some(path);
}
}
// 2. Walk up from the running executable (works in dev and .app bundle)
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(|p| p.to_path_buf());
for _ in 0..8 {
if let Some(ref d) = dir {
if d.join("pyproject.toml").exists() {
return Some(d.clone());
}
dir = d.parent().map(|p| p.to_path_buf());
}
}
}
// 3. Fallback: well-known direct paths
let home = home_dir();
let direct = [
format!("{home}/OpenJarvis"),
format!("{home}/projects/hazy/OpenJarvis"),
format!("{home}/projects/OpenJarvis"),
format!("{home}/src/OpenJarvis"),
format!("{home}/Documents/OpenJarvis"),
format!("{home}/Desktop/OpenJarvis"),
format!("{home}/Developer/OpenJarvis"),
format!("{home}/dev/OpenJarvis"),
format!("{home}/Code/OpenJarvis"),
format!("{home}/code/OpenJarvis"),
format!("{home}/repos/OpenJarvis"),
format!("{home}/github/OpenJarvis"),
];
for p in &direct {
let path = std::path::PathBuf::from(p);
if path.join("pyproject.toml").exists() {
return Some(path);
}
}
// 4. Shallow scan: look for OpenJarvis one level inside common parent dirs.
// This catches clones like ~/Documents/my-stuff/OpenJarvis without
// needing to enumerate every possible intermediate folder.
let scan_parents = [
format!("{home}/Documents"),
format!("{home}/Desktop"),
format!("{home}/Developer"),
format!("{home}/projects"),
format!("{home}/repos"),
format!("{home}/src"),
format!("{home}/Code"),
format!("{home}/code"),
format!("{home}/dev"),
format!("{home}/github"),
];
for parent in &scan_parents {
let parent_path = std::path::PathBuf::from(parent);
if let Ok(entries) = std::fs::read_dir(&parent_path) {
for entry in entries.flatten() {
let candidate = entry.path().join("OpenJarvis");
if candidate.join("pyproject.toml").exists() {
return Some(candidate);
}
// Also check if the entry itself is OpenJarvis (case-insensitive match)
if let Some(name) = entry.file_name().to_str() {
if name.eq_ignore_ascii_case("openjarvis")
&& entry.path().join("pyproject.toml").exists()
{
return Some(entry.path());
}
}
}
}
}
None
}
// ---------------------------------------------------------------------------
// BackendManager — owns the Ollama + Jarvis server child processes
// ---------------------------------------------------------------------------
struct ChildHandle {
child: tokio::process::Child,
}
impl ChildHandle {
async fn kill(&mut self) {
let _ = self.child.kill().await;
}
}
#[derive(Default)]
struct BackendManager {
ollama: Option<ChildHandle>,
jarvis: Option<ChildHandle>,
}
impl BackendManager {
async fn stop_all(&mut self) {
if let Some(ref mut h) = self.jarvis {
h.kill().await;
}
self.jarvis = None;
if let Some(ref mut h) = self.ollama {
h.kill().await;
}
self.ollama = None;
}
}
type SharedBackend = Arc<Mutex<BackendManager>>;
// ---------------------------------------------------------------------------
// Setup status (reported to frontend)
// ---------------------------------------------------------------------------
#[derive(serde::Serialize, Clone)]
struct SetupStatus {
phase: String,
detail: String,
ollama_ready: bool,
server_ready: bool,
model_ready: bool,
error: Option<String>,
}
impl Default for SetupStatus {
fn default() -> Self {
Self {
phase: "starting".into(),
detail: "Initializing...".into(),
ollama_ready: false,
server_ready: false,
model_ready: false,
error: None,
}
}
}
type SharedStatus = Arc<Mutex<SetupStatus>>;
// ---------------------------------------------------------------------------
// Health-check helpers
// ---------------------------------------------------------------------------
async fn wait_for_url(url: &str, timeout: Duration) -> bool {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.unwrap();
let deadline = tokio::time::Instant::now() + timeout;
while tokio::time::Instant::now() < deadline {
if let Ok(resp) = client.get(url).send().await {
if resp.status().is_success() {
return true;
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
false
}
async fn ollama_has_model(model: &str) -> bool {
let url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
if let Ok(resp) = client.get(&url).send().await {
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(models) = body.get("models").and_then(|m| m.as_array()) {
return models.iter().any(|m| {
m.get("name")
.and_then(|n| n.as_str())
.map(|n| {
n == model
|| n.strip_suffix(":latest") == Some(model)
|| model.strip_suffix(":latest") == Some(n)
})
.unwrap_or(false)
});
}
}
}
false
}
async fn pull_model(model: &str) -> Result<(), String> {
let url = format!("http://127.0.0.1:{}/api/pull", OLLAMA_PORT);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(600))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.post(&url)
.json(&serde_json::json!({"name": model, "stream": false}))
.send()
.await
.map_err(|e| format!("Pull request failed: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Pull returned status {}", resp.status()));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Backend boot sequence (runs in background after app launch)
// ---------------------------------------------------------------------------
async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
// Phase 1: Start Ollama
{
let mut s = status.lock().await;
s.phase = "ollama".into();
s.detail = "Starting inference engine...".into();
}
// Try the bundled sidecar first, fall back to system ollama
let ollama_child = {
let ollama_bin = resolve_bin("ollama");
let sidecar = tokio::process::Command::new(&ollama_bin)
.arg("serve")
.env("OLLAMA_HOST", format!("127.0.0.1:{}", OLLAMA_PORT))
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
match sidecar {
Ok(child) => Some(child),
Err(_) => None,
}
};
if let Some(child) = ollama_child {
backend.lock().await.ollama = Some(ChildHandle { child });
}
let ollama_url = format!("http://127.0.0.1:{}/api/tags", OLLAMA_PORT);
let ollama_ok = wait_for_url(&ollama_url, Duration::from_secs(30)).await;
if !ollama_ok {
let mut s = status.lock().await;
s.error = Some("Could not start Ollama. Install it from https://ollama.com".into());
return;
}
{
let mut s = status.lock().await;
s.ollama_ready = true;
s.detail = "Inference engine ready.".into();
}
// Phase 2: Pull one small model (qwen3.5:2b) so the app can open fast.
// Remaining models are pulled in the background after the server starts.
{
let mut s = status.lock().await;
s.phase = "model".into();
s.detail = format!("Checking for {}...", STARTUP_MODEL);
}
if !ollama_has_model(STARTUP_MODEL).await {
{
let mut s = status.lock().await;
s.detail = format!("Downloading {}... (this may take a minute)", STARTUP_MODEL);
}
if let Err(e) = pull_model(STARTUP_MODEL).await {
// If the startup model fails, try the tiny fallback
eprintln!("Warning: failed to pull {}: {}", STARTUP_MODEL, e);
if !ollama_has_model(FALLBACK_MODEL).await {
let mut s = status.lock().await;
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
drop(s);
if let Err(e2) = pull_model(FALLBACK_MODEL).await {
let mut s = status.lock().await;
s.error = Some(format!("Failed to download model: {}", e2));
return;
}
}
}
}
{
let mut s = status.lock().await;
s.model_ready = true;
s.detail = "Model ready.".into();
}
// Phase 3: Start jarvis serve
{
let mut s = status.lock().await;
s.phase = "server".into();
s.detail = "Starting API server...".into();
}
let uv_bin = resolve_bin("uv");
// Verify uv is actually installed
if !std::path::Path::new(&uv_bin).exists() && uv_bin == "uv" {
let mut s = status.lock().await;
s.error = Some(
"Could not find 'uv' (Python package manager). \
Install it from https://astral.sh/uv then relaunch."
.into(),
);
return;
}
let mut project_root = find_project_root();
if project_root.is_none() {
// Auto-clone on first launch
let git_bin = resolve_bin("git");
// Check that git is installed
if !std::path::Path::new(&git_bin).exists() && git_bin == "git" {
let mut s = status.lock().await;
s.error = Some(
"Could not find 'git'. \
Install it from https://git-scm.com then relaunch."
.into(),
);
return;
}
let target_path = std::path::PathBuf::from(home_dir()).join("OpenJarvis");
let clone_target = target_path.display().to_string();
// If the directory exists but is not a valid project, don't overwrite
if target_path.exists() && !target_path.join("pyproject.toml").exists() {
let mut s = status.lock().await;
s.error = Some(format!(
"{} exists but is not a valid OpenJarvis project. \
Remove it and relaunch, or set OPENJARVIS_ROOT to the correct path.",
clone_target,
));
return;
}
{
let mut s = status.lock().await;
s.detail = "Downloading OpenJarvis (first launch)...".into();
}
let clone_result = tokio::process::Command::new(&git_bin)
.args([
"clone",
"--depth",
"1",
"https://github.com/open-jarvis/OpenJarvis.git",
&clone_target,
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn();
match clone_result {
Ok(child) => match child.wait_with_output().await {
Ok(output) if output.status.success() => {
project_root = Some(target_path);
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let mut s = status.lock().await;
s.error = Some(format!(
"Failed to download OpenJarvis: {}. \
Clone manually: git clone https://github.com/open-jarvis/OpenJarvis.git {}",
stderr.trim(),
clone_target,
));
return;
}
Err(e) => {
let mut s = status.lock().await;
s.error = Some(format!(
"Failed to download OpenJarvis: {}. \
Clone manually: git clone https://github.com/open-jarvis/OpenJarvis.git {}",
e, clone_target,
));
return;
}
},
Err(e) => {
let mut s = status.lock().await;
s.error = Some(format!(
"Could not run git: {}. \
Install git from https://git-scm.com then relaunch.",
e,
));
return;
}
}
}
// Kill any leftover server on our port from a previous run
{
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.unwrap();
if client
.get(format!("http://127.0.0.1:{}/health", JARVIS_PORT))
.send()
.await
.is_ok()
{
// Something is already listening — try to kill it
#[cfg(unix)]
{
let _ = tokio::process::Command::new("fuser")
.args(["-k", &format!("{}/tcp", JARVIS_PORT)])
.output()
.await;
tokio::time::sleep(Duration::from_secs(2)).await;
}
#[cfg(target_os = "windows")]
{
// Find the PID holding the port via netstat, then kill it
if let Ok(output) = tokio::process::Command::new("cmd")
.args(["/C", &format!(
"for /f \"tokens=5\" %a in ('netstat -ano ^| findstr :{port} ^| findstr LISTENING') do taskkill /PID %a /F",
port = JARVIS_PORT,
)])
.output()
.await
{
let _ = output; // best-effort
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
}
// Start with STARTUP_MODEL (just pulled) or preferred if already available.
let pref = preferred_model();
let startup_model = if ollama_has_model(pref).await {
pref
} else if ollama_has_model(STARTUP_MODEL).await {
STARTUP_MODEL
} else {
FALLBACK_MODEL
};
let root = project_root.as_ref().unwrap();
// Install dependencies automatically (handles fresh clones)
{
let mut s = status.lock().await;
s.detail = "Installing dependencies...".into();
}
let _ = tokio::process::Command::new(&uv_bin)
.args(["sync", "--extra", "server"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.current_dir(root)
.status()
.await;
{
let mut s = status.lock().await;
s.detail = format!(
"Starting server with {} from {}...",
startup_model,
root.display(),
);
}
let mut cmd = tokio::process::Command::new(&uv_bin);
cmd.args([
"run",
"jarvis",
"serve",
"--port",
&JARVIS_PORT.to_string(),
"--model",
startup_model,
"--agent",
"simple",
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.current_dir(root);
// Inject cloud API keys from ~/.openjarvis/cloud-keys.env
for (key, value) in read_cloud_keys() {
cmd.env(&key, &value);
}
let jarvis_child = cmd.spawn();
match jarvis_child {
Ok(child) => {
backend.lock().await.jarvis = Some(ChildHandle { child });
}
Err(e) => {
let mut s = status.lock().await;
s.error = Some(format!(
"Could not start jarvis server: {}. \
Make sure uv is installed (https://astral.sh/uv) and the OpenJarvis repo is cloned at {}",
e,
root.display(),
));
return;
}
}
let server_url = format!("http://127.0.0.1:{}/health", JARVIS_PORT);
let server_ok = wait_for_url(&server_url, Duration::from_secs(600)).await;
if !server_ok {
// Try to read stderr from the failed process for a useful error
let mut stderr_msg = String::new();
{
let mut mgr = backend.lock().await;
if let Some(ref mut h) = mgr.jarvis {
if let Some(ref mut stderr) = h.child.stderr.take() {
use tokio::io::AsyncReadExt;
let mut buf = vec![0u8; 4096];
if let Ok(n) = stderr.read(&mut buf).await {
stderr_msg = String::from_utf8_lossy(&buf[..n]).to_string();
}
}
}
}
let detail = if stderr_msg.is_empty() {
format!(
"Jarvis server did not start. Check that:\n\
1. uv is installed ({})\n\
2. The OpenJarvis repo is at {}\n\
3. Run 'uv sync' in that directory",
uv_bin,
root.display(),
)
} else {
format!("Server failed to start: {}", stderr_msg.trim())
};
let mut s = status.lock().await;
s.error = Some(detail);
return;
}
{
let mut s = status.lock().await;
s.server_ready = true;
s.phase = "ready".into();
s.detail = "All systems ready.".into();
}
// Phase 4: Pull remaining Qwen3.5 models in the background.
// The app is already usable with qwen3.5:2b; as each model finishes
// it appears in the model list automatically.
let fitting = models_that_fit();
tokio::spawn(async move {
for model in fitting {
if model != STARTUP_MODEL && model != FALLBACK_MODEL {
if !ollama_has_model(model).await {
let _ = pull_model(model).await;
}
}
}
});
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
fn api_base() -> String {
format!("http://127.0.0.1:{}", JARVIS_PORT)
}
#[tauri::command]
async fn get_setup_status(state: tauri::State<'_, SharedStatus>) -> Result<SetupStatus, String> {
Ok(state.lock().await.clone())
}
#[tauri::command]
fn get_api_base() -> String {
api_base()
}
#[tauri::command]
async fn start_backend(
backend: tauri::State<'_, SharedBackend>,
status: tauri::State<'_, SharedStatus>,
) -> Result<(), String> {
let b = backend.inner().clone();
let s = status.inner().clone();
tauri::async_runtime::spawn(boot_backend(b, s));
Ok(())
}
#[tauri::command]
async fn stop_backend(backend: tauri::State<'_, SharedBackend>) -> Result<(), String> {
backend.lock().await.stop_all().await;
Ok(())
}
#[tauri::command]
async fn check_health(api_url: String) -> Result<serde_json::Value, String> {
let url = format!(
"{}/health",
if api_url.is_empty() {
api_base()
} else {
api_url
}
);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn fetch_energy(api_url: String) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() {
api_base()
} else {
api_url
};
let resp = reqwest::get(format!("{}/v1/telemetry/energy", base))
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn fetch_telemetry(api_url: String) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() {
api_base()
} else {
api_url
};
let resp = reqwest::get(format!("{}/v1/telemetry/stats", base))
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn fetch_traces(api_url: String, limit: u32) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() {
api_base()
} else {
api_url
};
let resp = reqwest::get(format!("{}/v1/traces?limit={}", base, limit))
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn fetch_trace(api_url: String, trace_id: String) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() {
api_base()
} else {
api_url
};
let resp = reqwest::get(format!("{}/v1/traces/{}", base, trace_id))
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn fetch_learning_stats(api_url: String) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() {
api_base()
} else {
api_url
};
let resp = reqwest::get(format!("{}/v1/learning/stats", base))
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn fetch_learning_policy(api_url: String) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() {
api_base()
} else {
api_url
};
let resp = reqwest::get(format!("{}/v1/learning/policy", base))
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn fetch_memory_stats(api_url: String) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() {
api_base()
} else {
api_url
};
let resp = reqwest::get(format!("{}/v1/memory/stats", base))
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn search_memory(
api_url: String,
query: String,
top_k: u32,
) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() {
api_base()
} else {
api_url
};
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/memory/search", base))
.json(&serde_json::json!({"query": query, "top_k": top_k}))
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn fetch_agents(api_url: String) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() {
api_base()
} else {
api_url
};
let resp = reqwest::get(format!("{}/v1/agents", base))
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn fetch_models(api_url: String) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() {
api_base()
} else {
api_url
};
let resp = reqwest::get(format!("{}/v1/models", base))
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
async fn run_jarvis_command(args: Vec<String>) -> Result<String, String> {
let mut cmd_args = vec!["run".to_string(), "jarvis".to_string()];
cmd_args.extend(args);
let uv_bin = resolve_bin("uv");
let output = tokio::process::Command::new(&uv_bin)
.args(&cmd_args)
.output()
.await
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&output.stderr).to_string())
}
}
#[tauri::command]
async fn fetch_savings(api_url: String) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() {
api_base()
} else {
api_url
};
let resp = reqwest::get(format!("{}/v1/savings", base))
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
/// Transcribe audio via the speech API endpoint.
#[tauri::command]
async fn transcribe_audio(
api_url: String,
audio_data: Vec<u8>,
filename: String,
) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/speech/transcribe", api_url);
let client = reqwest::Client::new();
let part = reqwest::multipart::Part::bytes(audio_data)
.file_name(filename)
.mime_str("audio/webm")
.map_err(|e| format!("Failed to create multipart: {}", e))?;
let form = reqwest::multipart::Form::new().part("file", part);
let resp = client
.post(&url)
.multipart(form)
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
/// Submit savings to Supabase leaderboard.
#[tauri::command]
async fn submit_savings(
supabase_url: String,
supabase_key: String,
payload: serde_json::Value,
) -> Result<bool, String> {
if supabase_url.is_empty() || supabase_key.is_empty() {
return Ok(false);
}
let client = reqwest::Client::new();
let resp = client
.post(format!(
"{}/rest/v1/savings_entries?on_conflict=anon_id",
supabase_url
))
.header("Content-Type", "application/json")
.header("apikey", &supabase_key)
.header("Authorization", format!("Bearer {}", supabase_key))
.header("Prefer", "resolution=merge-duplicates")
.json(&payload)
.send()
.await
.map_err(|e| format!("Supabase POST failed: {}", e))?;
Ok(resp.status().is_success())
}
// ---------------------------------------------------------------------------
// Cloud API key management
// ---------------------------------------------------------------------------
/// Path to the cloud keys file (~/.openjarvis/cloud-keys.env).
fn cloud_keys_path() -> std::path::PathBuf {
let home = home_dir();
std::path::PathBuf::from(home)
.join(".openjarvis")
.join("cloud-keys.env")
}
/// Read cloud keys from disk and return as key=value pairs.
fn read_cloud_keys() -> Vec<(String, String)> {
let path = cloud_keys_path();
let mut keys = Vec::new();
if let Ok(contents) = std::fs::read_to_string(&path) {
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
keys.push((k.trim().to_string(), v.trim().to_string()));
}
}
}
keys
}
/// Save a single cloud API key to the keys file.
#[tauri::command]
async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), String> {
let path = cloud_keys_path();
// Ensure directory exists
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// Read existing keys, update/add the one being saved
let mut keys: Vec<(String, String)> = read_cloud_keys()
.into_iter()
.filter(|(k, _)| k != &key_name)
.collect();
if !key_value.is_empty() {
keys.push((key_name, key_value));
}
// Write back
let content: String = keys
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(&path, content + "\n").map_err(|e| format!("Failed to save key: {}", e))?;
// Set permissions to owner-only (chmod 600)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
/// Get which cloud providers have keys configured (without exposing values).
#[tauri::command]
async fn get_cloud_key_status() -> Result<serde_json::Value, String> {
let keys = read_cloud_keys();
let status: Vec<serde_json::Value> = keys
.iter()
.map(|(k, v)| serde_json::json!({ "key": k, "set": !v.is_empty() }))
.collect();
Ok(serde_json::json!(status))
}
/// Pull a model via Ollama (called from frontend download button).
#[tauri::command]
async fn pull_ollama_model(model_name: String) -> Result<serde_json::Value, String> {
pull_model(&model_name)
.await
.map_err(|e| format!("Failed to pull {}: {}", model_name, e))?;
Ok(serde_json::json!({"status": "ok", "model": model_name}))
}
/// Delete a model from Ollama.
#[tauri::command]
async fn delete_ollama_model(model_name: String) -> Result<serde_json::Value, String> {
let url = format!("http://127.0.0.1:{}/api/delete", OLLAMA_PORT);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.delete(&url)
.json(&serde_json::json!({"name": model_name}))
.send()
.await
.map_err(|e| format!("Delete failed: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Delete returned status {}", resp.status()));
}
Ok(serde_json::json!({"status": "deleted", "model": model_name}))
}
/// Check speech backend health.
#[tauri::command]
async fn speech_health(api_url: String) -> Result<serde_json::Value, String> {
let url = format!("{}/v1/speech/health", api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| format!("Connection failed: {}", e))?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Invalid response: {}", e))?;
Ok(body)
}
// ---------------------------------------------------------------------------
// App entry point
// ---------------------------------------------------------------------------
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let backend: SharedBackend = Arc::new(Mutex::new(BackendManager::default()));
let status: SharedStatus = Arc::new(Mutex::new(SetupStatus::default()));
let boot_backend_ref = backend.clone();
let boot_status_ref = status.clone();
tauri::Builder::default()
.manage(backend.clone())
.manage(status.clone())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
Some(vec!["--hidden"]),
))
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
}))
.setup(move |app| {
// System tray
let show = MenuItemBuilder::with_id("show", "Show / Hide").build(app)?;
let health = MenuItemBuilder::with_id("health", "Health: starting...")
.enabled(false)
.build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit OpenJarvis").build(app)?;
let menu = MenuBuilder::new(app)
.item(&show)
.separator()
.item(&health)
.separator()
.item(&quit)
.build()?;
let _tray = TrayIconBuilder::with_id("main")
.icon(app.default_window_icon().unwrap().clone())
.tooltip("OpenJarvis")
.menu(&menu)
.on_menu_event(move |app, event| match event.id().as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.build(app)?;
// Auto-start backend services on launch
tauri::async_runtime::spawn(boot_backend(boot_backend_ref, boot_status_ref));
Ok(())
})
.invoke_handler(tauri::generate_handler![
get_setup_status,
get_api_base,
start_backend,
stop_backend,
check_health,
fetch_energy,
fetch_telemetry,
fetch_traces,
fetch_trace,
fetch_learning_stats,
fetch_learning_policy,
fetch_memory_stats,
search_memory,
fetch_agents,
fetch_models,
run_jarvis_command,
fetch_savings,
submit_savings,
transcribe_audio,
speech_health,
pull_ollama_model,
delete_ollama_model,
save_cloud_key,
get_cloud_key_status,
])
.build(tauri::generate_context!())
.expect("error while building OpenJarvis Desktop")
.run(move |_app, event| {
if let tauri::RunEvent::ExitRequested { .. } = event {
let b = backend.clone();
tauri::async_runtime::spawn(async move {
b.lock().await.stop_all().await;
});
}
});
}