Files
openhuman/app/src-tauri/src/core_process.rs
T
Steven EnamakelandGitHub 6410db1fad feat(thu-fullrun): overlay attention, skills sync, credits & settings refresh (#479)
* Update Conversations component to enhance user messaging for budget limits. Changed the warning text for exhausted weekly inference budget to improve clarity and user experience.

* feat(schemas): add new configuration option for vision model usage

- Introduced a new optional boolean field `use_vision_model` in the schemas for enabling vision LLM for screenshot analysis.
- Updated the screen intelligence schemas to include a required `consent` field for starting sessions, replacing the previous `sample_interval_ms` field.
- Enhanced the `ttl_secs` field description for clarity and modified the `capture_policy` to `screen_monitoring` for better understanding of its purpose.

* feat(CoreStateProvider): enhance state management with optimistic updates and error handling

- Implemented optimistic local commits for `setAnalyticsEnabled` and `setOnboardingCompletedFlag` to provide instant UI feedback while ensuring state consistency through authoritative snapshot refreshes.
- Added error handling for the `refresh` function calls in `setAnalyticsEnabled`, `setOnboardingCompletedFlag`, and `clearSession` to log failures, improving robustness in state management during user interactions.
- Updated dependencies in the `useCallback` hooks to include `refresh`, ensuring proper state updates and synchronization with the core.

* feat(paths): centralize runtime path resolution for user-scoped skills data

- Introduced a new module `paths.rs` to handle the resolution of runtime paths for skills, ensuring that `skills_data` and `workspace` directories are scoped per user.
- Updated `bootstrap_skill_runtime` and `bootstrap_skills_runtime` functions to utilize the new path resolution logic, improving consistency and clarity in directory management.
- Enhanced error logging for directory creation failures to include the specific path that failed, aiding in debugging.
- Added a new optional field `overlay_ttl_ms` in the autocomplete schemas to support overlay time-to-live configuration.

* feat(ScreenIntelligencePanel): optimize config synchronization to prevent user edit clobbering

- Introduced a reference to track the last synced configuration signature, ensuring that user edits are preserved during periodic updates from the CoreStateProvider.
- Updated the effect to compare serialized configuration values, allowing for re-sync only when actual changes occur, enhancing user experience and preventing unintended data loss.

* feat(SkillManager): implement initial sync after OAuth completion

- Added functionality to trigger an initial data sync immediately after OAuth completion, ensuring users see fresh data without waiting for the next scheduled sync.
- Updated comments to clarify the change in sync behavior due to recent modifications in the Rust core, which no longer auto-triggers sync on OAuth completion.

* fix(UsageLimitModal, Conversations): enhance user messaging for budget limits

- Updated warning messages in both UsageLimitModal and Conversations components to provide clearer information regarding weekly limits and reset times.
- Improved clarity in user notifications to enhance overall experience when budget limits are reached.

* refactor(SkillSetupModal): improve session mode handling for skill configuration

- Updated the SkillSetupModal component to lock the mode at mount time, ensuring users remain in the setup wizard during their session even if the skill is marked as complete.
- Simplified mode management by replacing the forceSetup state with a sessionMode state, allowing explicit mode switching while maintaining a consistent user experience.

* feat(SkillManager): enhance setup flow for OAuth-based skills

- Updated the `startSetup` method to handle OAuth-based skills more effectively by implementing a fallback to core RPC for skills without a frontend runtime.
- Improved error handling to treat missing `onSetupStart` implementations as successful completion for pure OAuth skills, allowing the setup wizard to display the "Connected!" screen.
- Added detailed logging for both local runtime and core RPC fallback scenarios to improve traceability during the setup process.

* feat(Home): enhance local AI status handling and asset management

- Introduced a new state for local AI assets, allowing for better tracking of model file readiness.
- Updated the loading logic to fetch both local AI status and assets concurrently, improving performance and error handling.
- Implemented a mechanism to hide the Local Model Runtime card once all models are fully downloaded, enhancing user experience.
- Added comprehensive comments to clarify the logic behind model readiness checks based on asset states.

* refactor(Credits): update credit balance structure and terminology

- Renamed credit categories in the RewardsCouponSection and PayAsYouGoCard components for clarity, changing "General credits" to "Promo credits" and "Top-up credits" to "Team top-up."
- Updated the credit balance API to reflect the new structure, replacing `balanceUsd` and `topUpBalanceUsd` with `promotionBalanceUsd` and `teamTopupUsd`.
- Adjusted normalization logic in the credits API to accommodate the new credit balance fields.
- Modified tests to ensure correct handling of the updated credit balance structure.

* feat(Settings): reorganize billing settings and update descriptions

- Added a new top-level billing section to the settings, promoting it out of the Account & Security category for better visibility.
- Updated the description for the Account & Security section to remove billing references, focusing on recovery phrase, team management, and linked account access.
- Adjusted the settings navigation to accommodate the new billing section, ensuring proper routing and user experience.

* refactor(Config): change logging level from info to debug for environment overrides

- Updated logging statements in the Config implementation to use debug level instead of info, reducing verbosity during runtime while maintaining necessary traceability for configuration loading.

* feat(Overlay): implement overlay attention event handling and refactor overlay app structure

- Introduced a new overlay module to manage attention events, allowing the core to publish messages to the overlay window.
- Enhanced the OverlayApp component to handle dictation and attention events, improving user interaction with the overlay.
- Refactored the overlay state management to support different modes (idle, stt, attention) and added auto-dismiss functionality for attention messages.
- Removed the Browser Access Toggle from the Skills page, streamlining the UI and focusing on core functionalities.
- Updated tests to reflect changes in the Skills component and removed unnecessary mocks related to browser access.

* fix(OverlayBubbleChip): reset typewriter animation on new bubble identity

- Updated the OverlayBubbleChip component to reset the typewriter animation correctly when a new bubble is displayed by using the `key` prop.
- Refactored the cleanup logic in the useEffect hook to ensure proper interval management and state reset, enhancing the user experience with bubble transitions.

* refactor(rest): streamline key_bytes_from_string function and improve readability

- Simplified the condition for checking the ASCII key length and character restrictions in the key_bytes_from_string function.
- Consolidated the import statements for base64 engines into a single line for better clarity.
- Adjusted test data formatting for improved readability in the key_bytes_from_string_tests module.

* enhance(logging): improve color detection logic for terminal output

- Updated the color detection logic in the logging module to prioritize environment variables (`NO_COLOR`, `FORCE_COLOR`, `CLICOLOR_FORCE`) for better control over color output.
- Added detailed comments explaining the color resolution order, enhancing code clarity and maintainability.

* test(Home): add mock for openhumanLocalAiAssetsStatus in tests

- Enhanced the Home and HomeBootstrapButtons test files by adding a mock implementation for openhumanLocalAiAssetsStatus, which resolves to an object with null result and empty logs. This improves the test setup for local AI asset status handling.

* refactor(SkillSetupModal): improve session mode handling and loading state

- Updated the SkillSetupModal component to ensure session mode is determined after the first snapshot resolution, preventing premature defaults to the setup wizard.
- Introduced a loading state to display a message while waiting for the skill setup status, enhancing user experience during the modal's initial render.
- Refactored the SkillManager to throw errors for real failures during setup, ensuring proper error handling and user feedback.

* refactor(Config): simplify logging for invalid proxy scope values

- Consolidated the logging statement for invalid OPENHUMAN_PROXY_SCOPE values into a single line, improving code readability while maintaining the warning functionality.
2026-04-09 23:01:56 -07:00

567 lines
21 KiB
Rust

use std::io::IsTerminal;
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};
/// Propagate ANSI color hints to the spawned core child.
///
/// Core's tracing formatter auto-detects color via `stderr.is_terminal()`,
/// but when core runs as a grandchild under `yarn tauri dev` the inherited
/// stderr may not register as a TTY even though the ultimate terminal
/// supports ANSI. If the Tauri process itself is attached to a TTY we
/// forward `FORCE_COLOR=1` so core emits colored log lines; `NO_COLOR`
/// (user opt-out) always wins and short-circuits the propagation.
fn apply_core_color_env(cmd: &mut Command) {
if std::env::var_os("NO_COLOR").is_some() {
return;
}
if std::io::stderr().is_terminal() {
cmd.env("FORCE_COLOR", "1");
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CoreRunMode {
InProcess,
ChildProcess,
}
#[derive(Clone)]
pub struct CoreProcessHandle {
child: Arc<Mutex<Option<Child>>>,
task: Arc<Mutex<Option<JoinHandle<()>>>>,
restart_lock: Arc<Mutex<()>>,
port: u16,
core_bin: Option<PathBuf>,
/// Override path set by the auto-updater after staging a new binary.
core_bin_override: Arc<Mutex<Option<PathBuf>>>,
run_mode: CoreRunMode,
}
impl CoreProcessHandle {
pub fn new(port: u16, core_bin: Option<PathBuf>, run_mode: CoreRunMode) -> Self {
Self {
child: Arc::new(Mutex::new(None)),
task: Arc::new(Mutex::new(None)),
restart_lock: Arc::new(Mutex::new(())),
port,
core_bin,
core_bin_override: Arc::new(Mutex::new(None)),
run_mode,
}
}
pub fn rpc_url(&self) -> String {
format!("http://127.0.0.1:{}/rpc", self.port)
}
pub fn port(&self) -> u16 {
self.port
}
/// Replace the core binary path so that the next `ensure_running()` launches
/// the new binary instead of the original one captured at construction time.
pub async fn set_core_bin(&self, new_bin: PathBuf) {
// We store it via a second field; but since core_bin is not behind a lock,
// we work around this by swapping the entire handle's notion of what to launch.
// For now, mutate through an interior-mutable wrapper.
log::info!(
"[core] set_core_bin: updating core binary path to {}",
new_bin.display()
);
*self.core_bin_override.lock().await = Some(new_bin);
}
/// Resolve which binary to launch: override (set by `set_core_bin`) > original.
async fn effective_core_bin(&self) -> Option<PathBuf> {
let override_guard = self.core_bin_override.lock().await;
if let Some(ref path) = *override_guard {
return Some(path.clone());
}
self.core_bin.clone()
}
/// Acquire the restart lock to serialize overlapping restart requests.
pub async fn restart_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
self.restart_lock.lock().await
}
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 self.is_rpc_port_open().await {
log::info!(
"[core] found existing core rpc endpoint at {}",
self.rpc_url()
);
return Ok(());
}
let effective_bin = self.effective_core_bin().await;
match self.run_mode {
CoreRunMode::InProcess => {
log::warn!(
"[core] in-process core mode is unavailable in host-only build; falling back to child process"
);
let mut guard = self.child.lock().await;
if guard.is_none() {
let mut cmd = if let Some(core_bin) = &effective_bin {
let mut cmd = Command::new(core_bin);
if is_current_exe_path(core_bin) {
cmd.arg("core");
}
cmd.arg("run").arg("--port").arg(self.port.to_string());
cmd
} else {
let exe = std::env::current_exe()
.map_err(|e| format!("failed to resolve current executable: {e}"))?;
let mut cmd = Command::new(exe);
cmd.arg("core")
.arg("run")
.arg("--port")
.arg(self.port.to_string());
cmd
};
apply_core_color_env(&mut cmd);
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;
*guard = Some(child);
}
}
CoreRunMode::ChildProcess => {
let mut guard = self.child.lock().await;
if guard.is_none() {
let mut cmd = if let Some(core_bin) = &effective_bin {
let mut cmd = Command::new(core_bin);
if is_current_exe_path(core_bin) {
// Safety: if core_bin resolves to this GUI executable, force the
// explicit subcommand path so we don't accidentally relaunch clients.
cmd.arg("core");
}
cmd.arg("run").arg("--port").arg(self.port.to_string());
log::info!(
"[core] spawning dedicated core binary: {:?} run --port {}",
cmd.as_std().get_program(),
self.port
);
cmd
} else {
let exe = std::env::current_exe()
.map_err(|e| format!("failed to resolve current executable: {e}"))?;
let mut cmd = Command::new(exe);
cmd.arg("core")
.arg("run")
.arg("--port")
.arg(self.port.to_string());
log::warn!(
"[core] dedicated core binary not found; falling back to self subcommand"
);
cmd
};
apply_core_color_env(&mut cmd);
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;
*guard = Some(child);
}
}
}
for _ in 0..40 {
if self.is_rpc_port_open().await {
log::info!("[core] core rpc became ready at {}", self.rpc_url());
return Ok(());
}
match self.run_mode {
CoreRunMode::InProcess => {
let mut guard = self.task.lock().await;
if let Some(task) = guard.as_ref() {
if task.is_finished() {
let task = guard.take().expect("checked is_some");
drop(guard);
match task.await {
Ok(_) => {
return Err(
"in-process core server exited before becoming ready"
.to_string(),
)
}
Err(err) => {
return Err(format!(
"in-process core server task failed before ready: {err}"
))
}
}
}
}
}
CoreRunMode::ChildProcess => {
let mut guard = self.child.lock().await;
if let Some(child) = guard.as_mut() {
match child.try_wait() {
Ok(Some(status)) => {
return Err(format!("core process exited before ready: {status}"));
}
Ok(None) => {}
Err(e) => {
return Err(format!("failed checking core process status: {e}"));
}
}
}
}
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
Err("core process did not become ready".to_string())
}
/// Restart the core process to pick up updated macOS permission grants.
///
/// macOS caches permission state per-process; the running sidecar never sees
/// a newly granted permission until it restarts. This method shuts down the
/// current child, waits until the RPC port is free (so `ensure_running` does not
/// fast-return while the old listener is still bound), then spawns a fresh instance.
///
/// If another process is listening on the core port (e.g. manual `openhuman core run`),
/// shutdown does not stop it — we time out and return an error instead of a false success.
///
/// Issue: <https://github.com/tinyhumansai/openhuman/issues/133>
pub async fn restart(&self) -> Result<(), String> {
log::info!("[core] restarting core process for permission refresh");
let had_managed_child = {
let guard = self.child.lock().await;
guard.is_some()
};
log::debug!(
"[core] restart: had_managed_child={} before shutdown",
had_managed_child
);
self.shutdown().await;
log::debug!(
"[core] restart: shutdown complete, checking port {}",
self.port
);
// If we never spawned the sidecar (something else was already listening), we cannot free
// the port — fail fast with a clear message instead of polling for 8s.
if !had_managed_child && self.is_rpc_port_open().await {
log::error!(
"[core] restart: no child to stop but port {} is open — another process owns it",
self.port
);
return Err(format!(
"Core RPC port {} is already in use by another process (OpenHuman did not start it). Quit any `openhuman core run` in a terminal or free the port, then relaunch the app. You can also set OPENHUMAN_CORE_PORT to a different port.",
self.port
));
}
// After kill+wait on our child, the port should close; poll briefly in case the OS is slow
// to release the socket.
const POLL_MS: u64 = 50;
const MAX_WAIT_MS: u64 = 10_000;
let mut waited_ms: u64 = 0;
while self.is_rpc_port_open().await {
if waited_ms >= MAX_WAIT_MS {
log::error!(
"[core] restart: port {} still in use after {}ms (had_managed_child={})",
self.port,
MAX_WAIT_MS,
had_managed_child
);
return Err(format!(
"Core RPC port {} did not become free after stopping the sidecar. Quit any other process using this port (e.g. `openhuman core run`) or change OPENHUMAN_CORE_PORT.",
self.port
));
}
tokio::time::sleep(std::time::Duration::from_millis(POLL_MS)).await;
waited_ms += POLL_MS;
}
log::debug!("[core] restart: port free, calling ensure_running");
let result = self.ensure_running().await;
match &result {
Ok(()) => log::info!("[core] restart: core process ready after restart"),
Err(e) => log::error!("[core] restart: failed to restart core process: {e}"),
}
result
}
/// Stop the core process this handle spawned (child or in-process task). Safe to call if
/// nothing was spawned or core was already external.
pub async fn shutdown(&self) {
let mut child_guard = self.child.lock().await;
if let Some(mut child) = child_guard.take() {
log::info!("[core] terminating child core process on app shutdown");
if let Err(e) = child.kill().await {
log::warn!("[core] failed to kill child core process: {e}");
}
// Wait for the process to exit so the RPC listen socket is released before restart
// checks the port (otherwise we can spuriously hit "port still in use").
match timeout(Duration::from_secs(12), child.wait()).await {
Ok(Ok(status)) => {
log::debug!("[core] child core process reaped after kill: {status}");
}
Ok(Err(e)) => {
log::warn!("[core] wait on child core process after kill: {e}");
}
Err(_) => {
log::warn!(
"[core] timed out waiting for child core process to exit after kill (12s)"
);
}
}
}
let mut task_guard = self.task.lock().await;
if let Some(task) = task_guard.take() {
task.abort();
}
}
}
fn is_current_exe_path(candidate: &std::path::Path) -> bool {
let Ok(current) = std::env::current_exe() else {
return false;
};
same_executable_path(candidate, &current)
}
fn same_executable_path(a: &std::path::Path, b: &std::path::Path) -> bool {
if a == b {
return true;
}
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(a_real), Ok(b_real)) => a_real == b_real,
_ => false,
}
}
pub fn default_core_port() -> u16 {
std::env::var("OPENHUMAN_CORE_PORT")
.ok()
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(7788)
}
pub fn default_core_run_mode(_daemon_mode: bool) -> CoreRunMode {
if let Ok(value) = std::env::var("OPENHUMAN_CORE_RUN_MODE") {
let normalized = value.trim().to_ascii_lowercase();
if matches!(normalized.as_str(), "inprocess" | "in-process" | "internal") {
return CoreRunMode::InProcess;
}
if matches!(
normalized.as_str(),
"child" | "process" | "external" | "sidecar"
) {
return CoreRunMode::ChildProcess;
}
}
// Default to a dedicated core process so app and core lifecycles are separated.
CoreRunMode::ChildProcess
}
pub fn default_core_bin() -> Option<PathBuf> {
if let Ok(path) = std::env::var("OPENHUMAN_CORE_BIN") {
let candidate = PathBuf::from(path);
if candidate.exists() {
return Some(candidate);
}
}
// Dev: prefer a staged sidecar under src-tauri/binaries, then use the same search as
// release (next to the .app, Resources/, etc.). Previously we returned None here when the
// folder was empty, which forced `core run` on the GUI binary — a different TCC identity than
// `openhuman-core-*` and misleading "still denied" after granting the sidecar name.
#[cfg(debug_assertions)]
{
let binaries_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("binaries");
if let Ok(entries) = std::fs::read_dir(&binaries_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
#[cfg(windows)]
let matches =
file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe");
#[cfg(not(windows))]
let matches = file_name.starts_with("openhuman-core-");
if matches {
return Some(path);
}
}
}
}
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
#[cfg(windows)]
let standalone = exe_dir.join("openhuman-core.exe");
#[cfg(not(windows))]
let standalone = exe_dir.join("openhuman-core");
if standalone.exists() && !same_executable_path(&standalone, &exe) {
return Some(standalone);
}
#[cfg(windows)]
let legacy_standalone = exe_dir.join("openhuman-core.exe");
#[cfg(not(windows))]
let legacy_standalone = exe_dir.join("openhuman-core");
if legacy_standalone.exists() && !same_executable_path(&legacy_standalone, &exe) {
return Some(legacy_standalone);
}
// Sidecar layout: bundle.externalBin("binaries/openhuman-core") is emitted as
// openhuman-core-<target-triple>(.exe) under app resources.
let search_dirs = {
let mut dirs = vec![exe_dir.to_path_buf()];
#[cfg(target_os = "macos")]
{
if let Some(resources_dir) = exe_dir.parent().map(|p| p.join("Resources")) {
dirs.push(resources_dir);
}
}
dirs
};
for dir in search_dirs {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
#[cfg(windows)]
let matches = (file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe"))
|| (file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe"));
#[cfg(not(windows))]
let matches = file_name.starts_with("openhuman-core-")
|| file_name.starts_with("openhuman-core-");
if matches && !same_executable_path(&path, &exe) {
return Some(path);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::{
default_core_port, default_core_run_mode, same_executable_path, CoreProcessHandle,
CoreRunMode,
};
struct EnvGuard {
key: &'static str,
old: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let old = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, old }
}
fn unset(key: &'static str) -> Self {
let old = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, old }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(old) = &self.old {
std::env::set_var(self.key, old);
} else {
std::env::remove_var(self.key);
}
}
}
#[test]
fn default_core_run_mode_env_parsing() {
let _unset = EnvGuard::unset("OPENHUMAN_CORE_RUN_MODE");
assert_eq!(default_core_run_mode(false), CoreRunMode::ChildProcess);
let _guard = EnvGuard::set("OPENHUMAN_CORE_RUN_MODE", "in-process");
assert_eq!(default_core_run_mode(false), CoreRunMode::InProcess);
let _guard = EnvGuard::set("OPENHUMAN_CORE_RUN_MODE", "sidecar");
assert_eq!(default_core_run_mode(false), CoreRunMode::ChildProcess);
}
#[test]
fn default_core_port_env_and_fallback() {
let _unset = EnvGuard::unset("OPENHUMAN_CORE_PORT");
assert_eq!(default_core_port(), 7788);
let _set = EnvGuard::set("OPENHUMAN_CORE_PORT", "8899");
assert_eq!(default_core_port(), 8899);
}
#[test]
fn same_executable_path_handles_equal_and_non_equal_paths() {
let current = std::env::current_exe().expect("current exe");
assert!(same_executable_path(&current, &current));
let different = current.with_file_name("definitely-not-the-current-exe");
assert!(!same_executable_path(&current, &different));
}
#[test]
fn ensure_running_returns_ok_when_rpc_port_already_open() {
let rt = tokio::runtime::Runtime::new().expect("runtime");
let result = rt.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
let handle = CoreProcessHandle::new(port, None, CoreRunMode::ChildProcess);
handle.ensure_running().await
});
assert!(
result.is_ok(),
"ensure_running should fast-path: {result:?}"
);
}
}