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>
This commit is contained in:
krypticmouse
2026-03-28 23:39:35 +00:00
co-authored by Claude Opus 4.6
parent eea5f65ea0
commit e3039c0d0a
+178 -60
View File
@@ -1,8 +1,8 @@
use std::sync::Arc;
use std::time::Duration;
use tauri::Manager;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher;
use tokio::sync::Mutex;
@@ -127,9 +127,18 @@ fn resolve_bin(name: &str) -> String {
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
@@ -144,6 +153,41 @@ fn resolve_bin(name: &str) -> String {
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()
}
@@ -623,14 +667,19 @@ async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
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);
"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() {
@@ -720,9 +769,7 @@ fn api_base() -> String {
}
#[tauri::command]
async fn get_setup_status(
state: tauri::State<'_, SharedStatus>,
) -> Result<SetupStatus, String> {
async fn get_setup_status(state: tauri::State<'_, SharedStatus>) -> Result<SetupStatus, String> {
Ok(state.lock().await.clone())
}
@@ -743,83 +790,132 @@ async fn start_backend(
}
#[tauri::command]
async fn stop_backend(
backend: tauri::State<'_, SharedBackend>,
) -> Result<(), String> {
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 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))
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 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))
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 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))
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 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))
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 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))
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 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))
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 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))
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 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))
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
@@ -828,7 +924,11 @@ async fn search_memory(
query: String,
top_k: u32,
) -> Result<serde_json::Value, String> {
let base = if api_url.is_empty() { api_base() } else { api_url };
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))
@@ -836,25 +936,39 @@ async fn search_memory(
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
resp.json().await.map_err(|e| format!("Invalid response: {}", 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 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))
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 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))
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
#[tauri::command]
@@ -877,11 +991,17 @@ async fn run_jarvis_command(args: Vec<String>) -> Result<String, 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 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))
resp.json()
.await
.map_err(|e| format!("Invalid response: {}", e))
}
/// Transcribe audio via the speech API endpoint.
@@ -926,7 +1046,10 @@ async fn submit_savings(
}
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/rest/v1/savings_entries?on_conflict=anon_id", supabase_url))
.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))
@@ -992,8 +1115,7 @@ async fn save_cloud_key(key_name: String, key_value: String) -> Result<(), Strin
.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))?;
std::fs::write(&path, content + "\n").map_err(|e| format!("Failed to save key: {}", e))?;
// Set permissions to owner-only (chmod 600)
#[cfg(unix)]
@@ -1090,13 +1212,11 @@ pub fn run() {
}))
.setup(move |app| {
// System tray
let show = MenuItemBuilder::with_id("show", "Show / Hide")
.build(app)?;
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 quit = MenuItemBuilder::with_id("quit", "Quit OpenJarvis").build(app)?;
let menu = MenuBuilder::new(app)
.item(&show)
@@ -1110,23 +1230,21 @@ pub fn run() {
.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();
}
.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);
}
_ => {}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.build(app)?;