refactor(tauri): reduce desktop host to sidecar lifecycle + deep-link (#84)

* feat(onboarding): introduce DEV_FORCE_ONBOARDING flag to control onboarding flow

- Added DEV_FORCE_ONBOARDING configuration to bypass onboarding checks during development.
- Updated onboarding logic in AppRoutes to respect the new flag, ensuring onboarding is always shown when enabled.
- Introduced new utility functions for managing onboarding state based on the flag.
- Updated Cargo.lock and Cargo.toml to reflect dependency changes and version updates.
- Removed deprecated openhuman command module and refactored related functionalities into daemon_host module for better organization.

* feat(daemon): refactor daemon health monitoring and window management

- Updated DaemonHealthService to use polling via `openhuman.health_snapshot` instead of event listening for health updates.
- Introduced a new focusMainWindow function to centralize window focus logic across deep link handling and Tauri commands.
- Replaced `invoke` calls with direct window management methods for showing, hiding, and toggling the main window.
- Removed deprecated core_rpc module and related commands for improved organization and clarity.
- Updated Cargo.toml and Cargo.lock to reflect dependency changes.

* refactor(daemonHealthService): streamline imports and simplify tool object structure

- Moved the import of `callCoreRpc` to a more appropriate location in DaemonHealthService.
- Simplified the structure of the tools object in the aiGetConfig function for better readability.
- Ensured consistency in Tauri command invocations by removing unnecessary line breaks.
This commit is contained in:
Steven Enamakel
2026-03-30 11:41:29 -07:00
committed by GitHub
parent 6aeea8ba49
commit 36fc5bdfa6
20 changed files with 279 additions and 2795 deletions
+2
View File
@@ -52,3 +52,5 @@ tauri.key
tauri.key.pub
/target/
src-tauri/target/
openhuman-skills
+13 -1415
View File
File diff suppressed because it is too large Load Diff
+1 -16
View File
@@ -27,28 +27,13 @@ serde_json = "1"
[dependencies]
# Tauri core and plugins
tauri = { version = "2.10", features = ["macos-private-api"] }
tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2.0.0"
tauri-plugin-os = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2", "multipart", "socks"] }
tokio = { version = "1", features = ["full", "sync"] }
once_cell = "1.19"
tokio = { version = "1", features = ["rt-multi-thread", "process", "sync", "time", "net"] }
log = "0.4"
env_logger = "0.11"
dirs = "5"
uuid = { version = "1", features = ["v4"] }
anyhow = "1.0"
chrono = { version = "0.4", features = ["serde"] }
futures-util = "0.3"
urlencoding = "2.1"
rustls = { version = "0.23", features = ["ring"] }
tauri-plugin-autostart = "2"
tauri-plugin-notification = "2"
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!
+1 -11
View File
@@ -6,16 +6,6 @@
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"deep-link:default",
"os:default",
"autostart:default",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled",
"notification:default",
"notification:allow-notify",
"notification:allow-request-permission",
"notification:allow-is-permission-granted"
"deep-link:default"
]
}
-103
View File
@@ -1,103 +0,0 @@
use crate::core_process::CoreProcessHandle;
use serde::Deserialize;
use serde_json::Value;
use tauri::Manager;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CoreRpcRelayRequest {
pub method: String,
#[serde(default)]
pub params: Value,
#[serde(default)]
pub service_managed: bool,
}
async fn invoke_core_cli_call(method: &str, params: Value) -> Result<(), String> {
let core_bin = crate::core_process::default_core_bin()
.ok_or_else(|| "openhuman core binary not found".to_string())?;
let params_json =
serde_json::to_string(&params).map_err(|e| format!("failed to serialize params: {e}"))?;
let output = tokio::process::Command::new(core_bin)
.arg("call")
.arg("--method")
.arg(method)
.arg("--params")
.arg(params_json)
.output()
.await
.map_err(|e| format!("failed to run core cli call {method}: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
return Err(format!(
"core cli call {method} failed with status {}. stdout: {} stderr: {}",
output.status, stdout, stderr
));
}
Ok(())
}
/// Whether to install/start the OS background service (launchd, etc.) when core RPC is down.
/// Disabled in debug/dev builds so `tauri dev` does not mutate system services; the shell still
/// spawns a child `openhuman core run` via [`crate::core_process::CoreProcessHandle`].
/// Set `OPENHUMAN_SKIP_SERVICE_AUTOSTART=1` to skip in release builds too.
pub(crate) fn should_autostart_service_managed_core() -> bool {
if std::env::var("OPENHUMAN_SKIP_SERVICE_AUTOSTART")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false)
{
return false;
}
if cfg!(debug_assertions) {
return false;
}
true
}
pub(crate) async fn ensure_service_managed_core_running() -> Result<(), String> {
if crate::core_rpc::ping().await {
return Ok(());
}
let _ = invoke_core_cli_call("openhuman.service_install", serde_json::json!({})).await;
let _ = invoke_core_cli_call("openhuman.service_start", serde_json::json!({})).await;
for _ in 0..40 {
if crate::core_rpc::ping().await {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
Err(
"OpenHuman Core daemon did not become ready. Confirm the background service is running."
.to_string(),
)
}
#[tauri::command]
pub async fn core_rpc_relay(
app: tauri::AppHandle,
request: CoreRpcRelayRequest,
) -> Result<Value, String> {
if request.service_managed {
ensure_service_managed_core_running().await?;
} else {
let core = app
.try_state::<CoreProcessHandle>()
.ok_or_else(|| "core process handle is not available".to_string())?;
let handle: CoreProcessHandle = (*core).clone();
handle.ensure_running().await?;
}
crate::core_rpc::call::<Value>(&request.method, request.params).await
}
#[tauri::command]
pub fn core_rpc_url() -> String {
crate::core_rpc::resolved_rpc_url()
}
-11
View File
@@ -1,11 +0,0 @@
pub mod core_relay;
pub mod openhuman;
#[cfg(desktop)]
pub mod window;
pub use core_relay::*;
pub use openhuman::*;
#[cfg(desktop)]
pub use window::*;
-356
View File
@@ -1,356 +0,0 @@
use crate::core_process::CoreProcessHandle;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::Mutex;
use uuid::Uuid;
const DAEMON_HOST_CONFIG_FILE: &str = "daemon_host_config.json";
static WEB_CHAT_CLIENT_ID: Lazy<String> = Lazy::new(|| Uuid::new_v4().to_string());
static WEB_CHAT_STREAM_TASK: Lazy<Mutex<Option<tokio::task::JoinHandle<()>>>> =
Lazy::new(|| Mutex::new(None));
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DaemonHostConfig {
pub show_tray: bool,
}
impl Default for DaemonHostConfig {
fn default() -> Self {
Self { show_tray: true }
}
}
fn daemon_host_config_path(app: &AppHandle) -> PathBuf {
app.path()
.app_data_dir()
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".openhuman")
})
.join(DAEMON_HOST_CONFIG_FILE)
}
async fn load_daemon_host_config(app: &AppHandle) -> DaemonHostConfig {
let path = daemon_host_config_path(app);
let Ok(contents) = tokio::fs::read_to_string(path).await else {
return DaemonHostConfig::default();
};
serde_json::from_str::<DaemonHostConfig>(&contents).unwrap_or_default()
}
async fn save_daemon_host_config(app: &AppHandle, config: &DaemonHostConfig) -> Result<(), String> {
let path = daemon_host_config_path(app);
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("failed to create daemon host config directory: {e}"))?;
}
let bytes = serde_json::to_vec_pretty(config)
.map_err(|e| format!("failed to serialize daemon host config: {e}"))?;
tokio::fs::write(path, bytes)
.await
.map_err(|e| format!("failed to write daemon host config: {e}"))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ServiceState {
Running,
Stopped,
NotInstalled,
Unknown(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceStatus {
pub state: ServiceState,
pub unit_path: Option<std::path::PathBuf>,
pub label: String,
pub details: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RpcCommandResponse<T> {
result: T,
}
async fn ensure_core_running(app: &AppHandle) -> Result<(), String> {
let core = app
.try_state::<CoreProcessHandle>()
.ok_or_else(|| "core process handle is not available".to_string())?;
let handle: CoreProcessHandle = (*core).clone();
handle.ensure_running().await
}
async fn call_service_method(app: &AppHandle, method: &str) -> Result<ServiceStatus, String> {
ensure_core_running(app).await?;
let response =
crate::core_rpc::call::<RpcCommandResponse<ServiceStatus>>(method, serde_json::json!({}))
.await?;
Ok(response.result)
}
#[derive(Debug, Deserialize)]
struct WebChatSseEvent {
event: String,
client_id: String,
thread_id: String,
request_id: String,
full_response: Option<String>,
message: Option<String>,
error_type: Option<String>,
tool_name: Option<String>,
skill_id: Option<String>,
args: Option<Value>,
output: Option<String>,
success: Option<bool>,
round: Option<u32>,
}
fn core_events_url() -> String {
let rpc_url = crate::core_rpc::resolved_rpc_url();
if rpc_url.ends_with("/rpc") {
rpc_url.trim_end_matches("/rpc").to_string() + "/events"
} else {
rpc_url + "/events"
}
}
fn parse_sse_block(block: &str) -> Option<WebChatSseEvent> {
let mut event_name: Option<String> = None;
let mut data_lines: Vec<&str> = Vec::new();
for line in block.lines() {
if let Some(rest) = line.strip_prefix("event:") {
event_name = Some(rest.trim().to_string());
} else if let Some(rest) = line.strip_prefix("data:") {
data_lines.push(rest.trim_start());
}
}
if data_lines.is_empty() {
return None;
}
let data = data_lines.join("\n");
let mut parsed: WebChatSseEvent = serde_json::from_str(&data).ok()?;
if parsed.event.is_empty() {
parsed.event = event_name.unwrap_or_default();
}
Some(parsed)
}
fn emit_web_chat_event(app: &AppHandle, event: WebChatSseEvent) {
if event.client_id != *WEB_CHAT_CLIENT_ID {
return;
}
match event.event.as_str() {
"chat_done" => {
let payload = serde_json::json!({
"thread_id": event.thread_id,
"full_response": event.full_response.unwrap_or_default(),
"rounds_used": 0,
"total_input_tokens": 0,
"total_output_tokens": 0,
"request_id": event.request_id,
});
let _ = app.emit("chat:done", payload);
}
"chat_error" => {
let payload = serde_json::json!({
"thread_id": event.thread_id,
"message": event.message.unwrap_or_else(|| "Unknown chat error".to_string()),
"error_type": event.error_type.unwrap_or_else(|| "inference".to_string()),
"round": serde_json::Value::Null,
"request_id": event.request_id,
});
let _ = app.emit("chat:error", payload);
}
"tool_call" => {
let payload = serde_json::json!({
"thread_id": event.thread_id,
"tool_name": event.tool_name.unwrap_or_else(|| "unknown".to_string()),
"skill_id": event.skill_id.unwrap_or_else(|| "web_channel".to_string()),
"args": event.args.unwrap_or_else(|| serde_json::json!({})),
"round": event.round.unwrap_or(0),
"request_id": event.request_id,
});
let _ = app.emit("chat:tool_call", payload);
}
"tool_result" => {
let payload = serde_json::json!({
"thread_id": event.thread_id,
"tool_name": event.tool_name.unwrap_or_else(|| "unknown".to_string()),
"skill_id": event.skill_id.unwrap_or_else(|| "web_channel".to_string()),
"output": event.output.unwrap_or_default(),
"success": event.success.unwrap_or(true),
"round": event.round.unwrap_or(0),
"request_id": event.request_id,
});
let _ = app.emit("chat:tool_result", payload);
}
_ => {}
}
}
async fn ensure_web_chat_stream(app: &AppHandle) -> Result<(), String> {
let mut guard = WEB_CHAT_STREAM_TASK.lock().await;
if guard.is_some() {
return Ok(());
}
let app_handle = app.clone();
let client_id = WEB_CHAT_CLIENT_ID.clone();
let url = format!(
"{}?client_id={}",
core_events_url(),
urlencoding::encode(&client_id)
);
let task = tokio::spawn(async move {
let client = reqwest::Client::new();
let mut backoff_ms: u64 = 500;
loop {
let response = match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => resp,
Ok(resp) => {
log::warn!(
"[web-channel] SSE stream HTTP error status={} for {}",
resp.status(),
url
);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
backoff_ms = (backoff_ms * 2).min(10_000);
continue;
}
Err(err) => {
log::warn!("[web-channel] SSE connect error: {}", err);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
backoff_ms = (backoff_ms * 2).min(10_000);
continue;
}
};
backoff_ms = 500;
let mut stream = response.bytes_stream();
let mut buffer = String::new();
use futures_util::StreamExt as _;
while let Some(item) = stream.next().await {
let chunk = match item {
Ok(chunk) => chunk,
Err(err) => {
log::warn!("[web-channel] SSE stream read error: {}", err);
break;
}
};
let text = match std::str::from_utf8(&chunk) {
Ok(s) => s,
Err(_) => continue,
};
buffer.push_str(text);
while let Some(split_index) = buffer.find("\n\n") {
let block = buffer[..split_index].to_string();
buffer = buffer[split_index + 2..].to_string();
if let Some(event) = parse_sse_block(block.trim()) {
emit_web_chat_event(&app_handle, event);
}
}
}
}
});
*guard = Some(task);
Ok(())
}
#[tauri::command]
pub async fn chat_send(
app: AppHandle,
thread_id: String,
message: String,
model: String,
auth_token: String,
backend_url: String,
messages: Vec<Value>,
notion_context: Option<String>,
) -> Result<(), String> {
ensure_web_chat_stream(&app).await?;
let _ = (&auth_token, &backend_url, &messages, &notion_context);
let params = serde_json::json!({
"client_id": WEB_CHAT_CLIENT_ID.as_str(),
"thread_id": thread_id,
"message": message,
"model_override": model,
});
let _: Value = crate::core_rpc::call("openhuman.channel_web_chat", params).await?;
Ok(())
}
#[tauri::command]
pub async fn chat_cancel(thread_id: String) -> Result<bool, String> {
let params = serde_json::json!({
"client_id": WEB_CHAT_CLIENT_ID.as_str(),
"thread_id": thread_id,
});
let response: Value = crate::core_rpc::call("openhuman.channel_web_cancel", params).await?;
let cancelled = response
.get("result")
.and_then(|v| v.get("cancelled"))
.or_else(|| response.get("cancelled"))
.and_then(Value::as_bool)
.unwrap_or(false);
Ok(cancelled)
}
#[tauri::command]
pub async fn openhuman_get_daemon_host_config(app: AppHandle) -> Result<DaemonHostConfig, String> {
Ok(load_daemon_host_config(&app).await)
}
#[tauri::command]
pub async fn openhuman_set_daemon_host_config(
app: AppHandle,
show_tray: bool,
) -> Result<DaemonHostConfig, String> {
let mut cfg = load_daemon_host_config(&app).await;
cfg.show_tray = show_tray;
save_daemon_host_config(&app, &cfg).await?;
Ok(cfg)
}
#[tauri::command]
pub async fn openhuman_service_install(app: AppHandle) -> Result<ServiceStatus, String> {
call_service_method(&app, "openhuman.service_install").await
}
#[tauri::command]
pub async fn openhuman_service_start(app: AppHandle) -> Result<ServiceStatus, String> {
call_service_method(&app, "openhuman.service_start").await
}
#[tauri::command]
pub async fn openhuman_service_stop(app: AppHandle) -> Result<ServiceStatus, String> {
call_service_method(&app, "openhuman.service_stop").await
}
#[tauri::command]
pub async fn openhuman_service_status(app: AppHandle) -> Result<ServiceStatus, String> {
call_service_method(&app, "openhuman.service_status").await
}
#[tauri::command]
pub async fn openhuman_service_uninstall(app: AppHandle) -> Result<ServiceStatus, String> {
call_service_method(&app, "openhuman.service_uninstall").await
}
-80
View File
@@ -1,80 +0,0 @@
use tauri::{AppHandle, Manager};
/// Show the main window
#[tauri::command]
pub fn show_window(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
/// Hide the main window
#[tauri::command]
pub fn hide_window(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
/// Toggle window visibility
#[tauri::command]
pub fn toggle_window(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
match window.is_visible() {
Ok(true) => {
let _ = window.hide();
}
Ok(false) | Err(_) => {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
}
}
/// Check if window is visible
#[tauri::command]
pub fn is_window_visible(app: AppHandle) -> bool {
app.get_webview_window("main")
.and_then(|w| w.is_visible().ok())
.unwrap_or(false)
}
/// Minimize the main window
#[tauri::command]
pub fn minimize_window(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.minimize();
}
}
/// Maximize or unmaximize the main window
#[tauri::command]
pub fn maximize_window(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if window.is_maximized().unwrap_or(false) {
let _ = window.unmaximize();
} else {
let _ = window.maximize();
}
}
}
/// Close the main window (triggers minimize on macOS if configured)
#[tauri::command]
pub fn close_window(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.close();
}
}
/// Set window title
#[tauri::command]
pub fn set_window_title(app: AppHandle, title: String) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_title(&title);
}
}
+15 -2
View File
@@ -1,9 +1,11 @@
use std::path::PathBuf;
use std::sync::Arc;
use tokio::net::TcpStream;
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::time::{timeout, Duration};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CoreRunMode {
@@ -35,8 +37,19 @@ impl CoreProcessHandle {
format!("http://127.0.0.1:{}/rpc", self.port)
}
async fn is_rpc_port_open(&self) -> bool {
matches!(
timeout(
Duration::from_millis(150),
TcpStream::connect(("127.0.0.1", self.port)),
)
.await,
Ok(Ok(_))
)
}
pub async fn ensure_running(&self) -> Result<(), String> {
if crate::core_rpc::ping().await {
if self.is_rpc_port_open().await {
log::info!(
"[core] found existing core rpc endpoint at {}",
self.rpc_url()
@@ -115,7 +128,7 @@ impl CoreProcessHandle {
}
for _ in 0..40 {
if crate::core_rpc::ping().await {
if self.is_rpc_port_open().await {
log::info!("[core] core rpc became ready at {}", self.rpc_url());
return Ok(());
}
-90
View File
@@ -1,90 +0,0 @@
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
const DEFAULT_CORE_RPC_URL: &str = "http://127.0.0.1:7788/rpc";
#[derive(Debug, Serialize)]
struct RpcRequest<'a> {
jsonrpc: &'static str,
id: u64,
method: &'a str,
params: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct RpcResponse<T> {
#[allow(dead_code)]
jsonrpc: String,
#[allow(dead_code)]
id: serde_json::Value,
result: Option<T>,
error: Option<RpcError>,
}
#[derive(Debug, Deserialize)]
struct RpcError {
#[allow(dead_code)]
code: i64,
message: String,
data: Option<serde_json::Value>,
}
fn rpc_url() -> String {
std::env::var("OPENHUMAN_CORE_RPC_URL").unwrap_or_else(|_| DEFAULT_CORE_RPC_URL.to_string())
}
pub fn resolved_rpc_url() -> String {
rpc_url()
}
pub async fn call<T: DeserializeOwned>(
method: &str,
params: serde_json::Value,
) -> Result<T, String> {
let client = reqwest::Client::new();
let req = RpcRequest {
jsonrpc: "2.0",
id: 1,
method,
params,
};
let resp = client
.post(rpc_url())
.json(&req)
.send()
.await
.map_err(|e| format!("core rpc request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("core rpc http error {status}: {text}"));
}
let payload: RpcResponse<T> = resp
.json()
.await
.map_err(|e| format!("core rpc decode failed: {e}"))?;
if let Some(err) = payload.error {
let data = err.data.map(|d| format!(" ({d})")).unwrap_or_default();
return Err(format!("{}{}", err.message, data));
}
payload
.result
.ok_or_else(|| "core rpc missing result".to_string())
}
pub async fn ping() -> bool {
#[derive(Deserialize)]
struct Pong {
ok: bool,
}
match call::<Pong>("core.ping", serde_json::json!({})).await {
Ok(p) => p.ok,
Err(_) => false,
}
}
+49 -571
View File
@@ -1,539 +1,59 @@
//! OpenHuman Desktop Application
//!
//! This is the Rust backend for the cross-platform crypto community platform.
//! It provides deep link handling, core process RPC relay, window management,
//! and AI configuration helpers.
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supported.");
mod commands;
mod core_process;
mod core_rpc;
mod utils;
use commands::*;
use serde::Serialize;
use serde_json::json;
use std::collections::HashMap;
use std::path::PathBuf;
use tauri::{AppHandle, Emitter, Manager, RunEvent};
use tokio::time::{interval, Duration};
use tauri::{Manager, RunEvent};
// register_all() is only for Windows/Linux; macOS uses Info.plist (see docs).
#[cfg(any(windows, target_os = "linux"))]
use tauri_plugin_deep_link::DeepLinkExt;
/// Demo command - can be removed in production
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AIPreview {
soul: AIPreviewSoul,
tools: AIPreviewTools,
metadata: AIPreviewMetadata,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AIPreviewSoul {
raw: String,
name: String,
description: String,
personality_preview: Vec<String>,
safety_rules_preview: Vec<String>,
loaded_at: i64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AIPreviewTools {
raw: String,
total_tools: usize,
active_skills: usize,
skills_preview: Vec<String>,
loaded_at: i64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AIPreviewMetadata {
loaded_at: i64,
loading_duration: i64,
has_fallbacks: bool,
sources: AIPreviewSources,
errors: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AIPreviewSources {
soul: String,
tools: String,
}
fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
fn extract_section(raw: &str, heading: &str) -> String {
let marker = format!("## {heading}");
let Some(start) = raw.find(&marker) else {
return String::new();
};
let body = &raw[start + marker.len()..];
if let Some(next_idx) = body.find("\n## ") {
body[..next_idx].trim().to_string()
} else {
body.trim().to_string()
}
}
fn parse_soul_preview(raw: String, loaded_at: i64) -> AIPreviewSoul {
let name = raw
.lines()
.find_map(|line| line.strip_prefix("# ").map(|s| s.trim().to_string()))
.unwrap_or_else(|| "OpenHuman".to_string());
let description = raw
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with('#'))
.unwrap_or("AI assistant")
.to_string();
let personality_preview = extract_section(&raw, "Personality")
.lines()
.filter_map(|line| line.trim().strip_prefix("- **"))
.filter_map(|line| {
let mut parts = line.splitn(2, "**:");
let trait_name = parts.next()?.trim();
let detail = parts.next().unwrap_or("").trim();
Some(format!("{trait_name}: {detail}"))
})
.take(3)
.collect::<Vec<_>>();
let safety_rules_preview = extract_section(&raw, "Safety Rules")
.lines()
.filter_map(|line| {
let trimmed = line.trim();
let dot_idx = trimmed.find('.')?;
let (prefix, rest) = trimmed.split_at(dot_idx);
if prefix.chars().all(|c| c.is_ascii_digit()) {
Some(rest.trim_start_matches('.').trim().to_string())
} else {
None
}
})
.take(3)
.collect::<Vec<_>>();
AIPreviewSoul {
raw,
name,
description,
personality_preview,
safety_rules_preview,
loaded_at,
}
}
fn parse_tools_preview(raw: String, loaded_at: i64) -> AIPreviewTools {
let mut current_skill = "General".to_string();
let mut skill_counts: HashMap<String, usize> = HashMap::new();
let mut total_tools = 0usize;
for line in raw.lines() {
let trimmed = line.trim();
if let Some(title) = trimmed.strip_prefix("### ") {
if let Some(skill_title) = title.strip_suffix(" Tools") {
current_skill = skill_title.trim().to_string();
skill_counts.entry(current_skill.clone()).or_insert(0);
}
continue;
}
if trimmed.starts_with("#### ") {
total_tools += 1;
*skill_counts.entry(current_skill.clone()).or_insert(0) += 1;
}
}
let mut skills = skill_counts.into_iter().collect::<Vec<_>>();
let active_skills = skills.len();
skills.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let skills_preview = skills
.into_iter()
.take(6)
.map(|(name, count)| format!("{name} ({count})"))
.collect::<Vec<_>>();
AIPreviewTools {
raw,
total_tools,
active_skills,
skills_preview,
loaded_at,
}
}
fn resolve_ai_directory(app: &tauri::AppHandle) -> Option<(PathBuf, &'static str)> {
if let Ok(resource_dir) = app.path().resource_dir() {
if let Some(ai_dir) = utils::dev_paths::bundled_openclaw_prompts_dir(&resource_dir) {
return Some((ai_dir, "bundled"));
}
}
if let Ok(cwd) = std::env::current_dir() {
if let Some(path) = utils::dev_paths::repo_ai_prompts_dir(&cwd) {
return Some((path, "bundled"));
}
let fallback = cwd.join("ai");
if fallback.is_dir() {
return Some((fallback, "bundled"));
}
}
None
}
fn build_ai_preview(app: &tauri::AppHandle) -> AIPreview {
let started = now_ms();
let loaded_at = now_ms();
let mut errors = Vec::new();
let mut soul_raw = String::new();
let mut tools_raw = String::new();
let mut source = "bundled".to_string();
if let Some((ai_dir, resolved_source)) = resolve_ai_directory(app) {
source = resolved_source.to_string();
let soul_path = ai_dir.join("SOUL.md");
let tools_path = ai_dir.join("TOOLS.md");
soul_raw = std::fs::read_to_string(&soul_path).unwrap_or_else(|e| {
errors.push(format!("Failed to read SOUL.md: {e}"));
String::new()
});
tools_raw = std::fs::read_to_string(&tools_path).unwrap_or_else(|e| {
errors.push(format!("Failed to read TOOLS.md: {e}"));
String::new()
});
} else {
errors.push("AI config directory not found".to_string());
}
let soul = parse_soul_preview(soul_raw, loaded_at);
let tools = parse_tools_preview(tools_raw, loaded_at);
let done = now_ms();
AIPreview {
soul,
tools,
metadata: AIPreviewMetadata {
loaded_at: done,
loading_duration: done - started,
has_fallbacks: false,
sources: AIPreviewSources {
soul: source.clone(),
tools: source,
},
errors,
},
}
}
#[tauri::command]
async fn ai_get_config(app: tauri::AppHandle) -> Result<AIPreview, String> {
Ok(build_ai_preview(&app))
}
#[tauri::command]
async fn ai_refresh_config(app: tauri::AppHandle) -> Result<AIPreview, String> {
Ok(build_ai_preview(&app))
}
/// Write AI configuration files to `src/openhuman/agent/prompts` in the repo (dev resolution from cwd).
#[tauri::command]
async fn write_ai_config_file(filename: String, content: String) -> Result<bool, String> {
use std::env;
// Determine runtime working directory
let current_dir =
env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
// Ensure filename is safe (only allow .md files)
if !filename.ends_with(".md") {
return Err("Only .md files are allowed".to_string());
}
// Prevent path traversal by checking for dangerous characters
if filename.contains("..") || filename.contains("/") || filename.contains("\\") {
return Err("Invalid filename: path traversal not allowed".to_string());
}
let ai_dir = utils::dev_paths::repo_ai_prompts_dir(&current_dir).unwrap_or_else(|| {
current_dir
.join("src")
.join("openhuman")
.join("agent")
.join("prompts")
});
let file_path = ai_dir.join(&filename);
// Ensure ai directory exists
std::fs::create_dir_all(&ai_dir).map_err(|e| format!("Failed to create ai directory: {e}"))?;
// Write the file
std::fs::write(&file_path, content)
.map_err(|e| format!("Failed to write file {}: {e}", filename))?;
Ok(true)
fn core_rpc_url() -> String {
std::env::var("OPENHUMAN_CORE_RPC_URL")
.unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".to_string())
}
fn is_daemon_mode() -> bool {
std::env::args().any(|arg| arg == "daemon" || arg == "--daemon")
}
/// Poll core RPC health and bridge updates to frontend Tauri events.
async fn watch_daemon_health_rpc(app_handle: AppHandle) {
let mut interval = interval(Duration::from_secs(2));
let mut last_snapshot: Option<serde_json::Value> = None;
let mut had_error = false;
log::info!("[openhuman] Watching daemon health via core RPC (openhuman.health_snapshot)");
loop {
interval.tick().await;
match crate::core_rpc::call::<serde_json::Value>("openhuman.health_snapshot", json!({}))
.await
{
Ok(raw_payload) => {
// RpcOutcome may be wrapped as {"result": {...}, "logs": [...]}; normalize to snapshot.
let snapshot = raw_payload.get("result").cloned().unwrap_or(raw_payload);
if last_snapshot.as_ref() != Some(&snapshot) {
if let Err(e) = app_handle.emit("openhuman:health", &snapshot) {
log::error!("[openhuman] Failed to emit health event from RPC: {e}");
} else {
last_snapshot = Some(snapshot);
}
}
if had_error {
had_error = false;
log::info!("[openhuman] Health RPC polling recovered");
}
}
Err(e) => {
if !had_error {
had_error = true;
log::debug!("[openhuman] Health RPC not ready yet: {e}");
}
}
}
}
}
pub fn run() {
if let Err(err) = rustls::crypto::ring::default_provider().install_default() {
log::warn!(
"[app] rustls crypto provider not installed (already set?): {:?}",
err
);
} else {
log::info!("[app] rustls crypto provider installed (ring)");
}
let daemon_mode = is_daemon_mode();
// Initialize logger
{
use env_logger::fmt::style::{AnsiColor, Style};
use std::io::Write;
let default_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string());
let _ = env_logger::Builder::new()
.parse_filters(&default_filter)
.try_init();
let default_filter = std::env::var("RUST_LOG")
.unwrap_or_else(|_| "info,tungstenite=warn,tokio_tungstenite=warn,reqwest=warn,rusqlite=warn,hyper=warn,h2=warn".to_string());
let write_style = std::env::var("RUST_LOG_STYLE")
.map(|v| match v.as_str() {
"never" => env_logger::fmt::WriteStyle::Never,
_ => env_logger::fmt::WriteStyle::Always,
})
.unwrap_or(env_logger::fmt::WriteStyle::Always);
let _ = env_logger::Builder::new()
.parse_filters(&default_filter)
.write_style(write_style)
.format(|buf, record| {
let timestamp = buf.timestamp_millis()
.to_string();
// Strip the date prefix, keep only HH:MM:SS.mmm
let time_only = timestamp.split('T')
.nth(1)
.and_then(|t| t.strip_suffix('Z'))
.unwrap_or(&timestamp);
let level = record.level();
// Level colors
let level_style = match level {
log::Level::Error => Style::new().fg_color(Some(AnsiColor::Red.into())).bold(),
log::Level::Warn => Style::new().fg_color(Some(AnsiColor::Yellow.into())).bold(),
log::Level::Info => Style::new().fg_color(Some(AnsiColor::Green.into())),
log::Level::Debug => Style::new().fg_color(Some(AnsiColor::BrightBlack.into())),
log::Level::Trace => Style::new().fg_color(Some(AnsiColor::BrightBlack.into())),
};
let msg = format!("{}", record.args());
// Extract tag from message (e.g. "[socket-mgr]", "[skill:x]")
let (tag, rest) = if msg.starts_with('[') {
if let Some(end) = msg.find(']') {
let tag = &msg[..=end];
let rest = msg[end + 1..].trim_start();
(Some(tag.to_string()), rest.to_string())
} else {
(None, msg)
}
} else {
(None, msg)
};
// Tag-based colors
let tag_style = if let Some(ref t) = tag {
let t_lower = t.to_lowercase();
if t_lower.contains("socket") {
Style::new().fg_color(Some(AnsiColor::Blue.into())).bold()
} else if t_lower.contains("runtime") {
Style::new().fg_color(Some(AnsiColor::Cyan.into())).bold()
} else if t_lower.contains("skill") {
Style::new().fg_color(Some(AnsiColor::Green.into())).bold()
} else if t_lower.contains("ping") || t_lower.contains("cron") {
Style::new().fg_color(Some(AnsiColor::Yellow.into())).bold()
} else if t_lower.contains("app") {
Style::new().fg_color(Some(AnsiColor::White.into())).bold()
} else if t_lower.contains("ai") {
Style::new().fg_color(Some(AnsiColor::BrightMagenta.into())).bold()
} else {
Style::new().fg_color(Some(AnsiColor::BrightBlack.into()))
}
} else {
Style::new()
};
let dim = Style::new().fg_color(Some(AnsiColor::BrightBlack.into()));
if let Some(ref t) = tag {
writeln!(
buf,
"{dim}{time_only}{dim:#} {level_style}{level:<5}{level_style:#} {tag_style}{t}{tag_style:#} {rest}"
)
} else {
writeln!(
buf,
"{dim}{time_only}{dim:#} {level_style}{level:<5}{level_style:#} {rest}"
)
}
})
.try_init();
}
let mut builder = tauri::Builder::default()
// Plugins
.plugin(tauri_plugin_opener::init())
tauri::Builder::default()
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_os::init());
// Add desktop-only plugins (autostart, notification)
#[cfg(desktop)]
{
builder = builder
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec!["--daemon"]),
))
.plugin(tauri_plugin_notification::init());
}
builder
// Setup
.setup(move |app| {
// Register schemes at runtime (Windows/Linux only). macOS uses bundle Info.plist.
#[cfg(any(windows, target_os = "linux"))]
{
app.deep_link().register_all()?;
}
// macOS-specific: Handle window close event to minimize to tray
#[cfg(target_os = "macos")]
{
if let Some(window) = app.get_webview_window("main") {
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// Prevent the window from closing, hide it instead
api.prevent_close();
if let Some(win) = app_handle.get_webview_window("main") {
let _ = win.hide();
}
}
});
}
}
// Bridge daemon health via core RPC; optionally ensure OS background service (release only).
{
let app_handle_for_watcher = app.handle().clone();
tauri::async_runtime::spawn(async move {
watch_daemon_health_rpc(app_handle_for_watcher).await;
});
if commands::core_relay::should_autostart_service_managed_core() {
tauri::async_runtime::spawn(async move {
match commands::core_relay::ensure_service_managed_core_running().await {
Ok(()) => {
log::info!("[openhuman] Core background service ensured via core RPC");
}
Err(e) => {
log::error!(
"[openhuman] Failed to ensure core background service: {e}"
);
}
}
});
let core_run_mode = core_process::default_core_run_mode(daemon_mode);
let core_bin = if matches!(core_run_mode, core_process::CoreRunMode::ChildProcess) {
core_process::default_core_bin()
} else {
None
};
let core_handle = core_process::CoreProcessHandle::new(
core_process::default_core_port(),
core_bin,
core_run_mode,
);
std::env::set_var("OPENHUMAN_CORE_RPC_URL", core_handle.rpc_url());
app.manage(core_handle.clone());
tauri::async_runtime::spawn(async move {
if let Err(err) = core_handle.ensure_running().await {
log::error!("[core] failed to start core process: {err}");
} else {
log::info!(
"[openhuman] Skipping OS background service autostart (dev build or OPENHUMAN_SKIP_SERVICE_AUTOSTART)"
);
log::info!("[core] core process ready");
}
}
// Start/ensure standalone core process for business logic RPC.
{
let core_run_mode = core_process::default_core_run_mode(daemon_mode);
let core_bin = if matches!(core_run_mode, core_process::CoreRunMode::ChildProcess) {
core_process::default_core_bin()
} else {
None
};
let core_handle = core_process::CoreProcessHandle::new(
core_process::default_core_port(),
core_bin,
core_run_mode,
);
std::env::set_var("OPENHUMAN_CORE_RPC_URL", core_handle.rpc_url());
app.manage(core_handle.clone());
tauri::async_runtime::spawn(async move {
if let Err(err) = core_handle.ensure_running().await {
log::error!("[core] failed to start core process: {err}");
} else {
log::info!("[core] core process ready");
}
});
}
});
if daemon_mode {
if let Some(window) = app.get_webview_window("main") {
@@ -543,39 +63,7 @@ pub fn run() {
Ok(())
})
// Register all commands (desktop build lists handlers explicitly below).
.invoke_handler({
#[cfg(desktop)]
{
tauri::generate_handler![
greet,
// AI config file writing
write_ai_config_file,
ai_get_config,
ai_refresh_config,
core_rpc_relay,
core_rpc_url,
show_window,
hide_window,
toggle_window,
is_window_visible,
minimize_window,
maximize_window,
close_window,
set_window_title,
// OpenHuman local host commands (core RPC uses core_rpc_relay)
openhuman_get_daemon_host_config,
openhuman_set_daemon_host_config,
openhuman_service_install,
openhuman_service_start,
openhuman_service_stop,
openhuman_service_status,
openhuman_service_uninstall,
chat_send,
chat_cancel,
]
}
})
.invoke_handler(tauri::generate_handler![core_rpc_url])
.build({
let mut context = tauri::generate_context!();
if daemon_mode {
@@ -584,48 +72,38 @@ pub fn run() {
context
})
.expect("error while building tauri application")
.run(move |app_handle, event| {
match event {
// Handle macOS Dock icon click (reopen event)
#[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => {
if !daemon_mode {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
.run(move |app_handle, event| match event {
#[cfg(target_os = "macos")]
RunEvent::Reopen { .. } => {
if !daemon_mode {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
// Stop the core sidecar we spawned so dev/prod do not leave orphan `openhuman core run`.
RunEvent::Exit => {
log::info!("[app] Exit event received, shutting down");
if let Some(core) = app_handle.try_state::<core_process::CoreProcessHandle>() {
let core = core.inner().clone();
tauri::async_runtime::block_on(async move {
core.shutdown().await;
});
}
}
_ => {
let _ = app_handle;
}
}
RunEvent::Exit => {
if let Some(core) = app_handle.try_state::<core_process::CoreProcessHandle>() {
let core = core.inner().clone();
tauri::async_runtime::block_on(async move {
core.shutdown().await;
});
}
}
_ => {}
});
}
pub fn run_core_from_args(args: &[String]) -> anyhow::Result<()> {
pub fn run_core_from_args(args: &[String]) -> Result<(), String> {
let core_bin = crate::core_process::default_core_bin()
.ok_or_else(|| anyhow::anyhow!("openhuman core binary not found"))?;
.ok_or_else(|| "openhuman core binary not found".to_string())?;
let status = std::process::Command::new(core_bin)
.args(args)
.status()
.map_err(|e| anyhow::anyhow!("failed to execute core binary: {e}"))?;
.map_err(|e| format!("failed to execute core binary: {e}"))?;
if !status.success() {
anyhow::bail!("core binary exited with status {status}");
return Err(format!("core binary exited with status {status}"));
}
Ok(())
}
-49
View File
@@ -1,49 +0,0 @@
//! Dev-time path resolution for bundled / repo AI prompts (desktop host).
use std::path::{Path, PathBuf};
/// Best-effort path to repo `src/openhuman/agent/prompts` when running from a checkout.
pub fn repo_ai_prompts_dir(cwd: &Path) -> Option<PathBuf> {
let prompts = cwd
.join("src")
.join("openhuman")
.join("agent")
.join("prompts");
if prompts.is_dir() {
return Some(prompts);
}
let from_app = cwd
.join("..")
.join("src")
.join("openhuman")
.join("agent")
.join("prompts");
if from_app.is_dir() {
return Some(from_app);
}
let app_prompts = cwd
.join("app")
.join("src")
.join("openhuman")
.join("agent")
.join("prompts");
if app_prompts.is_dir() {
return Some(app_prompts);
}
None
}
/// Bundled OpenClaw-style prompts inside the packaged app resource dir.
pub fn bundled_openclaw_prompts_dir(resource_dir: &Path) -> Option<PathBuf> {
let candidates = [
resource_dir.join("openhuman").join("agent").join("prompts"),
resource_dir.join("ai").join("prompts"),
resource_dir.join("_up_").join("ai").join("prompts"),
];
for p in candidates {
if p.is_dir() {
return Some(p);
}
}
None
}
-1
View File
@@ -1 +0,0 @@
pub mod dev_paths;
+12 -2
View File
@@ -17,6 +17,7 @@ import Skills from './pages/Skills';
import Welcome from './pages/Welcome';
import { selectHasEncryptionKey, selectIsOnboarded } from './store/authSelectors';
import { useAppSelector } from './store/hooks';
import { DEV_FORCE_ONBOARDING } from './utils/config';
import {
DEFAULT_WORKSPACE_ONBOARDING_FLAG,
openhumanWorkspaceOnboardingFlagExists,
@@ -33,7 +34,9 @@ const OnboardingRoute = ({
}: OnboardingRouteProps) => {
const isOnboarded = useAppSelector(selectIsOnboarded);
const hasEncryptionKey = useAppSelector(selectHasEncryptionKey);
const shouldSkipOnboarding = isOnboarded || hasWorkspaceOnboardingFlag;
const shouldSkipOnboarding = DEV_FORCE_ONBOARDING
? false
: isOnboarded || hasWorkspaceOnboardingFlag;
if (isWorkspaceFlagLoading) return <RouteLoadingScreen label="Loading workspace..." />;
if (shouldSkipOnboarding && !hasEncryptionKey) return <Navigate to="/mnemonic" replace />;
@@ -61,7 +64,9 @@ const HomeRoute = ({ hasWorkspaceOnboardingFlag, isWorkspaceFlagLoading }: HomeR
const isOnboarded = useAppSelector(selectIsOnboarded);
const hasEncryptionKey = useAppSelector(selectHasEncryptionKey);
const shouldSkipOnboarding = isOnboarded || hasWorkspaceOnboardingFlag;
const shouldSkipOnboarding = DEV_FORCE_ONBOARDING
? false
: isOnboarded || hasWorkspaceOnboardingFlag;
// While user profile is still loading, show Home (avoid flash to onboarding)
if (!user) return <Home />;
@@ -83,6 +88,11 @@ const AppRoutes = () => {
const [isWorkspaceFlagLoading, setIsWorkspaceFlagLoading] = useState(true);
useEffect(() => {
if (DEV_FORCE_ONBOARDING) {
setHasWorkspaceOnboardingFlag(false);
setIsWorkspaceFlagLoading(false);
return;
}
let mounted = true;
const loadWorkspaceFlag = async () => {
try {
+26 -31
View File
@@ -1,11 +1,9 @@
/**
* Daemon Health Service
*
* Manages health monitoring for the openhuman daemon by listening to
* 'openhuman:health' events emitted by the Rust backend every 5 seconds.
* Manages health monitoring for the openhuman daemon by polling
* `openhuman.health_snapshot` over core RPC from the frontend.
*/
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { store } from '../store';
import {
type ComponentHealth,
@@ -14,55 +12,52 @@ import {
setHealthTimeoutId,
updateHealthSnapshot,
} from '../store/daemonSlice';
import { callCoreRpc } from './coreRpcClient';
export class DaemonHealthService {
private healthTimeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly HEALTH_TIMEOUT_MS = 30000; // 30 seconds
private healthEventListener: UnlistenFn | null = null;
private pollingIntervalId: ReturnType<typeof setInterval> | null = null;
private readonly POLL_MS = 2000;
/**
* Setup health event listener from the Rust daemon.
* Should be called once when the app starts in Tauri mode.
*/
async setupHealthListener(): Promise<UnlistenFn | null> {
console.log('[DaemonHealth] setupHealthListener() called - starting setup process');
try {
// console.log('[DaemonHealth] About to call listen() for openhuman:health event');
// console.log('[DaemonHealth] Setting up openhuman:health event listener');
async setupHealthListener(): Promise<(() => void) | null> {
if (this.pollingIntervalId) {
return () => this.cleanup();
}
this.healthEventListener = await listen<unknown>('openhuman:health', event => {
// console.log('[DaemonHealth] Received health event:', event.payload);
const healthSnapshot = this.parseHealthSnapshot(event.payload);
const pollOnce = async () => {
try {
const payload = await callCoreRpc<unknown>({ method: 'openhuman.health_snapshot' });
const healthSnapshot = this.parseHealthSnapshot(payload);
if (healthSnapshot) {
this.updateReduxFromHealth(healthSnapshot);
this.startHealthTimeout();
} else {
console.warn('[DaemonHealth] Failed to parse health snapshot:', event.payload);
}
});
console.log('[DaemonHealth] openhuman:health listener created successfully');
} catch {
// Health endpoint can fail while sidecar is starting; timeout will mark disconnected.
}
};
// Start initial timeout
// console.log('[DaemonHealth] Starting health timeout');
this.startHealthTimeout();
// console.log('[DaemonHealth] Health timeout started');
await pollOnce();
this.pollingIntervalId = setInterval(() => {
void pollOnce();
}, this.POLL_MS);
this.startHealthTimeout();
// console.log('[DaemonHealth] Health listener setup complete');
return this.healthEventListener;
} catch (error) {
console.error('[DaemonHealth] Failed to setup health listener:', error);
return null;
}
return () => this.cleanup();
}
/**
* Cleanup the health event listener.
*/
cleanup(): void {
if (this.healthEventListener) {
this.healthEventListener();
this.healthEventListener = null;
if (this.pollingIntervalId) {
clearInterval(this.pollingIntervalId);
this.pollingIntervalId = null;
}
if (this.healthTimeoutId) {
+4
View File
@@ -5,5 +5,9 @@ export const CORE_RPC_URL =
export const IS_DEV = import.meta.env.DEV;
/** Dev only: skip `.skip_onboarding` workspace check and ignore onboarded state so `/onboarding` always shows. Set `VITE_DEV_FORCE_ONBOARDING=true` in `.env.local`. */
export const DEV_FORCE_ONBOARDING =
import.meta.env.DEV && import.meta.env.VITE_DEV_FORCE_ONBOARDING === 'true';
export const SKILLS_GITHUB_REPO =
import.meta.env.VITE_SKILLS_GITHUB_REPO || 'tinyhumansai/openhuman-skills';
+16 -16
View File
@@ -1,4 +1,5 @@
import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core';
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { skillManager } from '../lib/skills/manager';
@@ -35,6 +36,17 @@ function getCurrentUserId(): string | null {
}
}
const focusMainWindow = async () => {
try {
const window = getCurrentWindow();
await window.show();
await window.unminimize();
await window.setFocus();
} catch (err) {
console.warn('[DeepLink] Failed to focus window:', err);
}
};
/**
* Handle an `openhuman://auth?token=...` deep link for login.
*/
@@ -48,11 +60,7 @@ const handleAuthDeepLink = async (parsed: URL) => {
console.log('[DeepLink] Received auth token', token);
try {
await invoke('show_window');
} catch (err) {
console.warn('[DeepLink] Failed to show window:', err);
}
await focusMainWindow();
if (key === 'auth') {
store.dispatch(setToken(token));
@@ -72,11 +80,7 @@ const handleAuthDeepLink = async (parsed: URL) => {
const handlePaymentDeepLink = async (parsed: URL) => {
const path = parsed.pathname.replace(/^\/+/, '');
try {
await invoke('show_window');
} catch {
// Not fatal
}
await focusMainWindow();
if (path === 'success') {
const sessionId = parsed.searchParams.get('session_id');
@@ -110,11 +114,7 @@ const handleOAuthDeepLink = async (parsed: URL) => {
// pathname is "/success" or "/error" (hostname is "oauth")
const path = parsed.pathname.replace(/^\/+/, '');
try {
await invoke('show_window');
} catch {
// Not fatal
}
await focusMainWindow();
if (path === 'success') {
const integrationId = parsed.searchParams.get('integrationId');
+55 -40
View File
@@ -4,6 +4,7 @@
* Helper functions for invoking Tauri commands from the frontend.
*/
import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { callCoreRpc } from '../services/coreRpcClient';
@@ -86,7 +87,10 @@ export async function showWindow(): Promise<void> {
return;
}
await invoke('show_window');
const window = getCurrentWindow();
await window.show();
await window.unminimize();
await window.setFocus();
}
/**
@@ -97,7 +101,7 @@ export async function hideWindow(): Promise<void> {
return;
}
await invoke('hide_window');
await getCurrentWindow().hide();
}
/**
@@ -108,7 +112,15 @@ export async function toggleWindow(): Promise<void> {
return;
}
await invoke('toggle_window');
const window = getCurrentWindow();
const visible = await window.isVisible();
if (visible) {
await window.hide();
return;
}
await window.show();
await window.unminimize();
await window.setFocus();
}
/**
@@ -119,7 +131,7 @@ export async function isWindowVisible(): Promise<boolean> {
return true; // In browser, window is always visible
}
return await invoke('is_window_visible');
return await getCurrentWindow().isVisible();
}
/**
@@ -130,7 +142,7 @@ export async function minimizeWindow(): Promise<void> {
return;
}
await invoke('minimize_window');
await getCurrentWindow().minimize();
}
/**
@@ -141,7 +153,8 @@ export async function maximizeWindow(): Promise<void> {
return;
}
await invoke('maximize_window');
const window = getCurrentWindow();
await window.toggleMaximize();
}
/**
@@ -152,7 +165,7 @@ export async function closeWindow(): Promise<void> {
return;
}
await invoke('close_window');
await getCurrentWindow().close();
}
/**
@@ -164,7 +177,7 @@ export async function setWindowTitle(title: string): Promise<void> {
return;
}
await invoke('set_window_title', { title });
await getCurrentWindow().setTitle(title);
}
// --- Memory Commands ---
@@ -348,10 +361,6 @@ export interface CommandResponse<T> {
logs: string[];
}
function wrapCommandResult<T>(result: T): CommandResponse<T> {
return { result, logs: [] };
}
export interface SkillSnapshot {
skill_id: string;
name: string;
@@ -1254,17 +1263,28 @@ export async function openhumanLocalAiDownloadAsset(
}
export async function aiGetConfig(): Promise<AIPreview> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('ai_get_config');
return {
soul: {
raw: '',
name: 'OpenHuman',
description: 'AI assistant',
personalityPreview: [],
safetyRulesPreview: [],
loadedAt: Date.now(),
},
tools: { raw: '', totalTools: 0, activeSkills: 0, skillsPreview: [], loadedAt: Date.now() },
metadata: {
loadedAt: Date.now(),
loadingDuration: 0,
hasFallbacks: true,
sources: { soul: 'frontend', tools: 'frontend' },
errors: ['AI prompt preview has been moved out of the Tauri host.'],
},
};
}
export async function aiRefreshConfig(): Promise<AIPreview> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('ai_refresh_config');
return aiGetConfig();
}
export async function openhumanEncryptSecret(plaintext: string): Promise<CommandResponse<string>> {
@@ -1344,44 +1364,36 @@ export async function openhumanServiceInstall(): Promise<CommandResponse<Service
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke<CommandResponse<ServiceStatus>>('core_rpc_relay', {
request: { method: 'openhuman.service_install', params: {} },
});
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_install' });
}
export async function openhumanServiceStart(): Promise<CommandResponse<ServiceStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke<CommandResponse<ServiceStatus>>('core_rpc_relay', {
request: { method: 'openhuman.service_start', params: {} },
});
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_start' });
}
export async function openhumanServiceStop(): Promise<CommandResponse<ServiceStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke<CommandResponse<ServiceStatus>>('core_rpc_relay', {
request: { method: 'openhuman.service_stop', params: {} },
});
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_stop' });
}
export async function openhumanServiceStatus(): Promise<CommandResponse<ServiceStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke<CommandResponse<ServiceStatus>>('core_rpc_relay', {
request: { method: 'openhuman.service_status', params: {} },
});
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_status' });
}
export async function openhumanServiceUninstall(): Promise<CommandResponse<ServiceStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke<CommandResponse<ServiceStatus>>('core_rpc_relay', {
request: { method: 'openhuman.service_uninstall', params: {} },
return await callCoreRpc<CommandResponse<ServiceStatus>>({
method: 'openhuman.service_uninstall',
});
}
@@ -1389,8 +1401,8 @@ export async function openhumanAgentServerStatus(): Promise<CommandResponse<Agen
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke<CommandResponse<AgentServerStatus>>('core_rpc_relay', {
request: { method: 'openhuman.agent_server_status', params: {} },
return await callCoreRpc<CommandResponse<AgentServerStatus>>({
method: 'openhuman.agent_server_status',
});
}
@@ -1398,8 +1410,9 @@ export async function openhumanGetDaemonHostConfig(): Promise<CommandResponse<Da
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
const result = await invoke<DaemonHostConfig>('openhuman_get_daemon_host_config');
return wrapCommandResult(result);
return await callCoreRpc<CommandResponse<DaemonHostConfig>>({
method: 'openhuman.service_daemon_host_get',
});
}
export async function openhumanSetDaemonHostConfig(
@@ -1408,8 +1421,10 @@ export async function openhumanSetDaemonHostConfig(
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
const result = await invoke<DaemonHostConfig>('openhuman_set_daemon_host_config', { showTray });
return wrapCommandResult(result);
return await callCoreRpc<CommandResponse<DaemonHostConfig>>({
method: 'openhuman.service_daemon_host_set',
params: { show_tray: showTray },
});
}
export async function openhumanAccessibilityStatus(): Promise<
+24 -1
View File
@@ -1,7 +1,8 @@
//! JSON-RPC / CLI controller surface for platform service install/lifecycle.
use crate::openhuman::config::Config;
use crate::openhuman::service::{self, ServiceStatus};
use crate::openhuman::service::daemon_host::DaemonHostConfig;
use crate::openhuman::service::{self, daemon_host, ServiceStatus};
use crate::rpc::RpcOutcome;
pub async fn service_install(config: &Config) -> Result<RpcOutcome<ServiceStatus>, String> {
@@ -31,3 +32,25 @@ pub async fn service_uninstall(config: &Config) -> Result<RpcOutcome<ServiceStat
"service uninstall completed",
))
}
pub async fn daemon_host_get(config: &Config) -> Result<RpcOutcome<DaemonHostConfig>, String> {
let config_dir = config
.config_path
.parent()
.ok_or_else(|| "failed to resolve config directory".to_string())?;
let current = daemon_host::load_for_config_dir(config_dir).await;
Ok(RpcOutcome::single_log(current, "daemon host config loaded"))
}
pub async fn daemon_host_set(
config: &Config,
show_tray: bool,
) -> Result<RpcOutcome<DaemonHostConfig>, String> {
let config_dir = config
.config_path
.parent()
.ok_or_else(|| "failed to resolve config directory".to_string())?;
let next = DaemonHostConfig { show_tray };
daemon_host::save_for_config_dir(config_dir, &next).await?;
Ok(RpcOutcome::single_log(next, "daemon host config saved"))
}
+61
View File
@@ -1,3 +1,4 @@
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
@@ -12,6 +13,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("stop"),
schemas("status"),
schemas("uninstall"),
schemas("daemon_host_get"),
schemas("daemon_host_set"),
]
}
@@ -37,6 +40,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("uninstall"),
handler: handle_uninstall,
},
RegisteredController {
schema: schemas("daemon_host_get"),
handler: handle_daemon_host_get,
},
RegisteredController {
schema: schemas("daemon_host_set"),
handler: handle_daemon_host_set,
},
]
}
@@ -60,6 +71,35 @@ pub fn schemas(function: &str) -> ControllerSchema {
required: true,
}],
},
"daemon_host_get" => ControllerSchema {
namespace: "service",
function: "daemon_host_get",
description: "Read daemon host UI preferences.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "status",
ty: TypeSchema::Ref("DaemonHostConfig"),
comment: "Daemon host preference payload.",
required: true,
}],
},
"daemon_host_set" => ControllerSchema {
namespace: "service",
function: "daemon_host_set",
description: "Update daemon host UI preferences.",
inputs: vec![FieldSchema {
name: "show_tray",
ty: TypeSchema::Bool,
comment: "Show tray icon in daemon-host mode.",
required: true,
}],
outputs: vec![FieldSchema {
name: "status",
ty: TypeSchema::Ref("DaemonHostConfig"),
comment: "Updated daemon host preference payload.",
required: true,
}],
},
_ => ControllerSchema {
namespace: "service",
function: "unknown",
@@ -110,6 +150,27 @@ fn handle_uninstall(_params: Map<String, Value>) -> ControllerFuture {
})
}
#[derive(Debug, Deserialize)]
struct DaemonHostSetParams {
show_tray: bool,
}
fn handle_daemon_host_get(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::service::rpc::daemon_host_get(&config).await?)
})
}
fn handle_daemon_host_set(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload: DaemonHostSetParams =
serde_json::from_value(Value::Object(params)).map_err(|e| e.to_string())?;
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::service::rpc::daemon_host_set(&config, payload.show_tray).await?)
})
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}