mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-31 03:12:16 +00:00
* fix(windows): desktop backend spawn (#531) + model-aware engine selection (#532) Two runtime bugs found during end-to-end testing on a clean Windows 11 24H2 Azure VM. #531 - Desktop "Failed to get response": run_jarvis_command spawned the backend with .output(), which waits for the process to exit. `jarvis serve` never exits, so the Tauri command hung forever (the Start button never resolved); and it ran `uv run jarvis` with no cwd, so in a packaged install -- where the cwd isn't the checkout -- `jarvis` wasn't found and the server never started. Now: run from find_project_root(), and for `serve` spawn detached (.spawn()), drain stderr, and poll /health for readiness (mirrors start_backend); short commands keep .output(). The server layer itself was verified healthy on Windows (/health and /v1/chat/completions both 200, localhost included) -- the fault was the Tauri spawn path. #532 - "OpenAI client not available" after reboot: when the local engine is down, get_engine's fallback selected CloudEngine because health() is True if ANY provider client exists -- without checking the resolved model's provider has a client. A user with e.g. OPENROUTER_API_KEY and a gpt-* model then hit the OpenAI path with no client. Add CloudEngine.can_serve(model) (checks the specific provider client via the same routing generate()/stream() use) + a default can_serve->True on the base engine, and make get_engine model-aware so it skips an engine that can't serve the model -- the user falls through to the helpful "no engine available / start ollama" message instead. Tests: engine discovery/cloud/model-matrix + cli serve/ask suites pass (the one ask_e2e failure is a pre-existing version-banner flake, fails identically on main). The Tauri crate couldn't be compiled locally (no GTK/webkit sys-libs in this env); relies on CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(engine): cover model-aware engine selection + CloudEngine.can_serve (#532) #533 added a `model` arg to get_engine and a can_serve() gate but shipped no tests. Add them: - get_engine skips a healthy engine that can't serve the requested model (the cloud-fallback-for-unservable-model case behind #532), - model=None preserves the legacy model-agnostic selection, - CloudEngine.can_serve gates on the per-provider client (gpt->OpenAI, claude->Anthropic, ...), verified empirically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
2798 lines
104 KiB
Rust
2798 lines
104 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 = 8000;
|
|
|
|
/// 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 Qwen3.5 models that fit in `ram_gb`, smallest first.
|
|
fn models_that_fit_in(ram_gb: f64) -> Vec<&'static str> {
|
|
QWEN35_MODELS
|
|
.iter()
|
|
.filter(|(_, _, min_ram)| ram_gb >= *min_ram)
|
|
.map(|(tag, _, _)| *tag)
|
|
.collect()
|
|
}
|
|
|
|
/// The default local model: the second-largest Qwen3.5 model that fits in
|
|
/// `ram_gb`. Falls back to the only fitting model, or FALLBACK_MODEL if none
|
|
/// fit. Deliberately NOT the largest — leaves RAM headroom for the OS/app.
|
|
fn default_local_model(ram_gb: f64) -> &'static str {
|
|
let fitting = models_that_fit_in(ram_gb);
|
|
match fitting.len() {
|
|
0 => FALLBACK_MODEL,
|
|
1 => fitting[0],
|
|
n => fitting[n - 2],
|
|
}
|
|
}
|
|
|
|
/// A resolved boot plan derived purely from the inference config + RAM.
|
|
/// Pure and side-effect-free so it can be unit-tested without spawning
|
|
/// processes or touching the network.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct BootPlan {
|
|
/// Whether to start and wait for the bundled Ollama.
|
|
launch_ollama: bool,
|
|
/// The single Ollama model to pull (None for custom endpoints).
|
|
model_to_pull: Option<String>,
|
|
/// Optional `(engine_key, bare_host)` override for a custom endpoint,
|
|
/// e.g. `("lmstudio", "http://localhost:1234")`. Written into
|
|
/// ~/.openjarvis/config.toml so `jarvis serve` picks it up.
|
|
engine_host: Option<(String, String)>,
|
|
/// Args appended after `uv run jarvis serve --port <port>`.
|
|
serve_args: Vec<String>,
|
|
}
|
|
|
|
/// Default OpenAI-compatible engine key used when a custom endpoint config
|
|
/// omits one (LM Studio is the canonical local server).
|
|
const CUSTOM_FALLBACK_ENGINE: &str = "lmstudio";
|
|
|
|
/// Decide what to launch/pull/serve from the inference config + system RAM.
|
|
/// Pure: no I/O, no spawning.
|
|
fn boot_plan(cfg: &InferenceConfig, ram_gb: f64) -> BootPlan {
|
|
match cfg.kind {
|
|
SourceKind::Ollama => {
|
|
let model = cfg
|
|
.model
|
|
.clone()
|
|
.unwrap_or_else(|| default_local_model(ram_gb).to_string());
|
|
BootPlan {
|
|
launch_ollama: true,
|
|
model_to_pull: Some(model.clone()),
|
|
engine_host: None,
|
|
serve_args: vec![
|
|
"--engine".into(),
|
|
"ollama".into(),
|
|
"--model".into(),
|
|
model,
|
|
"--agent".into(),
|
|
"simple".into(),
|
|
],
|
|
}
|
|
}
|
|
SourceKind::Custom => {
|
|
let engine = cfg
|
|
.engine
|
|
.clone()
|
|
.unwrap_or_else(|| CUSTOM_FALLBACK_ENGINE.to_string());
|
|
// Record (engine_key, bare_host) only when a host is configured, so
|
|
// boot can write `[engine.<key>] host = ...` into config.toml. An
|
|
// empty host is dropped (no override).
|
|
let engine_host = cfg
|
|
.host
|
|
.clone()
|
|
.filter(|h| !h.is_empty())
|
|
.map(|h| (engine.clone(), h));
|
|
// `model` may be empty if the config is malformed; `jarvis serve`
|
|
// surfaces a clear error then (there is no universal default model
|
|
// for an arbitrary endpoint).
|
|
let model = cfg.model.clone().unwrap_or_default();
|
|
BootPlan {
|
|
launch_ollama: false,
|
|
model_to_pull: None,
|
|
engine_host,
|
|
serve_args: vec![
|
|
"--engine".into(),
|
|
engine,
|
|
"--model".into(),
|
|
model,
|
|
"--agent".into(),
|
|
"simple".into(),
|
|
],
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
|
|
/// Rolling buffer holding the most recent ~16 KB of jarvis stderr.
|
|
///
|
|
/// Populated by a background drainer task spawned at boot so the pipe
|
|
/// never fills and back-pressures `jarvis serve`; consumed by the boot
|
|
/// path when surfacing failure messages.
|
|
type StderrTail = Arc<Mutex<Vec<u8>>>;
|
|
|
|
const STDERR_TAIL_LIMIT: usize = 16 * 1024;
|
|
|
|
struct BackendManager {
|
|
ollama: Option<ChildHandle>,
|
|
jarvis: Option<ChildHandle>,
|
|
jarvis_stderr_tail: StderrTail,
|
|
}
|
|
|
|
impl Default for BackendManager {
|
|
fn default() -> Self {
|
|
Self {
|
|
ollama: None,
|
|
jarvis: None,
|
|
jarvis_stderr_tail: Arc::new(Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
}
|
|
|
|
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>,
|
|
/// "ollama" | "custom" — lets the setup UI relabel the progress steps.
|
|
source: 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,
|
|
source: "ollama".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
/// True if a custom OpenAI-compatible endpoint answers at all (any HTTP
|
|
/// status counts — even a 404 proves the server is up). `host` is the bare
|
|
/// base URL; we probe `<host>/v1/models`.
|
|
async fn endpoint_reachable(host: &str, timeout: Duration) -> bool {
|
|
let client = match reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(3))
|
|
.build()
|
|
{
|
|
Ok(c) => c,
|
|
Err(_) => return false,
|
|
};
|
|
let url = format!("{}/v1/models", host.trim_end_matches('/'));
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
while tokio::time::Instant::now() < deadline {
|
|
if client.get(&url).send().await.is_ok() {
|
|
return true;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Outcome of waiting for `jarvis serve` to become healthy.
|
|
///
|
|
/// Unlike [`wait_for_url`] this differentiates "server is up but degraded"
|
|
/// (HTTP 503 — usually inference engine failed to load) from "server never
|
|
/// came up" and from "child process died before serving anything", because
|
|
/// each needs a different user-facing message.
|
|
#[derive(Debug)]
|
|
enum JarvisStartResult {
|
|
/// `/health` returned 2xx.
|
|
Ready,
|
|
/// Server replied 503. The body is the actionable message (typically
|
|
/// "engine not ready" or a model-load error).
|
|
ServiceUnavailable(String),
|
|
/// The `jarvis serve` child exited before `/health` returned 2xx.
|
|
EarlyExit { code: Option<i32>, stderr: String },
|
|
/// Deadline elapsed without ever seeing 2xx or an early exit.
|
|
Timeout,
|
|
}
|
|
|
|
/// Spawn a detached task that continuously drains `jarvis serve`'s
|
|
/// stderr into a rolling tail buffer.
|
|
///
|
|
/// We MUST keep reading stderr for as long as the child runs — `jarvis
|
|
/// serve` is chatty (engine load progress, request logs), and the OS
|
|
/// pipe buffer is small (4 KB on Windows, 64 KB on Linux). Once full,
|
|
/// the child's next stderr write blocks indefinitely and the server
|
|
/// hangs mid-operation. The drainer reads in chunks and keeps only the
|
|
/// last `STDERR_TAIL_LIMIT` bytes — enough to surface a tail trace if
|
|
/// the child later dies, without unbounded memory growth.
|
|
///
|
|
/// Returns immediately after spawning the task; the task ends naturally
|
|
/// when the child closes stderr (i.e. exits).
|
|
fn spawn_jarvis_stderr_drainer(mut stderr: tokio::process::ChildStderr, tail: StderrTail) {
|
|
use tokio::io::AsyncReadExt;
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0u8; 4096];
|
|
loop {
|
|
match stderr.read(&mut buf).await {
|
|
Ok(0) => break, // EOF — child closed stderr
|
|
Err(_) => break, // pipe broke — also done
|
|
Ok(n) => {
|
|
let mut t = tail.lock().await;
|
|
t.extend_from_slice(&buf[..n]);
|
|
if t.len() > STDERR_TAIL_LIMIT {
|
|
let drop_n = t.len() - STDERR_TAIL_LIMIT;
|
|
t.drain(..drop_n);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Read whatever the stderr drainer has buffered so far.
|
|
///
|
|
/// Safe to call at any time; returns an empty string before the
|
|
/// drainer has seen any bytes. Trimmed.
|
|
async fn read_jarvis_stderr_tail(backend: &SharedBackend) -> String {
|
|
let tail = backend.lock().await.jarvis_stderr_tail.clone();
|
|
let bytes = tail.lock().await.clone();
|
|
String::from_utf8_lossy(&bytes).trim().to_string()
|
|
}
|
|
|
|
/// Poll `jarvis serve` health, watching the child process state so we
|
|
/// never wait 10 minutes for a process that crashed in the first second.
|
|
async fn wait_for_jarvis_health(
|
|
url: &str,
|
|
timeout: Duration,
|
|
backend: &SharedBackend,
|
|
) -> JarvisStartResult {
|
|
let client = match reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(2))
|
|
.build()
|
|
{
|
|
Ok(c) => c,
|
|
Err(_) => return JarvisStartResult::Timeout,
|
|
};
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
// 1. Has the child already exited? `try_wait` is non-blocking; on
|
|
// Windows where uv / python / the Rust extension can fail to load
|
|
// very fast, this catches the crash within ~500ms instead of after
|
|
// the full HTTP timeout window.
|
|
let exit_status = {
|
|
let mut mgr = backend.lock().await;
|
|
match mgr.jarvis.as_mut() {
|
|
Some(h) => h.child.try_wait().ok().flatten(),
|
|
None => None,
|
|
}
|
|
};
|
|
if let Some(status) = exit_status {
|
|
let stderr = read_jarvis_stderr_tail(backend).await;
|
|
return JarvisStartResult::EarlyExit {
|
|
code: status.code(),
|
|
stderr,
|
|
};
|
|
}
|
|
|
|
// 2. Try the health endpoint.
|
|
match client.get(url).send().await {
|
|
Ok(resp) => {
|
|
let status = resp.status();
|
|
if status.is_success() {
|
|
return JarvisStartResult::Ready;
|
|
}
|
|
if status == reqwest::StatusCode::SERVICE_UNAVAILABLE {
|
|
// Server is up but the inference engine is not. This
|
|
// is a terminal-for-us state — polling won't change
|
|
// anything; the user has to fix their engine config.
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return JarvisStartResult::ServiceUnavailable(body);
|
|
}
|
|
// Other non-2xx (e.g. 404 during a brief routing-table
|
|
// warmup window) — fall through and keep polling.
|
|
}
|
|
Err(_) => {
|
|
// Connection refused / DNS / timeout — server still
|
|
// booting. Keep polling.
|
|
}
|
|
}
|
|
|
|
if tokio::time::Instant::now() >= deadline {
|
|
return JarvisStartResult::Timeout;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// uv sync error formatting (pure helpers — unit-tested, see #331)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Last `max_chars` characters of a `uv sync` stderr stream, trimmed.
|
|
///
|
|
/// uv's actionable diagnostic almost always lands at the tail of the
|
|
/// stream, so when surfacing a failure to the user we show the end, not
|
|
/// the (usually noisy progress-spinner) beginning. Operates on `char`
|
|
/// boundaries so it never splits a multi-byte UTF-8 codepoint — important
|
|
/// because Windows consoles emit non-ASCII (cp9xx) bytes.
|
|
fn uv_sync_stderr_tail(stderr: &str, max_chars: usize) -> String {
|
|
let total = stderr.chars().count();
|
|
let skip = total.saturating_sub(max_chars);
|
|
stderr.chars().skip(skip).collect::<String>().trim().to_string()
|
|
}
|
|
|
|
/// Error message shown when `uv sync` runs but exits non-zero (#331).
|
|
///
|
|
/// `exit_code` is `None` when the process was terminated by a signal with
|
|
/// no exit code (rendered as "unknown" rather than a misleading -1).
|
|
fn format_uv_sync_failure(
|
|
root: &std::path::Path,
|
|
exit_code: Option<i32>,
|
|
stderr: &str,
|
|
) -> String {
|
|
let code = exit_code
|
|
.map(|c| c.to_string())
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
format!(
|
|
"`uv sync` failed in {} (exit {}). Last output:\n\n{}\n\n\
|
|
Try opening a terminal in that directory and running \
|
|
`uv sync --extra server` manually for the full output.",
|
|
root.display(),
|
|
code,
|
|
uv_sync_stderr_tail(stderr, 800),
|
|
)
|
|
}
|
|
|
|
/// Strip AppImage-injected environment from a subprocess command (#455).
|
|
///
|
|
/// When the OpenJarvis desktop binary is shipped as an AppImage, the AppImage
|
|
/// runtime sets `LD_LIBRARY_PATH` (and friends) to the extracted-to-/tmp
|
|
/// bundled lib dir. Any child we spawn inherits that env by default — but the
|
|
/// children we spawn (`uv`, `ollama`, `git`) live outside the AppImage and
|
|
/// must NOT load their shared libraries from the AppImage's bundle. The
|
|
/// classic symptom: `uv` finds `python3`, `python3` tries to `import numpy`,
|
|
/// numpy's `.so` files try to dlopen libstdc++/libssl/libcrypto, the linker
|
|
/// picks the AppImage's versions which were built against a different glibc
|
|
/// or libcrypto API, and python dies silently — before any startup log
|
|
/// reaches us. The user sees "API Server — starting server..." forever.
|
|
///
|
|
/// Fix: when we detect we're inside an AppImage (the AppImage runtime sets
|
|
/// `$APPIMAGE` to the original image path), strip the leaked env vars before
|
|
/// spawn. Conditional on `APPIMAGE` being set so regular Linux installs that
|
|
/// legitimately use `LD_LIBRARY_PATH` are untouched. Linux-only — the
|
|
/// `#[cfg]` makes this a no-op on macOS / Windows.
|
|
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
|
|
fn prepare_subprocess_for_appimage(cmd: &mut tokio::process::Command) {
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
if std::env::var_os("APPIMAGE").is_some() {
|
|
cmd.env_remove("LD_LIBRARY_PATH");
|
|
cmd.env_remove("LD_PRELOAD");
|
|
cmd.env_remove("APPIMAGE");
|
|
cmd.env_remove("APPIMAGE_UUID");
|
|
cmd.env_remove("APPDIR");
|
|
cmd.env_remove("ARGV0");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Error message shown when `uv sync` can't even be spawned (#331) —
|
|
/// e.g. the resolved `uv` binary doesn't exist or isn't executable.
|
|
fn format_uv_sync_spawn_error(root: &std::path::Path, uv_bin: &str, err: &str) -> String {
|
|
format!(
|
|
"Could not run `uv sync`: {}. Verify uv is installed at \
|
|
`{}` and the OpenJarvis repo is at `{}`.",
|
|
err,
|
|
uv_bin,
|
|
root.display(),
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Backend boot sequence (runs in background after app launch)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async fn boot_backend(backend: SharedBackend, status: SharedStatus) {
|
|
// Decide the inference source (default Ollama) before launching anything.
|
|
let cfg = read_inference_config();
|
|
let plan = boot_plan(&cfg, total_ram_gb());
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.source = match cfg.kind {
|
|
SourceKind::Ollama => "ollama",
|
|
SourceKind::Custom => "custom",
|
|
}
|
|
.into();
|
|
}
|
|
|
|
// For the Ollama path, the model pull may fall back to FALLBACK_MODEL; we
|
|
// record what is actually available here so the serve command below uses
|
|
// it instead of the originally-planned tag. None on the custom path.
|
|
let mut serve_model_override: Option<String> = None;
|
|
|
|
if plan.launch_ollama {
|
|
// 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 mut sidecar_cmd = tokio::process::Command::new(&ollama_bin);
|
|
sidecar_cmd
|
|
.arg("serve")
|
|
.env("OLLAMA_HOST", format!("127.0.0.1:{}", OLLAMA_PORT))
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null());
|
|
// Avoid LD_LIBRARY_PATH leak when running inside an AppImage (#455).
|
|
prepare_subprocess_for_appimage(&mut sidecar_cmd);
|
|
match sidecar_cmd.spawn() {
|
|
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);
|
|
if !wait_for_url(&ollama_url, Duration::from_secs(30)).await {
|
|
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 the single default model (see default_local_model /
|
|
// boot_plan). We deliberately do NOT pull any others.
|
|
let model = plan
|
|
.model_to_pull
|
|
.clone()
|
|
.unwrap_or_else(|| STARTUP_MODEL.to_string());
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.phase = "model".into();
|
|
s.detail = format!("Checking for {}...", model);
|
|
}
|
|
|
|
if !ollama_has_model(&model).await {
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.detail = format!("Downloading {}... (this may take a minute)", model);
|
|
}
|
|
if let Err(e) = pull_model(&model).await {
|
|
// If the chosen model fails, try the tiny fallback
|
|
eprintln!("Warning: failed to pull {}: {}", model, e);
|
|
if !ollama_has_model(FALLBACK_MODEL).await {
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.detail = format!("Downloading {}...", FALLBACK_MODEL);
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The pull may have fallen back to FALLBACK_MODEL; serve and persist
|
|
// whatever is actually available now, not the originally-planned tag.
|
|
let resolved_model = if ollama_has_model(&model).await {
|
|
model
|
|
} else {
|
|
FALLBACK_MODEL.to_string()
|
|
};
|
|
serve_model_override = Some(resolved_model.clone());
|
|
|
|
// Persist the resolved model so Settings shows it and future boots reuse it.
|
|
let mut persisted = cfg.clone();
|
|
persisted.model = Some(resolved_model);
|
|
let _ = write_inference_config(&persisted);
|
|
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.model_ready = true;
|
|
s.detail = "Model ready.".into();
|
|
}
|
|
} else {
|
|
// Custom OpenAI-compatible endpoint: never start Ollama, never download.
|
|
let host = plan
|
|
.engine_host
|
|
.as_ref()
|
|
.map(|(_, v)| v.clone())
|
|
.unwrap_or_default();
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.phase = "model".into();
|
|
s.detail = format!("Connecting to {}...", host);
|
|
}
|
|
if host.is_empty() || !endpoint_reachable(&host, Duration::from_secs(15)).await {
|
|
let mut s = status.lock().await;
|
|
s.error = Some(format!(
|
|
"Could not reach your custom inference server at {}. \
|
|
Start the server (e.g. LM Studio) and check the URL in Settings, then relaunch.",
|
|
if host.is_empty() { "(no URL set)" } else { host.as_str() }
|
|
));
|
|
return;
|
|
}
|
|
// Point `jarvis serve` at the user's endpoint by writing the engine
|
|
// host into ~/.openjarvis/config.toml (the env var alone is shadowed by
|
|
// the engine's non-empty default host in the Python layer).
|
|
if let Some((engine, host)) = &plan.engine_host {
|
|
if let Err(e) = set_engine_host_in_config(engine, host) {
|
|
let mut s = status.lock().await;
|
|
s.error = Some(format!("Could not write engine config: {}", e));
|
|
return;
|
|
}
|
|
}
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.ollama_ready = true;
|
|
s.model_ready = true;
|
|
s.detail = "Connected to custom endpoint.".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. Concrete per-OS instructions —
|
|
// the generic "install it from astral.sh" was the #1 source of
|
|
// confusion on the Discord support thread; users couldn't tell whether
|
|
// to use winget, scoop, pip, or the official installer.
|
|
if !std::path::Path::new(&uv_bin).exists() && uv_bin == "uv" {
|
|
let mut s = status.lock().await;
|
|
#[cfg(target_os = "windows")]
|
|
let msg = "Could not find 'uv' (Python package manager). \
|
|
To install on Windows, open PowerShell and run:\n\n\
|
|
powershell -ExecutionPolicy Bypass -c \"irm https://astral.sh/uv/install.ps1 | iex\"\n\n\
|
|
Then close and relaunch this app. \
|
|
(If the install completes but the app still can't find uv, \
|
|
you may need to log out and back in so PATH refreshes.)";
|
|
#[cfg(target_os = "macos")]
|
|
let msg = "Could not find 'uv' (Python package manager). \
|
|
To install on macOS, open Terminal and run:\n\n\
|
|
curl -LsSf https://astral.sh/uv/install.sh | sh\n\n\
|
|
Then relaunch this app.";
|
|
#[cfg(target_os = "linux")]
|
|
let msg = "Could not find 'uv' (Python package manager). \
|
|
To install on Linux, open a terminal and run:\n\n\
|
|
curl -LsSf https://astral.sh/uv/install.sh | sh\n\n\
|
|
Then relaunch this app.";
|
|
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
|
let msg = "Could not find 'uv' (Python package manager). \
|
|
Install it from https://astral.sh/uv then relaunch.";
|
|
s.error = Some(msg.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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If something is already serving on our port, decide what to do based
|
|
// on what it actually responds with — don't blindly kill it (#455).
|
|
//
|
|
// The OLD behaviour was: any HTTP response (even 404) → `fuser -k 8000/tcp`
|
|
// / `taskkill /PID /F`. That broke the legitimate case where a user had
|
|
// already started `jarvis serve` in a terminal and then launched the
|
|
// desktop app — the app killed their server, then raced to spawn its
|
|
// own, sometimes losing the race and hanging.
|
|
//
|
|
// New behaviour, by response shape:
|
|
// * 2xx /health — healthy jarvis serve. Attach to it; skip the
|
|
// uv-sync + spawn dance entirely. Done.
|
|
// * 503 — server is up but engine isn't ready. Surface
|
|
// an actionable message; don't kill (matches
|
|
// our wait_for_jarvis_health 503 contract).
|
|
// * any other status — something else is listening on the port. Tell
|
|
// the user via the error banner instead of
|
|
// force-killing a foreign service.
|
|
// * Err (conn refused) — nothing is listening. Proceed to spawn.
|
|
//
|
|
// TODO(#455 follow-up): validate /health response body before attaching
|
|
// so a multi-user host can't trivially spoof us. Also accept a port
|
|
// override from config instead of hard-coding JARVIS_PORT.
|
|
{
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(2))
|
|
.build()
|
|
.unwrap();
|
|
match client
|
|
.get(format!("http://127.0.0.1:{}/health", JARVIS_PORT))
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(resp) if resp.status().is_success() => {
|
|
// Confirm with a second probe — the first might have caught
|
|
// a flickering server (engine half-loaded, dying mid-stop,
|
|
// etc.) and we don't want to claim ready off a 2-second
|
|
// snapshot. Small sleep between to give the server room.
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
let confirm = client
|
|
.get(format!("http://127.0.0.1:{}/health", JARVIS_PORT))
|
|
.send()
|
|
.await
|
|
.map(|r| r.status().is_success())
|
|
.unwrap_or(false);
|
|
if !confirm {
|
|
// First probe was 2xx but the second wasn't — fall
|
|
// through to the spawn path. The server probably went
|
|
// away between probes.
|
|
// (No early return — we want to spawn our own.)
|
|
} else {
|
|
// Attach to the existing healthy server. Mark every
|
|
// pre-spawn step done so the setup UI doesn't show a
|
|
// half-progress bar (model_ready / ollama_ready stay
|
|
// false otherwise because we skipped those steps).
|
|
let mut s = status.lock().await;
|
|
s.phase = "ready".into();
|
|
s.detail = format!(
|
|
"Connected to existing API server on port {}.",
|
|
JARVIS_PORT,
|
|
);
|
|
s.server_ready = true;
|
|
s.model_ready = true;
|
|
s.ollama_ready = true;
|
|
return;
|
|
}
|
|
}
|
|
Ok(resp) if resp.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE => {
|
|
let mut s = status.lock().await;
|
|
s.error = Some(format!(
|
|
"An API server is already running on port {} but its \
|
|
inference engine isn't ready (HTTP 503). If this is your \
|
|
`jarvis serve`, wait for it to finish loading and relaunch. \
|
|
Otherwise, stop that service or change the port.",
|
|
JARVIS_PORT,
|
|
));
|
|
return;
|
|
}
|
|
Ok(resp) => {
|
|
// Something else (a different web server, a stale process,
|
|
// a 4xx-returning instance) is on our port. Don't kill it —
|
|
// give the user actionable info instead.
|
|
let lsof_hint = if cfg!(target_os = "windows") {
|
|
format!("netstat -ano | findstr :{}", JARVIS_PORT)
|
|
} else {
|
|
format!("lsof -i :{}", JARVIS_PORT)
|
|
};
|
|
let mut s = status.lock().await;
|
|
s.error = Some(format!(
|
|
"Port {} is already in use by another service (it answered \
|
|
/health with HTTP {}). Stop that service or change the \
|
|
OpenJarvis port, then relaunch.\n\nTo identify it:\n {}",
|
|
JARVIS_PORT,
|
|
resp.status(),
|
|
lsof_hint,
|
|
));
|
|
return;
|
|
}
|
|
Err(_) => {
|
|
// Nothing listening — proceed to the normal spawn path.
|
|
}
|
|
}
|
|
}
|
|
|
|
let root = project_root.as_ref().unwrap();
|
|
|
|
// Install dependencies automatically (handles fresh clones).
|
|
//
|
|
// Previously we ran `uv sync` with both stdout AND stderr piped to
|
|
// /dev/null and discarded the exit code (`let _ = …`). When `uv sync`
|
|
// failed — Windows path issues, network problems, lockfile conflicts —
|
|
// the user saw no error, the boot continued, `uv run jarvis serve`
|
|
// then ran in an under-provisioned venv, and the user waited the full
|
|
// 600s health-check window before getting "Jarvis server did not
|
|
// become healthy in time" with no actionable detail (issue #331).
|
|
//
|
|
// Now: capture stderr, check the exit status, surface a useful error
|
|
// to the user BEFORE the long server-start wait. The status detail
|
|
// message also indicates this can take a couple of minutes on first
|
|
// boot so users don't restart the app thinking it's stuck.
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.detail = "Installing dependencies (uv sync — may take 1-2 min on first boot)...".into();
|
|
}
|
|
let mut sync_cmd = tokio::process::Command::new(&uv_bin);
|
|
sync_cmd
|
|
.args([
|
|
"sync",
|
|
"--extra", "server",
|
|
"--extra", "inference-cloud",
|
|
"--extra", "inference-google",
|
|
])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::piped())
|
|
.current_dir(root);
|
|
// Avoid LD_LIBRARY_PATH leak when running inside an AppImage (#455).
|
|
prepare_subprocess_for_appimage(&mut sync_cmd);
|
|
let sync_output = sync_cmd.output().await;
|
|
match sync_output {
|
|
Ok(out) if !out.status.success() => {
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
let mut s = status.lock().await;
|
|
s.error = Some(format_uv_sync_failure(root, out.status.code(), &stderr));
|
|
return;
|
|
}
|
|
Err(e) => {
|
|
let mut s = status.lock().await;
|
|
s.error = Some(format_uv_sync_spawn_error(root, &uv_bin, &e.to_string()));
|
|
return;
|
|
}
|
|
Ok(_) => {} // success — fall through
|
|
}
|
|
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.detail = format!("Starting API server from {}...", root.display());
|
|
}
|
|
|
|
let mut cmd = tokio::process::Command::new(&uv_bin);
|
|
let mut serve_argv: Vec<String> = vec![
|
|
"run".into(),
|
|
"jarvis".into(),
|
|
"serve".into(),
|
|
"--port".into(),
|
|
JARVIS_PORT.to_string(),
|
|
];
|
|
serve_argv.extend(plan.serve_args.iter().cloned());
|
|
// If the Ollama pull fell back to a different tag than planned, serve the
|
|
// tag that is actually present. boot_plan always emits `--model` followed
|
|
// immediately by its value, so `i + 1` is in bounds.
|
|
if let Some(m) = &serve_model_override {
|
|
match serve_argv.iter().position(|a| a == "--model") {
|
|
Some(i) if i + 1 < serve_argv.len() => serve_argv[i + 1] = m.clone(),
|
|
_ => eprintln!(
|
|
"Warning: resolved model {:?} could not be applied; \
|
|
'--model <value>' not found in serve args {:?}",
|
|
m, serve_argv
|
|
),
|
|
}
|
|
}
|
|
cmd.args(&serve_argv)
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::piped())
|
|
.current_dir(root);
|
|
// Avoid LD_LIBRARY_PATH leak when running inside an AppImage (#455) —
|
|
// do this BEFORE cmd.env() calls below so our explicit cloud-key env
|
|
// additions aren't accidentally stripped.
|
|
prepare_subprocess_for_appimage(&mut cmd);
|
|
|
|
// 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(mut child) => {
|
|
// Start draining stderr immediately. If we wait until the
|
|
// health check returns we risk filling the 4 KB Windows pipe
|
|
// buffer during startup logging and hanging the child before
|
|
// it can bind its HTTP port — exactly the symptom in #309.
|
|
let stderr_handle = child.stderr.take();
|
|
let mut mgr = backend.lock().await;
|
|
let tail = mgr.jarvis_stderr_tail.clone();
|
|
mgr.jarvis = Some(ChildHandle { child });
|
|
drop(mgr);
|
|
if let Some(stderr) = stderr_handle {
|
|
spawn_jarvis_stderr_drainer(stderr, tail);
|
|
}
|
|
}
|
|
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);
|
|
match wait_for_jarvis_health(&server_url, Duration::from_secs(600), &backend).await {
|
|
JarvisStartResult::Ready => {}
|
|
JarvisStartResult::ServiceUnavailable(body) => {
|
|
let mut s = status.lock().await;
|
|
s.error = Some(format!(
|
|
"Jarvis server is running but the inference engine is not available \
|
|
(HTTP 503). This usually means the configured model couldn't be loaded.\n\n\
|
|
Check the server logs, or run 'uv run jarvis serve --port {}{}' \
|
|
from {} to see the engine error.\n\n\
|
|
Server response:\n{}",
|
|
JARVIS_PORT,
|
|
// Show the args actually passed (after `serve --port <port>`),
|
|
// including any post-fallback `--model` override.
|
|
match serve_argv.get(5..) {
|
|
Some(rest) if !rest.is_empty() => format!(" {}", rest.join(" ")),
|
|
_ => String::new(),
|
|
},
|
|
root.display(),
|
|
body.trim(),
|
|
));
|
|
return;
|
|
}
|
|
JarvisStartResult::EarlyExit { code, stderr } => {
|
|
// `None` here means the OS didn't expose an exit code — on
|
|
// Unix that's a signal kill (SIGKILL/SIGSEGV/...), on Windows
|
|
// it means the process was terminated externally (Task
|
|
// Manager, parent-of-parent, AV). "unknown" covers both.
|
|
let code_str = code
|
|
.map(|c| c.to_string())
|
|
.unwrap_or_else(|| "unknown".into());
|
|
let mut s = status.lock().await;
|
|
s.error = Some(if stderr.is_empty() {
|
|
format!(
|
|
"Jarvis server exited (code {}) before becoming ready.\n\n\
|
|
No stderr output. Check that:\n\
|
|
1. uv is installed ({})\n\
|
|
2. The OpenJarvis repo is at {}\n\
|
|
3. 'uv sync' completes in that directory",
|
|
code_str,
|
|
uv_bin,
|
|
root.display(),
|
|
)
|
|
} else {
|
|
format!(
|
|
"Jarvis server exited (code {}) before becoming ready.\n\nStderr:\n{}",
|
|
code_str, stderr,
|
|
)
|
|
});
|
|
return;
|
|
}
|
|
JarvisStartResult::Timeout => {
|
|
let stderr = read_jarvis_stderr_tail(&backend).await;
|
|
let mut s = status.lock().await;
|
|
s.error = Some(if stderr.is_empty() {
|
|
format!(
|
|
"Jarvis server did not become ready within 10 minutes. 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!(
|
|
"Jarvis server did not become ready within 10 minutes.\n\nStderr:\n{}",
|
|
stderr,
|
|
)
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
{
|
|
let mut s = status.lock().await;
|
|
s.server_ready = true;
|
|
s.phase = "ready".into();
|
|
s.detail = "All systems ready.".into();
|
|
}
|
|
|
|
// Phase 4: done. We intentionally do NOT auto-pull the rest of the
|
|
// Qwen3.5 ladder here. The previous behavior walked every model that
|
|
// "fit" in RAM (up to qwen3.5:122b ≈ 81 GB) and pulled each one in an
|
|
// un-cancellable background task — so the app silently consumed tens of
|
|
// gigabytes with no way to stop short of deleting it. The startup model
|
|
// pulled in Phase 2 is enough to make the app fully usable; additional
|
|
// models are now opt-in (Settings → "ollama pull <model>", or the
|
|
// `pull_model` command invoked from the UI).
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 uv_bin = resolve_bin("uv");
|
|
|
|
let mut cmd_args = vec!["run".to_string(), "jarvis".to_string()];
|
|
cmd_args.extend(args.iter().cloned());
|
|
|
|
let mut cmd = tokio::process::Command::new(&uv_bin);
|
|
cmd.args(&cmd_args);
|
|
// Run from the project root so `uv run jarvis` resolves the OpenJarvis
|
|
// project regardless of the app's launch cwd. In a packaged install the
|
|
// cwd isn't the checkout, so without this `jarvis` isn't found and the
|
|
// backend never starts — the UI then shows "Failed to get response"
|
|
// (see #531).
|
|
if let Some(ref root) = find_project_root() {
|
|
cmd.current_dir(root);
|
|
}
|
|
|
|
let is_serve = args.first().map(|a| a.as_str() == "serve").unwrap_or(false);
|
|
|
|
if !is_serve {
|
|
// Short-lived command (e.g. `stop`, `status`): wait for it and return
|
|
// its captured output.
|
|
let output = cmd
|
|
.output()
|
|
.await
|
|
.map_err(|e| format!("Failed to launch jarvis: {}", e))?;
|
|
return if output.status.success() {
|
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
|
} else {
|
|
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
|
};
|
|
}
|
|
|
|
// `jarvis serve` is a long-running server that never exits. The old code
|
|
// used `.output()`, which waits for the process to exit and so hung this
|
|
// command forever — the "Start" button never resolved (#531). Spawn it
|
|
// detached instead, drain stderr (a full 4 KB Windows pipe can otherwise
|
|
// stall the child mid-startup, #309), and poll /health for readiness.
|
|
cmd.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::piped());
|
|
let mut child = cmd
|
|
.spawn()
|
|
.map_err(|e| format!("Failed to launch jarvis serve: {}", e))?;
|
|
|
|
let tail: StderrTail = Arc::new(Mutex::new(Vec::new()));
|
|
if let Some(stderr) = child.stderr.take() {
|
|
spawn_jarvis_stderr_drainer(stderr, tail.clone());
|
|
}
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(2))
|
|
.build()
|
|
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
|
|
let url = format!("http://127.0.0.1:{}/health", JARVIS_PORT);
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(120);
|
|
|
|
loop {
|
|
// Surface an early crash (bad venv, missing Rust ext, etc.) right away
|
|
// instead of waiting out the full readiness timeout.
|
|
if let Ok(Some(status)) = child.try_wait() {
|
|
let stderr = String::from_utf8_lossy(tail.lock().await.as_slice()).into_owned();
|
|
return Err(format!(
|
|
"jarvis serve exited (code {:?}) before becoming healthy:\n{}",
|
|
status.code(),
|
|
stderr.trim()
|
|
));
|
|
}
|
|
if let Ok(resp) = client.get(&url).send().await {
|
|
if resp.status().is_success() {
|
|
// Leave the server running (the Child is detached on drop —
|
|
// kill_on_drop defaults to false); `stop` tears it down.
|
|
return Ok(format!(
|
|
"jarvis serve is ready on http://127.0.0.1:{}",
|
|
JARVIS_PORT
|
|
));
|
|
}
|
|
}
|
|
if tokio::time::Instant::now() >= deadline {
|
|
return Err(format!(
|
|
"jarvis serve did not become healthy on port {} within 120s.",
|
|
JARVIS_PORT
|
|
));
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
}
|
|
}
|
|
|
|
#[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));
|
|
}
|
|
|
|
// Tell the running server to hot-reload its cloud engine so the user
|
|
// doesn't need to restart the app after entering an API key.
|
|
let reload_url = format!("http://127.0.0.1:{}/v1/cloud/reload", JARVIS_PORT);
|
|
let _ = reqwest::Client::new()
|
|
.post(&reload_url)
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.send()
|
|
.await;
|
|
|
|
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))
|
|
}
|
|
|
|
/// Return the current inference-source config for the Settings UI.
|
|
#[tauri::command]
|
|
async fn get_inference_source() -> Result<InferenceConfig, String> {
|
|
Ok(read_inference_config())
|
|
}
|
|
|
|
/// Persist the chosen inference source. `host` is normalized to a bare base
|
|
/// URL. For custom endpoints, an optional API key is stored in cloud-keys.env
|
|
/// under `<ENGINE>_API_KEY`. Applies on next app launch.
|
|
#[tauri::command]
|
|
async fn set_inference_source(
|
|
kind: String,
|
|
model: Option<String>,
|
|
host: Option<String>,
|
|
engine: Option<String>,
|
|
api_key: Option<String>,
|
|
) -> Result<(), String> {
|
|
let kind = match kind.as_str() {
|
|
"ollama" => SourceKind::Ollama,
|
|
"custom" => SourceKind::Custom,
|
|
other => return Err(format!("Unknown inference source kind: {:?}", other)),
|
|
};
|
|
let cfg = InferenceConfig {
|
|
kind,
|
|
model: model.filter(|m| !m.is_empty()),
|
|
host: host.map(|h| normalize_host(&h)).filter(|h| !h.is_empty()),
|
|
engine: engine.filter(|e| !e.is_empty()),
|
|
};
|
|
if let SourceKind::Custom = cfg.kind {
|
|
if cfg.host.is_none() {
|
|
return Err("A server URL is required for a custom endpoint.".into());
|
|
}
|
|
if cfg.model.as_deref().unwrap_or("").is_empty() {
|
|
return Err("A model name is required for a custom endpoint.".into());
|
|
}
|
|
if let Some(key) = api_key.filter(|k| !k.is_empty()) {
|
|
let engine = cfg
|
|
.engine
|
|
.clone()
|
|
.unwrap_or_else(|| CUSTOM_FALLBACK_ENGINE.to_string());
|
|
let key_name = format!("{}_API_KEY", engine.to_ascii_uppercase());
|
|
// Save the key before persisting the config: if the key can't be
|
|
// written, surface it and DON'T record a custom source whose
|
|
// credential is missing (which would fail confusingly at runtime).
|
|
save_cloud_key(key_name, key)
|
|
.await
|
|
.map_err(|e| format!("Could not store the API key: {}", e))?;
|
|
}
|
|
}
|
|
write_inference_config(&cfg)
|
|
}
|
|
|
|
/// 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}))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Inference-source selection (~/.openjarvis/inference.json)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
enum SourceKind {
|
|
Ollama,
|
|
Custom,
|
|
}
|
|
|
|
impl Default for SourceKind {
|
|
fn default() -> Self {
|
|
SourceKind::Ollama
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default)]
|
|
struct InferenceConfig {
|
|
#[serde(default)]
|
|
kind: SourceKind,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
model: Option<String>,
|
|
/// Bare base URL (no trailing `/v1`), custom only.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
host: Option<String>,
|
|
/// OpenAI-compatible engine key (e.g. "lmstudio"), custom only.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
engine: Option<String>,
|
|
}
|
|
|
|
/// Path to the inference-source config (~/.openjarvis/inference.json).
|
|
fn inference_config_path() -> std::path::PathBuf {
|
|
std::path::PathBuf::from(home_dir())
|
|
.join(".openjarvis")
|
|
.join("inference.json")
|
|
}
|
|
|
|
/// Parse config text. Any error (missing/garbage) yields the Ollama default —
|
|
/// a broken file must never strand the user with no working inference source.
|
|
fn parse_inference_config(text: &str) -> InferenceConfig {
|
|
serde_json::from_str::<InferenceConfig>(text).unwrap_or_default()
|
|
}
|
|
|
|
/// Read the on-disk inference config, or the Ollama default if absent.
|
|
fn read_inference_config() -> InferenceConfig {
|
|
match std::fs::read_to_string(inference_config_path()) {
|
|
Ok(text) => parse_inference_config(&text),
|
|
Err(_) => InferenceConfig::default(),
|
|
}
|
|
}
|
|
|
|
/// Write the inference config to disk (pretty JSON).
|
|
fn write_inference_config(cfg: &InferenceConfig) -> Result<(), String> {
|
|
let path = inference_config_path();
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let json = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
|
|
std::fs::write(&path, json + "\n").map_err(|e| format!("Failed to save inference config: {}", e))
|
|
}
|
|
|
|
/// Upsert `[engine.<engine>] host = "<host>"` into an existing config.toml
|
|
/// string, preserving all other content/formatting. Pure: string in, string out.
|
|
fn upsert_engine_host(existing: &str, engine: &str, host: &str) -> Result<String, String> {
|
|
let mut doc = existing
|
|
.parse::<toml_edit::DocumentMut>()
|
|
.map_err(|e| format!("Invalid config.toml: {}", e))?;
|
|
doc["engine"][engine]["host"] = toml_edit::value(host);
|
|
Ok(doc.to_string())
|
|
}
|
|
|
|
/// Write the custom-endpoint host into ~/.openjarvis/config.toml so
|
|
/// `jarvis serve` (which reads that file via load_config) points at it.
|
|
/// The `<ENGINE>_HOST` env var is unreliable — it is shadowed by the engine's
|
|
/// non-empty default host in the Python layer — so config.toml is the override.
|
|
fn set_engine_host_in_config(engine: &str, host: &str) -> Result<(), String> {
|
|
let path = std::path::PathBuf::from(home_dir())
|
|
.join(".openjarvis")
|
|
.join("config.toml");
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let existing = std::fs::read_to_string(&path).unwrap_or_default();
|
|
let updated = upsert_engine_host(&existing, engine, host)?;
|
|
std::fs::write(&path, updated).map_err(|e| format!("Failed to write config.toml: {}", e))
|
|
}
|
|
|
|
/// Normalize a user-entered server URL to a bare base host: trim whitespace,
|
|
/// drop a trailing `/v1` segment (the engine re-appends its own api prefix),
|
|
/// then drop any trailing slash.
|
|
fn normalize_host(raw: &str) -> String {
|
|
let s = raw.trim().trim_end_matches('/');
|
|
let s = s.strip_suffix("/v1").unwrap_or(s);
|
|
s.trim_end_matches('/').to_string()
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Native macOS overlay — NSPanel + WKWebView, entirely bypassing Tauri's
|
|
// window management so we get proper always-on-top, transparency, non-
|
|
// activating panel behaviour and cross-Space support.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(target_os = "macos")]
|
|
mod native_overlay {
|
|
use objc::declare::ClassDecl;
|
|
use objc::runtime::{Class, Object, Sel, BOOL, NO, YES};
|
|
use objc::{class, msg_send, sel, sel_impl};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
/// Raw pointer to the NSPanel, stored as usize for atomicity.
|
|
static PANEL_PTR: AtomicUsize = AtomicUsize::new(0);
|
|
/// Raw pointer to the WKWebView inside the panel.
|
|
static WEBVIEW_PTR: AtomicUsize = AtomicUsize::new(0);
|
|
/// Raw pointer to the previously-frontmost NSRunningApplication.
|
|
static PREV_APP: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
// CoreGraphics geometry types expected by AppKit.
|
|
#[repr(C)]
|
|
#[derive(Copy, Clone)]
|
|
struct CGPoint {
|
|
x: f64,
|
|
y: f64,
|
|
}
|
|
#[repr(C)]
|
|
#[derive(Copy, Clone)]
|
|
struct CGSize {
|
|
width: f64,
|
|
height: f64,
|
|
}
|
|
#[repr(C)]
|
|
#[derive(Copy, Clone)]
|
|
struct CGRect {
|
|
origin: CGPoint,
|
|
size: CGSize,
|
|
}
|
|
|
|
/// Create an autoreleased NSString from a Rust &str.
|
|
unsafe fn nsstring(s: &str) -> *mut Object {
|
|
let obj: *mut Object = msg_send![class!(NSString), alloc];
|
|
msg_send![obj,
|
|
initWithBytes: s.as_ptr()
|
|
length: s.len()
|
|
encoding: 4usize // NSUTF8StringEncoding
|
|
]
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Conversation persistence
|
|
// ------------------------------------------------------------------
|
|
|
|
fn conversation_path() -> std::path::PathBuf {
|
|
std::path::PathBuf::from(super::home_dir())
|
|
.join(".openjarvis")
|
|
.join("overlay-conversation.json")
|
|
}
|
|
|
|
pub fn load_conversation() -> String {
|
|
std::fs::read_to_string(conversation_path()).unwrap_or_else(|_| "[]".into())
|
|
}
|
|
|
|
/// Read cloud API keys and return a JSON array of model IDs
|
|
/// whose provider has a key configured.
|
|
fn cloud_models_json() -> String {
|
|
let keys = super::read_cloud_keys();
|
|
let mut models: Vec<&str> = Vec::new();
|
|
for (name, value) in &keys {
|
|
if value.is_empty() {
|
|
continue;
|
|
}
|
|
match name.as_str() {
|
|
"OPENAI_API_KEY" => models.extend(["gpt-4o", "gpt-4o-mini"]),
|
|
"ANTHROPIC_API_KEY" => {
|
|
models.extend(["claude-sonnet-4-20250514", "claude-haiku-4-20250414"])
|
|
}
|
|
"GEMINI_API_KEY" | "GOOGLE_API_KEY" => {
|
|
models.extend(["gemini-2.5-flash", "gemini-2.5-pro"])
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
serde_json::to_string(&models).unwrap_or_else(|_| "[]".into())
|
|
}
|
|
|
|
fn save_conversation(json: &str) {
|
|
let path = conversation_path();
|
|
if let Some(parent) = path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let _ = std::fs::write(&path, json);
|
|
}
|
|
|
|
/// Apply every transparency trick to the WKWebView.
|
|
/// Called once at creation and again after the page finishes loading.
|
|
unsafe fn force_transparent(wv: *mut Object) {
|
|
let clear: *mut Object = msg_send![class!(NSColor), clearColor];
|
|
let _: () = msg_send![wv, _setDrawsBackground: NO];
|
|
let no_num: *mut Object = msg_send![class!(NSNumber), numberWithBool: NO];
|
|
let _: () = msg_send![wv, setValue: no_num forKey: nsstring("drawsBackground")];
|
|
let _: () = msg_send![wv, setUnderPageBackgroundColor: clear];
|
|
// Also inject CSS to nuke any remaining background
|
|
let js = nsstring(
|
|
"document.documentElement.style.background='transparent';\
|
|
document.body.style.background='transparent';"
|
|
);
|
|
let nil: *mut Object = std::ptr::null_mut();
|
|
let _: () = msg_send![wv, evaluateJavaScript: js completionHandler: nil];
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Public API (must be called on the main thread)
|
|
// ------------------------------------------------------------------
|
|
|
|
/// Build the native overlay panel. Call once during app setup.
|
|
pub unsafe fn create(html: &str, api_port: u16) {
|
|
// --- Custom NSPanel subclass that accepts keyboard input ------
|
|
if Class::get("JarvisOverlayPanel").is_none() {
|
|
let sup = Class::get("NSPanel").unwrap();
|
|
let mut decl = ClassDecl::new("JarvisOverlayPanel", sup).unwrap();
|
|
extern "C" fn yes(_: &Object, _: Sel) -> BOOL {
|
|
YES
|
|
}
|
|
decl.add_method(
|
|
sel!(canBecomeKeyWindow),
|
|
yes as extern "C" fn(&Object, Sel) -> BOOL,
|
|
);
|
|
decl.register();
|
|
}
|
|
|
|
// --- WKNavigationDelegate — re-apply transparency after load --
|
|
if Class::get("JarvisOverlayNavDelegate").is_none() {
|
|
let sup = Class::get("NSObject").unwrap();
|
|
let mut decl = ClassDecl::new("JarvisOverlayNavDelegate", sup).unwrap();
|
|
extern "C" fn did_finish(_: &Object, _: Sel, wv: *mut Object, _nav: *mut Object) {
|
|
unsafe { force_transparent(wv); }
|
|
}
|
|
decl.add_method(
|
|
sel!(webView:didFinishNavigation:),
|
|
did_finish as extern "C" fn(&Object, Sel, *mut Object, *mut Object),
|
|
);
|
|
decl.register();
|
|
}
|
|
|
|
// --- WKScriptMessageHandler so JS can call hide() ------------
|
|
if Class::get("JarvisOverlayMsgHandler").is_none() {
|
|
let sup = Class::get("NSObject").unwrap();
|
|
let mut decl = ClassDecl::new("JarvisOverlayMsgHandler", sup).unwrap();
|
|
extern "C" fn on_msg(_: &Object, _: Sel, _ctrl: *mut Object, msg: *mut Object) {
|
|
unsafe {
|
|
let body: *mut Object = msg_send![msg, body];
|
|
if body.is_null() {
|
|
return;
|
|
}
|
|
let c: *const std::os::raw::c_char = msg_send![body, UTF8String];
|
|
if c.is_null() {
|
|
return;
|
|
}
|
|
if let Ok(s) = std::ffi::CStr::from_ptr(c).to_str() {
|
|
if s == "hide" {
|
|
hide();
|
|
} else if let Some(json) = s.strip_prefix("save:") {
|
|
save_conversation(json);
|
|
} else if let Some(coords) = s.strip_prefix("drag:") {
|
|
drag(coords);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
decl.add_method(
|
|
sel!(userContentController:didReceiveScriptMessage:),
|
|
on_msg as extern "C" fn(&Object, Sel, *mut Object, *mut Object),
|
|
);
|
|
decl.register();
|
|
}
|
|
|
|
// --- Create the NSPanel --------------------------------------
|
|
let frame = CGRect {
|
|
origin: CGPoint { x: 0.0, y: 0.0 },
|
|
size: CGSize {
|
|
width: 560.0,
|
|
height: 400.0,
|
|
},
|
|
};
|
|
// NSWindowStyleMaskNonactivatingPanel = 1 << 7
|
|
let style: u64 = 1 << 7;
|
|
|
|
let cls = Class::get("JarvisOverlayPanel").unwrap();
|
|
let panel: *mut Object = msg_send![cls, alloc];
|
|
let panel: *mut Object = msg_send![panel,
|
|
initWithContentRect: frame
|
|
styleMask: style
|
|
backing: 2u64 // NSBackingStoreBuffered
|
|
defer: NO
|
|
];
|
|
|
|
// Window level — NSFloatingWindowLevel (3).
|
|
let _: () = msg_send![panel, setLevel: 3_i64];
|
|
// canJoinAllSpaces (1) | fullScreenAuxiliary (1<<8)
|
|
let _: () = msg_send![panel, setCollectionBehavior: 257_u64];
|
|
let _: () = msg_send![panel, setHidesOnDeactivate: NO];
|
|
let _: () = msg_send![panel, setOpaque: NO];
|
|
let _: () = msg_send![panel, setHasShadow: NO];
|
|
let _: () = msg_send![panel, setMovableByWindowBackground: YES];
|
|
|
|
let clear: *mut Object = msg_send![class!(NSColor), clearColor];
|
|
let _: () = msg_send![panel, setBackgroundColor: clear];
|
|
let _: () = msg_send![panel, center];
|
|
|
|
// --- WKWebView -----------------------------------------------
|
|
let cfg: *mut Object = msg_send![class!(WKWebViewConfiguration), alloc];
|
|
let cfg: *mut Object = msg_send![cfg, init];
|
|
|
|
// Attach message handler ("overlay" channel)
|
|
let hcls = Class::get("JarvisOverlayMsgHandler").unwrap();
|
|
let handler: *mut Object = msg_send![hcls, alloc];
|
|
let handler: *mut Object = msg_send![handler, init];
|
|
let uc: *mut Object = msg_send![cfg, userContentController];
|
|
let _: () = msg_send![uc,
|
|
addScriptMessageHandler: handler
|
|
name: nsstring("overlay")
|
|
];
|
|
|
|
let wv: *mut Object = msg_send![class!(WKWebView), alloc];
|
|
let wv: *mut Object = msg_send![wv,
|
|
initWithFrame: frame
|
|
configuration: cfg
|
|
];
|
|
|
|
// ---- Make the webview fully transparent ----
|
|
force_transparent(wv);
|
|
|
|
// Set navigation delegate so we re-apply after page loads
|
|
let nav_cls = Class::get("JarvisOverlayNavDelegate").unwrap();
|
|
let nav_del: *mut Object = msg_send![nav_cls, alloc];
|
|
let nav_del: *mut Object = msg_send![nav_del, init];
|
|
let _: () = msg_send![wv, setNavigationDelegate: nav_del];
|
|
|
|
let _: () = msg_send![panel, setContentView: wv];
|
|
WEBVIEW_PTR.store(wv as usize, Ordering::SeqCst);
|
|
|
|
// Inject saved conversation into the HTML template, then load it.
|
|
// Use the API server as the base URL so fetch() is same-origin.
|
|
// Escape "</" so the JSON can't prematurely close the <script> tag.
|
|
// ("\/" is valid JSON — resolves back to "/" when parsed.)
|
|
let saved = load_conversation().replace("</", "<\\/");
|
|
let cloud = cloud_models_json();
|
|
let filled = html
|
|
.replace("__SAVED_MESSAGES__", &saved)
|
|
.replace("__CLOUD_MODELS__", &cloud);
|
|
let base_str = nsstring(&format!("http://127.0.0.1:{}", api_port));
|
|
let base_url: *mut Object = msg_send![class!(NSURL), URLWithString: base_str];
|
|
let _: () = msg_send![wv,
|
|
loadHTMLString: nsstring(&filled)
|
|
baseURL: base_url
|
|
];
|
|
|
|
PANEL_PTR.store(panel as usize, Ordering::SeqCst);
|
|
}
|
|
|
|
pub unsafe fn toggle() {
|
|
let ptr = PANEL_PTR.load(Ordering::SeqCst);
|
|
if ptr == 0 {
|
|
return;
|
|
}
|
|
let panel = ptr as *mut Object;
|
|
let vis: BOOL = msg_send![panel, isVisible];
|
|
if vis != NO {
|
|
hide();
|
|
} else {
|
|
show();
|
|
}
|
|
}
|
|
|
|
pub unsafe fn show() {
|
|
let ptr = PANEL_PTR.load(Ordering::SeqCst);
|
|
if ptr == 0 {
|
|
return;
|
|
}
|
|
let panel = ptr as *mut Object;
|
|
|
|
// Re-apply transparency every time (the webview can reset it)
|
|
let wv_ptr = WEBVIEW_PTR.load(Ordering::SeqCst);
|
|
if wv_ptr != 0 {
|
|
force_transparent(wv_ptr as *mut Object);
|
|
}
|
|
|
|
// Remember the currently-frontmost app so we can restore it.
|
|
let ws: *mut Object = msg_send![class!(NSWorkspace), sharedWorkspace];
|
|
let front: *mut Object = msg_send![ws, frontmostApplication];
|
|
if !front.is_null() {
|
|
let _: () = msg_send![front, retain];
|
|
let old = PREV_APP.swap(front as usize, Ordering::SeqCst);
|
|
if old != 0 {
|
|
let _: () = msg_send![(old as *mut Object), release];
|
|
}
|
|
}
|
|
|
|
// Activate our process so the panel receives keyboard input.
|
|
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
|
|
let _: () = msg_send![app, activateIgnoringOtherApps: YES];
|
|
let nil: *mut Object = std::ptr::null_mut();
|
|
let _: () = msg_send![panel, makeKeyAndOrderFront: nil];
|
|
|
|
// Focus the text field inside the webview.
|
|
let wv: *mut Object = msg_send![panel, contentView];
|
|
let js = nsstring("document.getElementById('input').focus()");
|
|
let _: () = msg_send![wv, evaluateJavaScript: js completionHandler: nil];
|
|
}
|
|
|
|
/// Move the panel by a screen-space delta (called from JS drag handler).
|
|
unsafe fn drag(coords: &str) {
|
|
let ptr = PANEL_PTR.load(Ordering::SeqCst);
|
|
if ptr == 0 {
|
|
return;
|
|
}
|
|
let panel = ptr as *mut Object;
|
|
let Some((dxs, dys)) = coords.split_once(',') else {
|
|
return;
|
|
};
|
|
let Ok(dx) = dxs.parse::<f64>() else { return };
|
|
let Ok(dy) = dys.parse::<f64>() else { return };
|
|
// NSWindow frame origin is bottom-left; screen Y increases upward,
|
|
// but mouse screenY increases downward, so invert dy.
|
|
let frame: CGRect = msg_send![panel, frame];
|
|
let origin = CGPoint {
|
|
x: frame.origin.x + dx,
|
|
y: frame.origin.y - dy,
|
|
};
|
|
let _: () = msg_send![panel, setFrameOrigin: origin];
|
|
}
|
|
|
|
pub unsafe fn hide() {
|
|
let ptr = PANEL_PTR.load(Ordering::SeqCst);
|
|
if ptr == 0 {
|
|
return;
|
|
}
|
|
let panel = ptr as *mut Object;
|
|
let nil: *mut Object = std::ptr::null_mut();
|
|
let _: () = msg_send![panel, orderOut: nil];
|
|
|
|
// Give focus back to whatever app was frontmost before.
|
|
let prev = PREV_APP.swap(0, Ordering::SeqCst);
|
|
if prev != 0 {
|
|
let prev_app = prev as *mut Object;
|
|
let _: BOOL = msg_send![prev_app, activateWithOptions: 2_u64];
|
|
let _: () = msg_send![prev_app, release];
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dispatch a closure onto the main thread via GCD.
|
|
#[cfg(target_os = "macos")]
|
|
fn on_main_thread(f: impl FnOnce() + Send + 'static) {
|
|
dispatch::Queue::main().exec_async(f);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Overlay Tauri commands (thin wrappers that dispatch to the main thread)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tauri::command]
|
|
async fn get_overlay_conversation() -> Result<String, String> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
return Ok(native_overlay::load_conversation());
|
|
}
|
|
#[cfg(not(target_os = "macos"))]
|
|
Ok("[]".into())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn toggle_overlay() -> Result<(), String> {
|
|
#[cfg(target_os = "macos")]
|
|
on_main_thread(|| unsafe { native_overlay::toggle() });
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn hide_overlay() -> Result<(), String> {
|
|
#[cfg(target_os = "macos")]
|
|
on_main_thread(|| unsafe { native_overlay::hide() });
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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_dialog::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)?;
|
|
|
|
// Create native macOS overlay panel
|
|
#[cfg(target_os = "macos")]
|
|
unsafe {
|
|
native_overlay::create(include_str!("overlay.html"), JARVIS_PORT);
|
|
}
|
|
|
|
// Register Cmd+Shift+Space to toggle the overlay
|
|
{
|
|
use tauri_plugin_global_shortcut::{
|
|
Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState,
|
|
};
|
|
let sc = Shortcut::new(Some(Modifiers::META | Modifiers::SHIFT), Code::Space);
|
|
if let Err(e) = app.global_shortcut().on_shortcut(sc, |_app, _sc, ev| {
|
|
if ev.state == ShortcutState::Pressed {
|
|
#[cfg(target_os = "macos")]
|
|
unsafe {
|
|
native_overlay::toggle();
|
|
}
|
|
}
|
|
}) {
|
|
eprintln!("Warning: could not register Cmd+Shift+Space: {e}");
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
get_inference_source,
|
|
set_inference_source,
|
|
toggle_overlay,
|
|
hide_overlay,
|
|
get_overlay_conversation,
|
|
])
|
|
.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;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
boot_plan, default_local_model, format_uv_sync_failure, format_uv_sync_spawn_error,
|
|
normalize_host, parse_inference_config, upsert_engine_host, uv_sync_stderr_tail,
|
|
InferenceConfig, SourceKind,
|
|
};
|
|
use std::path::Path;
|
|
|
|
#[test]
|
|
fn tail_returns_whole_string_when_shorter_than_limit() {
|
|
assert_eq!(uv_sync_stderr_tail("short error", 800), "short error");
|
|
}
|
|
|
|
#[test]
|
|
fn tail_keeps_the_end_not_the_beginning() {
|
|
// uv's actionable line is at the end; the spinner noise is at the start.
|
|
let s = format!("{}ACTUAL ERROR HERE", "spinner-noise ".repeat(200));
|
|
let tail = uv_sync_stderr_tail(&s, 40);
|
|
assert!(tail.ends_with("ACTUAL ERROR HERE"), "tail was: {tail:?}");
|
|
assert!(!tail.contains("spinner-noise spinner-noise spinner-noise"));
|
|
assert!(tail.chars().count() <= 40);
|
|
}
|
|
|
|
#[test]
|
|
fn tail_trims_surrounding_whitespace() {
|
|
assert_eq!(uv_sync_stderr_tail(" \n padded \n ", 800), "padded");
|
|
}
|
|
|
|
#[test]
|
|
fn tail_never_splits_a_multibyte_codepoint() {
|
|
// Each "é" is 2 bytes / 1 char. A byte-based slice could panic or
|
|
// produce invalid UTF-8; the char-based tail must not.
|
|
let s = "é".repeat(500);
|
|
let tail = uv_sync_stderr_tail(&s, 100);
|
|
assert_eq!(tail.chars().count(), 100);
|
|
assert!(tail.chars().all(|c| c == 'é'));
|
|
}
|
|
|
|
#[test]
|
|
fn failure_message_includes_exit_code_and_tail_and_hint() {
|
|
let msg = format_uv_sync_failure(
|
|
Path::new("/home/u/.openjarvis/src"),
|
|
Some(2),
|
|
"error: failed to resolve numpy==2.1.3",
|
|
);
|
|
assert!(msg.contains("exit 2"));
|
|
assert!(msg.contains("/home/u/.openjarvis/src"));
|
|
assert!(msg.contains("failed to resolve numpy==2.1.3"));
|
|
assert!(msg.contains("uv sync --extra server")); // actionable next step
|
|
}
|
|
|
|
#[test]
|
|
fn failure_message_renders_missing_exit_code_as_unknown() {
|
|
// Process killed by signal → no exit code. Must not show a misleading -1.
|
|
let msg = format_uv_sync_failure(Path::new("/x"), None, "boom");
|
|
assert!(msg.contains("exit unknown"));
|
|
assert!(!msg.contains("exit -1"));
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_error_names_the_binary_and_root() {
|
|
let msg = format_uv_sync_spawn_error(
|
|
Path::new("/repo"),
|
|
"C:\\Users\\me\\.local\\bin\\uv.exe",
|
|
"No such file or directory (os error 2)",
|
|
);
|
|
assert!(msg.contains("C:\\Users\\me\\.local\\bin\\uv.exe"));
|
|
assert!(msg.contains("/repo"));
|
|
assert!(msg.contains("No such file or directory"));
|
|
}
|
|
|
|
#[test]
|
|
fn default_local_model_picks_second_largest_that_fits() {
|
|
// QWEN35_MODELS min_ram ladder: 4,6,8,12,24,32,96 GB
|
|
assert_eq!(default_local_model(4.0), "qwen3.5:0.8b"); // only one fits
|
|
assert_eq!(default_local_model(8.0), "qwen3.5:2b"); // fits 0.8/2/4 → 2nd-largest
|
|
assert_eq!(default_local_model(16.0), "qwen3.5:4b"); // fits ..9b → 2nd-largest
|
|
assert_eq!(default_local_model(32.0), "qwen3.5:27b"); // fits 0.8/2/4/9/27/35b → 2nd-largest is 27b
|
|
assert_eq!(default_local_model(128.0), "qwen3.5:35b"); // fits all → 2nd-largest
|
|
}
|
|
|
|
#[test]
|
|
fn default_local_model_falls_back_when_nothing_fits() {
|
|
assert_eq!(default_local_model(1.0), super::FALLBACK_MODEL);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_defaults_to_ollama_when_file_missing_or_garbage() {
|
|
assert!(matches!(parse_inference_config("").kind, SourceKind::Ollama));
|
|
assert!(matches!(parse_inference_config("not json").kind, SourceKind::Ollama));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_reads_custom_endpoint() {
|
|
let cfg = parse_inference_config(
|
|
r#"{"kind":"custom","model":"qwen2.5-7b","host":"http://localhost:1234","engine":"lmstudio"}"#,
|
|
);
|
|
assert!(matches!(cfg.kind, SourceKind::Custom));
|
|
assert_eq!(cfg.model.as_deref(), Some("qwen2.5-7b"));
|
|
assert_eq!(cfg.host.as_deref(), Some("http://localhost:1234"));
|
|
assert_eq!(cfg.engine.as_deref(), Some("lmstudio"));
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_host_strips_trailing_slash_and_v1() {
|
|
assert_eq!(normalize_host("http://localhost:1234/v1"), "http://localhost:1234");
|
|
assert_eq!(normalize_host("http://localhost:1234/v1/"), "http://localhost:1234");
|
|
assert_eq!(normalize_host("http://localhost:1234/"), "http://localhost:1234");
|
|
assert_eq!(normalize_host("http://host:8000"), "http://host:8000");
|
|
}
|
|
|
|
#[test]
|
|
fn boot_plan_ollama_launches_and_pulls_one_model() {
|
|
let cfg = InferenceConfig { kind: SourceKind::Ollama, ..Default::default() };
|
|
let plan = boot_plan(&cfg, 16.0);
|
|
assert!(plan.launch_ollama);
|
|
assert_eq!(plan.model_to_pull.as_deref(), Some("qwen3.5:4b"));
|
|
assert!(plan.engine_host.is_none());
|
|
assert!(plan.serve_args.windows(2).any(|w| w == ["--engine", "ollama"]));
|
|
assert!(plan.serve_args.windows(2).any(|w| w == ["--model", "qwen3.5:4b"]));
|
|
}
|
|
|
|
#[test]
|
|
fn boot_plan_ollama_respects_pinned_model() {
|
|
let cfg = InferenceConfig {
|
|
kind: SourceKind::Ollama,
|
|
model: Some("qwen3.5:9b".into()),
|
|
..Default::default()
|
|
};
|
|
let plan = boot_plan(&cfg, 16.0);
|
|
assert_eq!(plan.model_to_pull.as_deref(), Some("qwen3.5:9b"));
|
|
}
|
|
|
|
#[test]
|
|
fn boot_plan_custom_skips_ollama_and_sets_engine_host() {
|
|
let cfg = InferenceConfig {
|
|
kind: SourceKind::Custom,
|
|
model: Some("qwen2.5-7b".into()),
|
|
host: Some("http://localhost:1234".into()),
|
|
engine: Some("lmstudio".into()),
|
|
};
|
|
let plan = boot_plan(&cfg, 16.0);
|
|
assert!(!plan.launch_ollama);
|
|
assert!(plan.model_to_pull.is_none());
|
|
assert_eq!(
|
|
plan.engine_host,
|
|
Some(("lmstudio".to_string(), "http://localhost:1234".to_string()))
|
|
);
|
|
assert!(plan.serve_args.windows(2).any(|w| w == ["--engine", "lmstudio"]));
|
|
assert!(plan.serve_args.windows(2).any(|w| w == ["--model", "qwen2.5-7b"]));
|
|
}
|
|
|
|
#[test]
|
|
fn boot_plan_custom_defaults_engine_to_lmstudio() {
|
|
let cfg = InferenceConfig {
|
|
kind: SourceKind::Custom,
|
|
model: Some("m".into()),
|
|
host: Some("http://h:1".into()),
|
|
engine: None,
|
|
};
|
|
let plan = boot_plan(&cfg, 16.0);
|
|
assert_eq!(plan.engine_host.as_ref().unwrap().0, "lmstudio");
|
|
assert!(plan.serve_args.windows(2).any(|w| w == ["--engine", "lmstudio"]));
|
|
}
|
|
|
|
#[test]
|
|
fn boot_plan_custom_omits_engine_host_when_no_host() {
|
|
// No configured host → don't set engine_host (no override to write).
|
|
let cfg = InferenceConfig {
|
|
kind: SourceKind::Custom,
|
|
model: Some("m".into()),
|
|
host: None,
|
|
engine: Some("lmstudio".into()),
|
|
};
|
|
let plan = boot_plan(&cfg, 16.0);
|
|
assert!(plan.engine_host.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn boot_plan_ollama_uses_fallback_model_on_low_ram() {
|
|
// Below the smallest model's min_ram → default_local_model → FALLBACK_MODEL.
|
|
let cfg = InferenceConfig { kind: SourceKind::Ollama, ..Default::default() };
|
|
let plan = boot_plan(&cfg, 1.0);
|
|
assert_eq!(plan.model_to_pull.as_deref(), Some(super::FALLBACK_MODEL));
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_engine_host_writes_into_empty_config() {
|
|
let out = upsert_engine_host("", "lmstudio", "http://localhost:1234").unwrap();
|
|
let doc: toml_edit::DocumentMut = out.parse().unwrap();
|
|
assert_eq!(
|
|
doc["engine"]["lmstudio"]["host"].as_str(),
|
|
Some("http://localhost:1234")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_engine_host_preserves_existing_content() {
|
|
let existing = "[intelligence]\ndefault_model = \"keep-me\"\n";
|
|
let out = upsert_engine_host(existing, "vllm", "http://host:8000").unwrap();
|
|
let doc: toml_edit::DocumentMut = out.parse().unwrap();
|
|
assert_eq!(doc["intelligence"]["default_model"].as_str(), Some("keep-me"));
|
|
assert_eq!(doc["engine"]["vllm"]["host"].as_str(), Some("http://host:8000"));
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_engine_host_updates_existing_host() {
|
|
let existing = "[engine.lmstudio]\nhost = \"http://old:1\"\n";
|
|
let out = upsert_engine_host(existing, "lmstudio", "http://new:2").unwrap();
|
|
let doc: toml_edit::DocumentMut = out.parse().unwrap();
|
|
assert_eq!(doc["engine"]["lmstudio"]["host"].as_str(), Some("http://new:2"));
|
|
}
|
|
|
|
// -----------------------------------------------------------------
|
|
// #455 — AppImage subprocess env-strip helper
|
|
// -----------------------------------------------------------------
|
|
//
|
|
// `prepare_subprocess_for_appimage` strips LD_LIBRARY_PATH (and the
|
|
// related AppImage runtime variables) from a child `Command` ONLY
|
|
// when the parent process is itself running inside an AppImage —
|
|
// detected by the presence of the `APPIMAGE` env variable that the
|
|
// AppImage runtime sets to the original .AppImage path. We can't
|
|
// observe the env_remove calls directly through tokio's Command
|
|
// API (it doesn't expose its env map publicly), so these tests
|
|
// exercise the documented contract on each platform:
|
|
//
|
|
// * on macOS / Windows: the function is a no-op regardless of env.
|
|
// * on Linux without $APPIMAGE: also a no-op.
|
|
// * on Linux with $APPIMAGE: it doesn't panic, doesn't return an
|
|
// error, and the calling code that follows succeeds. The
|
|
// observable behaviour test is the integration repro on a real
|
|
// AppImage build (covered in PR test plan).
|
|
//
|
|
// The Mutex serialises any test that touches the process-wide
|
|
// `APPIMAGE` env var so cargo test's parallel runner can't race two
|
|
// tests setting and unsetting it concurrently. `static Mutex` works
|
|
// on a const path since Rust 1.63 (and Tauri's MSRV is well above).
|
|
|
|
static APPIMAGE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
|
|
|
// Pick a binary that exists on every test target so the test body is
|
|
// doing something other than constructing an obviously-broken command
|
|
// path on Windows.
|
|
#[cfg(target_os = "windows")]
|
|
const HARMLESS_BIN: &str = "cmd";
|
|
#[cfg(not(target_os = "windows"))]
|
|
const HARMLESS_BIN: &str = "/bin/true";
|
|
|
|
#[test]
|
|
fn prepare_subprocess_for_appimage_no_appimage_is_safe() {
|
|
let _guard = APPIMAGE_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
let prev = std::env::var_os("APPIMAGE");
|
|
// SAFETY: APPIMAGE_ENV_LOCK serialises every test that touches
|
|
// this env var, so the mutation is single-threaded for the
|
|
// duration of the lock. The 2024-edition env mutation rules
|
|
// require the `unsafe` block but the guard makes it sound.
|
|
unsafe {
|
|
std::env::remove_var("APPIMAGE");
|
|
}
|
|
let mut cmd = tokio::process::Command::new(HARMLESS_BIN);
|
|
super::prepare_subprocess_for_appimage(&mut cmd);
|
|
if let Some(v) = prev {
|
|
unsafe {
|
|
std::env::set_var("APPIMAGE", v);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn prepare_subprocess_for_appimage_with_appimage_set_is_safe() {
|
|
let _guard = APPIMAGE_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
let prev = std::env::var_os("APPIMAGE");
|
|
unsafe {
|
|
std::env::set_var("APPIMAGE", "/tmp/test.AppImage");
|
|
}
|
|
let mut cmd = tokio::process::Command::new(HARMLESS_BIN);
|
|
super::prepare_subprocess_for_appimage(&mut cmd);
|
|
unsafe {
|
|
if let Some(v) = prev {
|
|
std::env::set_var("APPIMAGE", v);
|
|
} else {
|
|
std::env::remove_var("APPIMAGE");
|
|
}
|
|
}
|
|
}
|
|
}
|