diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock
index 9be01b531..b7da58645 100644
--- a/app/src-tauri/Cargo.lock
+++ b/app/src-tauri/Cargo.lock
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "OpenHuman"
-version = "0.52.27"
+version = "0.52.28"
dependencies = [
"anyhow",
"async-trait",
diff --git a/app/src-tauri/Info.plist b/app/src-tauri/Info.plist
index c59ccb91e..61ce33af5 100644
--- a/app/src-tauri/Info.plist
+++ b/app/src-tauri/Info.plist
@@ -6,5 +6,16 @@
OpenHuman uses the microphone for voice dictation and for voice/video calls and huddles inside embedded apps (Google Meet, Discord, Slack).
NSCameraUsageDescription
OpenHuman uses the camera for video calls and huddles inside embedded apps (Google Meet, Discord, Slack).
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ com.openhuman.app
+ CFBundleURLSchemes
+
+ openhuman
+
+
+
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index 76d024285..840043b6b 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -3,11 +3,11 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supp
mod core_process;
mod core_update;
-mod notification_settings;
#[cfg(feature = "cef")]
mod discord_scanner;
#[cfg(feature = "cef")]
mod imessage_scanner;
+mod notification_settings;
#[cfg(feature = "cef")]
mod slack_scanner;
#[cfg(feature = "cef")]
diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs
index f970c7e00..4db68608b 100644
--- a/app/src-tauri/src/webview_accounts/mod.rs
+++ b/app/src-tauri/src/webview_accounts/mod.rs
@@ -267,7 +267,8 @@ fn forward_native_notification(
payload: &tauri_runtime_cef::notification::NotificationPayload,
) {
// Feature flag — bail early when the user hasn't opted in.
- if let Some(settings) = app.try_state::()
+ if let Some(settings) =
+ app.try_state::()
{
if !settings.enabled() {
log::debug!(
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index 1d4b27285..af3177300 100644
--- a/app/src/pages/Conversations.tsx
+++ b/app/src/pages/Conversations.tsx
@@ -39,10 +39,7 @@ import {
openhumanVoiceTts,
} from '../utils/tauriCommands';
import { formatTimelineEntry } from '../utils/toolTimelineFormatting';
-import {
- AgentMessageBubble,
- BubbleMarkdown,
-} from './conversations/components/AgentMessageBubble';
+import { AgentMessageBubble, BubbleMarkdown } from './conversations/components/AgentMessageBubble';
import { LimitPill } from './conversations/components/LimitPill';
import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock';
import {
@@ -66,7 +63,6 @@ type ReplyMode = 'text' | 'voice';
const AUTOCOMPLETE_POLL_DEBOUNCE_MS = 320;
const AUTOCOMPLETE_MIN_CONTEXT_CHARS = 3;
-
interface ConversationsProps {
/**
* `page` (default) renders the centered max-w-2xl card layout used as
diff --git a/scripts/setup-dev-codesign.sh b/scripts/setup-dev-codesign.sh
index 7ae525ac2..1841a4668 100755
--- a/scripts/setup-dev-codesign.sh
+++ b/scripts/setup-dev-codesign.sh
@@ -65,13 +65,22 @@ openssl req \
2>/dev/null
# ── Bundle to PKCS12 ─────────────────────────────────────────────────────────
+# `-legacy` keeps PKCS12 MAC/encryption compatible with macOS `security` tool
+# which does not yet support OpenSSL 3.x defaults (SHA256 MAC / AES-256-CBC).
+# Older OpenSSL/LibreSSL (including the macOS-bundled LibreSSL) do not know
+# about `-legacy`, so probe for support before adding it.
+PKCS12_LEGACY_ARGS=()
+if openssl pkcs12 -help 2>&1 | grep -q -- '-legacy'; then
+ PKCS12_LEGACY_ARGS=(-legacy)
+fi
+
openssl pkcs12 \
-export \
+ "${PKCS12_LEGACY_ARGS[@]}" \
-out "$P12" \
-inkey "$KEY" \
-in "$CERT" \
- -passout "pass:$P12_PASS" \
- 2>/dev/null
+ -passout "pass:$P12_PASS"
# ── Import into login Keychain ───────────────────────────────────────────────
security import "$P12" \
diff --git a/src/openhuman/local_ai/mod.rs b/src/openhuman/local_ai/mod.rs
index 17ea31ce7..a0fbfb854 100644
--- a/src/openhuman/local_ai/mod.rs
+++ b/src/openhuman/local_ai/mod.rs
@@ -15,7 +15,7 @@ pub mod sentiment;
mod install;
pub(crate) mod model_ids;
mod ollama_api;
-pub(crate) use ollama_api::OLLAMA_BASE_URL;
+pub(crate) use ollama_api::{ollama_base_url, OLLAMA_BASE_URL};
mod parse;
pub(crate) mod paths;
mod service;
diff --git a/src/openhuman/routing/factory.rs b/src/openhuman/routing/factory.rs
index f75865432..ff8c40112 100644
--- a/src/openhuman/routing/factory.rs
+++ b/src/openhuman/routing/factory.rs
@@ -1,13 +1,18 @@
use std::sync::Arc;
+use std::time::Duration;
use crate::openhuman::config::LocalAiConfig;
-use crate::openhuman::local_ai::OLLAMA_BASE_URL;
+use crate::openhuman::local_ai::ollama_base_url;
use crate::openhuman::providers::compatible::{AuthStyle, OpenAiCompatibleProvider};
use crate::openhuman::providers::Provider;
use super::health::LocalHealthChecker;
use super::provider::IntelligentRoutingProvider;
+/// Cache TTL for the non-ollama local health probe. Mirrors the default used
+/// by [`LocalHealthChecker::new`].
+const LOCAL_HEALTH_TTL: Duration = Duration::from_secs(30);
+
/// Construct an [`IntelligentRoutingProvider`] from a remote backend provider
/// and the local AI configuration.
///
@@ -22,16 +27,51 @@ pub fn new_provider(
local_ai_config: &LocalAiConfig,
remote_fallback_model: &str,
) -> IntelligentRoutingProvider {
- let local_base = format!("{}/v1", OLLAMA_BASE_URL);
+ // Allow operators to point the local routing tier at an OpenAI-compatible
+ // server other than Ollama (e.g. llama-server for Gemma 4 E2B, which
+ // Ollama's embedded llama.cpp cannot load yet as of April 2026).
+ //
+ // `OPENHUMAN_LOCAL_INFERENCE_URL` — full `/v1` base URL of the local
+ // OpenAI-compat server. When set, health is probed via `GET {base}/models`
+ // instead of Ollama's `/api/tags`.
+ let override_base = std::env::var("OPENHUMAN_LOCAL_INFERENCE_URL")
+ .ok()
+ .map(|s| s.trim().trim_end_matches('/').to_string())
+ .filter(|s| !s.is_empty());
+
+ let provider_kind = local_ai_config.provider.trim().to_ascii_lowercase();
+ let use_openai_compat_local =
+ override_base.is_some() || matches!(provider_kind.as_str(), "llamacpp" | "llama-server");
+
+ let (provider_label, local_base, health) = if use_openai_compat_local {
+ let base = override_base.unwrap_or_else(|| "http://127.0.0.1:8080/v1".to_string());
+ let probe = format!("{base}/models");
+ tracing::debug!(
+ provider = %provider_kind,
+ "[routing] local inference configured via OpenAI-compat (non-ollama)"
+ );
+ (
+ "llamacpp",
+ base,
+ Arc::new(LocalHealthChecker::with_probe_url(probe, LOCAL_HEALTH_TTL)),
+ )
+ } else {
+ let ollama_base = ollama_base_url();
+ let local_v1 = format!("{ollama_base}/v1");
+ (
+ "ollama",
+ local_v1,
+ Arc::new(LocalHealthChecker::new(&ollama_base)),
+ )
+ };
+
let local: Box = Box::new(OpenAiCompatibleProvider::new(
- "ollama",
+ provider_label,
&local_base,
- None, // Ollama does not require authentication
+ None, // local servers do not require authentication
AuthStyle::Bearer,
));
- let health = Arc::new(LocalHealthChecker::new(OLLAMA_BASE_URL));
-
IntelligentRoutingProvider::new(
remote,
local,
diff --git a/src/openhuman/routing/health.rs b/src/openhuman/routing/health.rs
index 0030cb004..0196036f3 100644
--- a/src/openhuman/routing/health.rs
+++ b/src/openhuman/routing/health.rs
@@ -45,6 +45,12 @@ impl LocalHealthChecker {
/// Create a checker with a custom cache TTL (useful in tests).
pub fn with_ttl(base_url: &str, ttl: Duration) -> Self {
+ Self::with_probe_url(format!("{base_url}/api/tags"), ttl)
+ }
+
+ /// Create a checker with an explicit full probe URL (for non-ollama local
+ /// backends such as llama-server, whose health endpoint is `/v1/models`).
+ pub fn with_probe_url(probe_url: String, ttl: Duration) -> Self {
let client = reqwest::Client::builder()
.timeout(PROBE_TIMEOUT)
.build()
@@ -57,7 +63,7 @@ impl LocalHealthChecker {
});
Self {
client,
- probe_url: format!("{base_url}/api/tags"),
+ probe_url,
cache: Mutex::new(None),
ttl,
}