mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -52,3 +52,5 @@ tauri.key
|
||||
tauri.key.pub
|
||||
/target/
|
||||
src-tauri/target/
|
||||
|
||||
openhuman-skills
|
||||
|
||||
Generated
+13
-1415
File diff suppressed because it is too large
Load Diff
@@ -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!!
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(¶ms).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()
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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, ¬ion_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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(¤t_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(×tamp);
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
pub mod dev_paths;
|
||||
+12
-2
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user