feat(cli): integrate clap_complete for shell command completions (#49)

* feat(daemon): introduce daemon host configuration management

- Updated the daemon service to support a new configuration structure for managing tray visibility.
- Added functions to load and save daemon host configuration from a JSON file.
- Implemented Tauri commands to retrieve and update the daemon host configuration.
- Enhanced the service management logic to account for legacy application labels and improve compatibility on macOS.
- Refactored executable resolution logic to streamline the process of locating the daemon executable across platforms.

* feat(daemon): enhance daemon host configuration with tray visibility settings

- Added functionality to load and save daemon host configuration, specifically for managing the visibility of the daemon tray icon.
- Implemented UI components in both DaemonHealthPanel and TauriCommandsPanel to toggle the tray visibility setting.
- Integrated Tauri commands to retrieve and update the daemon host configuration, improving user control over the daemon's display options.
- Enhanced loading states and error handling for better user feedback during configuration updates.

* fix(local-ai): update default model IDs for local AI configuration

- Changed default model IDs from `qwen2.5:1.5b` and `qwen3-vl:2b` to `gemma3:4b-it-qat` for chat and vision models, ensuring consistency in local AI settings.

* feat(core): enhance macOS service management and CLI thread stack size

- Added dynamic configuration for thread stack size in the CLI, allowing customization via the `OPENHUMAN_CORE_THREAD_STACK_SIZE` environment variable.
- Improved macOS service management by validating the LaunchAgent plist and ensuring it is installed before starting the service.
- Enhanced error handling and logging for service loading and plist validation, improving user feedback and reliability.

* feat(app): initialize Tauri + React + TypeScript project structure

- Added essential project files including package.json, tsconfig.json, and Vite configuration for a Tauri application using React and TypeScript.
- Created initial HTML template and CSS styles for the application interface.
- Included .gitignore to exclude build artifacts and environment-specific files.
- Established basic README documentation to guide setup and development.

* feat(cli): refactor command structure for core CLI

- Replaced the existing CLI command structure with a new design using `clap` for better organization and extensibility.
- Introduced a `CoreCli` struct with subcommands for various operations including server management, health checks, and configuration settings.
- Updated command handling to support new subcommands for settings and accessibility operations, enhancing the CLI's functionality.
- Modified the core process handling to reflect the new command structure, ensuring compatibility with the updated CLI design.

* chore(eslint): add 'app/**' to ignored paths in ESLint configuration

- Updated the ESLint configuration to include the 'app/**' directory in the list of ignored paths, ensuring that files in this directory are not linted during the development process.

* chore(prettier): add 'app' directory to .prettierignore

- Updated the .prettierignore file to include the 'app' directory, preventing formatting checks on files within this path during development.

* feat(cli): integrate clap_complete for shell command completions

- Added support for generating shell completion scripts using `clap_complete`, enhancing the CLI's usability.
- Introduced new subcommands for generating completions and updated command structures to accommodate this feature.
- Implemented a new `capture_image_ref` command in the accessibility module for direct image reference capture.
- Enhanced the `Tools` command structure to include screenshot functionalities, improving CLI tool management.
This commit is contained in:
Steven Enamakel
2026-03-28 11:52:50 -07:00
committed by GitHub
parent b5037b0c91
commit b6ec716b7c
54 changed files with 1833 additions and 411 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules
dist
coverage
app
src-tauri
rust-core
skills
Generated
+10
View File
@@ -1169,6 +1169,15 @@ dependencies = [
"strsim",
]
[[package]]
name = "clap_complete"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
@@ -5441,6 +5450,7 @@ dependencies = [
"chrono",
"chrono-tz",
"clap",
"clap_complete",
"console",
"cron",
"dialoguer",
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+7
View File
@@ -0,0 +1,7 @@
# Tauri + React + Typescript
This template should help get you started developing with Tauri, React and Typescript in Vite.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "openhuman",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"@tauri-apps/cli": "^2"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "openhuman"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "openhuman_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+14
View File
@@ -0,0 +1,14 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
openhuman_lib::run()
}
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "openhuman",
"version": "0.1.0",
"identifier": "com.tinyhumansai.openhuman",
"build": {
"beforeDevCommand": "yarn dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "yarn build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "openhuman",
"width": 800,
"height": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+116
View File
@@ -0,0 +1,116 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}
+51
View File
@@ -0,0 +1,51 @@
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke } from "@tauri-apps/api/core";
import "./App.css";
function App() {
const [greetMsg, setGreetMsg] = useState("");
const [name, setName] = useState("");
async function greet() {
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
setGreetMsg(await invoke("greet", { name }));
}
return (
<main className="container">
<h1>Welcome to Tauri + React</h1>
<div className="row">
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
greet();
}}
>
<input
id="greet-input"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="Enter a name..."
/>
<button type="submit">Greet</button>
</form>
<p>{greetMsg}</p>
</main>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+9
View File
@@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));
+1
View File
@@ -26,6 +26,7 @@ export default [
'**/target/**',
'dist/**',
'coverage/**',
'app/**',
'src-tauri/**',
'rust-core/**',
'skills/**',
+1
View File
@@ -62,6 +62,7 @@ rustls-pki-types = "1.14.0"
tokio-rustls = "0.26.4"
webpki-roots = "1.0.6"
clap = { version = "4.5", features = ["derive"] }
clap_complete = "4.5"
lettre = { version = "0.11.19", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"] }
mail-parser = "0.11.2"
async-imap = { version = "0.11", features = ["runtime-tokio"], default-features = false }
+5 -332
View File
@@ -1,334 +1,7 @@
use clap::{Args, Parser, Subcommand};
use serde_json::json;
#[derive(Debug, Parser)]
#[command(name = "openhuman")]
#[command(about = "OpenHuman core CLI")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Run JSON-RPC server
Serve {
#[arg(long)]
port: Option<u16>,
},
/// Check core health
Ping,
/// Print core version
Version,
/// Get health snapshot
Health,
/// Get runtime flags
RuntimeFlags,
/// Get security policy info
SecurityPolicy,
/// Generic JSON-RPC style method call
Call {
#[arg(long)]
method: String,
#[arg(long, default_value = "{}")]
params: String,
},
/// Config operations
Config {
#[command(subcommand)]
command: ConfigCommand,
},
/// Service operations
Service {
#[command(subcommand)]
command: ServiceCommand,
},
/// Doctor operations
Doctor {
#[command(subcommand)]
command: DoctorCommand,
},
/// Integrations operations
Integrations {
#[command(subcommand)]
command: IntegrationsCommand,
},
/// Send one-shot agent message
AgentChat(AgentChatArgs),
/// Hardware operations
Hardware {
#[command(subcommand)]
command: HardwareCommand,
},
/// Encrypt a secret
Encrypt { plaintext: String },
/// Decrypt a secret
Decrypt { ciphertext: String },
/// Toggle browser allow-all runtime flag
BrowserAllowAll {
#[arg(long)]
enabled: bool,
},
/// Refresh model catalog
ModelsRefresh {
#[arg(long)]
provider: Option<String>,
#[arg(long, default_value_t = false)]
force: bool,
},
/// Migrate OpenClaw memory
MigrateOpenclaw {
#[arg(long)]
source_workspace: Option<String>,
#[arg(long, default_value_t = true)]
dry_run: bool,
},
}
#[derive(Debug, Subcommand)]
enum ConfigCommand {
/// Get full config snapshot
Get,
/// Update model settings with a JSON object
UpdateModel {
#[arg(long)]
json: String,
},
/// Update memory settings with a JSON object
UpdateMemory {
#[arg(long)]
json: String,
},
/// Update gateway settings with a JSON object
UpdateGateway {
#[arg(long)]
json: String,
},
/// Update runtime settings with a JSON object
UpdateRuntime {
#[arg(long)]
json: String,
},
/// Update browser settings with a JSON object
UpdateBrowser {
#[arg(long)]
json: String,
},
/// Replace tunnel settings with a JSON object
UpdateTunnel {
#[arg(long)]
json: String,
},
}
#[derive(Debug, Subcommand)]
enum ServiceCommand {
Install,
Start,
Stop,
Status,
Reinstall,
Uninstall,
}
#[derive(Debug, Subcommand)]
enum DoctorCommand {
/// Run doctor checks
Report,
/// Probe model catalog
Models {
#[arg(long)]
provider: Option<String>,
#[arg(long, default_value_t = true)]
use_cache: bool,
},
}
#[derive(Debug, Subcommand)]
enum IntegrationsCommand {
/// List integrations
List,
/// Get one integration info
Info {
#[arg(long)]
name: String,
},
}
#[derive(Debug, Subcommand)]
enum HardwareCommand {
/// Discover connected hardware
Discover,
/// Introspect one device path
Introspect {
#[arg(long)]
path: String,
},
}
#[derive(Debug, Args)]
struct AgentChatArgs {
message: String,
#[arg(long)]
provider: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
temperature: Option<f64>,
}
fn parse_json_arg(raw: &str) -> Result<serde_json::Value, String> {
serde_json::from_str(raw).map_err(|e| format!("invalid JSON for --json/--params: {e}"))
}
async fn call_local(method: &str, params: serde_json::Value) -> Result<serde_json::Value, String> {
openhuman_core::core_server::call_method(method, params).await
}
async fn execute(cli: Cli) -> Result<serde_json::Value, String> {
match cli.command {
Command::Serve { port } => openhuman_core::core_server::run_server(port)
.await
.map(|_| serde_json::Value::Null)
.map_err(|e| format!("serve failed: {e}")),
Command::Ping => call_local("core.ping", json!({})).await,
Command::Version => call_local("core.version", json!({})).await,
Command::Health => call_local("openhuman.health_snapshot", json!({})).await,
Command::RuntimeFlags => call_local("openhuman.get_runtime_flags", json!({})).await,
Command::SecurityPolicy => call_local("openhuman.security_policy_info", json!({})).await,
Command::Call { method, params } => call_local(&method, parse_json_arg(&params)?).await,
Command::Config { command } => match command {
ConfigCommand::Get => call_local("openhuman.get_config", json!({})).await,
ConfigCommand::UpdateModel { json } => {
call_local("openhuman.update_model_settings", parse_json_arg(&json)?).await
}
ConfigCommand::UpdateMemory { json } => {
call_local("openhuman.update_memory_settings", parse_json_arg(&json)?).await
}
ConfigCommand::UpdateGateway { json } => {
call_local("openhuman.update_gateway_settings", parse_json_arg(&json)?).await
}
ConfigCommand::UpdateRuntime { json } => {
call_local("openhuman.update_runtime_settings", parse_json_arg(&json)?).await
}
ConfigCommand::UpdateBrowser { json } => {
call_local("openhuman.update_browser_settings", parse_json_arg(&json)?).await
}
ConfigCommand::UpdateTunnel { json } => {
call_local("openhuman.update_tunnel_settings", parse_json_arg(&json)?).await
}
},
Command::Service { command } => match command {
ServiceCommand::Install => call_local("openhuman.service_install", json!({})).await,
ServiceCommand::Start => call_local("openhuman.service_start", json!({})).await,
ServiceCommand::Stop => call_local("openhuman.service_stop", json!({})).await,
ServiceCommand::Status => call_local("openhuman.service_status", json!({})).await,
ServiceCommand::Reinstall => {
call_local("openhuman.service_uninstall", json!({})).await?;
call_local("openhuman.service_install", json!({})).await
}
ServiceCommand::Uninstall => call_local("openhuman.service_uninstall", json!({})).await,
},
Command::Doctor { command } => match command {
DoctorCommand::Report => call_local("openhuman.doctor_report", json!({})).await,
DoctorCommand::Models {
provider,
use_cache,
} => {
call_local(
"openhuman.doctor_models",
json!({
"provider_override": provider,
"use_cache": use_cache,
}),
)
.await
}
},
Command::Integrations { command } => match command {
IntegrationsCommand::List => call_local("openhuman.list_integrations", json!({})).await,
IntegrationsCommand::Info { name } => {
call_local("openhuman.get_integration_info", json!({ "name": name })).await
}
},
Command::AgentChat(args) => {
call_local(
"openhuman.agent_chat",
json!({
"message": args.message,
"provider_override": args.provider,
"model_override": args.model,
"temperature": args.temperature,
}),
)
.await
}
Command::Hardware { command } => match command {
HardwareCommand::Discover => call_local("openhuman.hardware_discover", json!({})).await,
HardwareCommand::Introspect { path } => {
call_local("openhuman.hardware_introspect", json!({ "path": path })).await
}
},
Command::Encrypt { plaintext } => {
call_local(
"openhuman.encrypt_secret",
json!({ "plaintext": plaintext }),
)
.await
}
Command::Decrypt { ciphertext } => {
call_local(
"openhuman.decrypt_secret",
json!({ "ciphertext": ciphertext }),
)
.await
}
Command::BrowserAllowAll { enabled } => {
call_local(
"openhuman.set_browser_allow_all",
json!({ "enabled": enabled }),
)
.await
}
Command::ModelsRefresh { provider, force } => {
call_local(
"openhuman.models_refresh",
json!({
"provider_override": provider,
"force": force,
}),
)
.await
}
Command::MigrateOpenclaw {
source_workspace,
dry_run,
} => {
call_local(
"openhuman.migrate_openclaw",
json!({
"source_workspace": source_workspace,
"dry_run": dry_run,
}),
)
.await
}
}
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
match execute(cli).await {
Ok(value) => {
println!(
"{}",
serde_json::to_string_pretty(&value).unwrap_or_else(|_| "null".to_string())
);
}
Err(err) => {
eprintln!("{err}");
std::process::exit(1);
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if let Err(err) = openhuman_core::run_core_from_args(&args) {
eprintln!("{err}");
std::process::exit(1);
}
}
+912 -19
View File
@@ -4,9 +4,15 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use clap::{Args, CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::openhuman::config::Config;
use crate::openhuman::cron;
@@ -16,6 +22,7 @@ use crate::openhuman::local_ai::{
Suggestion,
};
use crate::openhuman::security::{SecretStore, SecurityPolicy};
use crate::openhuman::tools::{ScreenshotTool, Tool};
use crate::openhuman::{
accessibility, doctor, hardware, integrations, migration, onboard, service,
};
@@ -23,9 +30,10 @@ use chrono::Utc;
pub use crate::openhuman::accessibility::{
AccessibilityStatus, AutocompleteCommitParams, AutocompleteCommitResult,
AutocompleteSuggestParams, AutocompleteSuggestResult, CaptureNowResult, InputActionParams,
InputActionResult, PermissionRequestParams, PermissionStatus, SessionStatus,
StartSessionParams, StopSessionParams, VisionFlushResult, VisionRecentResult,
AutocompleteSuggestParams, AutocompleteSuggestResult, CaptureImageRefResult, CaptureNowResult,
InputActionParams, InputActionResult, PermissionRequestParams, PermissionState,
PermissionStatus, SessionStatus, StartSessionParams, StopSessionParams, VisionFlushResult,
VisionRecentResult,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1067,6 +1075,15 @@ async fn dispatch(
))
}
"openhuman.accessibility_capture_image_ref" => {
let result: CaptureImageRefResult =
accessibility::global_engine().capture_image_ref_test().await;
to_json_value(command_response(
result,
vec!["accessibility direct image_ref capture requested".to_string()],
))
}
"openhuman.accessibility_input_action" => {
let payload: InputActionParams = parse_params(params)?;
let result = accessibility::global_engine().input_action(payload).await?;
@@ -1211,29 +1228,905 @@ pub async fn run_server(port: Option<u16>) -> Result<()> {
Ok(())
}
pub fn run_from_cli_args(args: &[String]) -> Result<()> {
let mut port: Option<u16> = None;
#[derive(Debug, Parser)]
#[command(name = "openhuman-core")]
#[command(about = "OpenHuman core CLI")]
#[command(arg_required_else_help = true)]
struct CoreCli {
#[command(subcommand)]
command: CoreCommand,
}
let mut idx = 0;
while idx < args.len() {
match args[idx].as_str() {
"serve" => {}
"--port" => {
let value = args
.get(idx + 1)
.ok_or_else(|| anyhow::anyhow!("missing value for --port"))?;
port = Some(value.parse::<u16>()?);
idx += 1;
}
_ => {}
#[derive(Debug, Subcommand)]
enum CoreCommand {
/// Run JSON-RPC server
#[command(alias = "serve")]
Run {
#[arg(long)]
port: Option<u16>,
},
/// Check core health
Ping,
/// Print core version
Version,
/// Get health snapshot
Health,
/// Get runtime flags
RuntimeFlags,
/// Get security policy info
SecurityPolicy,
/// Generic JSON-RPC style method call
Call {
#[arg(long)]
method: String,
#[arg(long, default_value = "{}")]
params: String,
},
/// Generate shell completion scripts
Completions {
#[arg(value_enum)]
shell: Shell,
},
/// Settings style commands mirroring app settings sections
Settings {
#[command(subcommand)]
command: SettingsCommand,
},
/// Accessibility automation commands
Accessibility {
#[command(subcommand)]
command: AccessibilityCommand,
},
/// Tool wrappers for local CLI testing
Tools {
#[command(subcommand)]
command: ToolsCommand,
},
/// Legacy config operations
Config {
#[command(subcommand)]
command: ConfigCommand,
},
}
#[derive(Debug, Subcommand)]
enum SettingsCommand {
Model {
#[command(subcommand)]
command: ModelSettingsCommand,
},
Memory {
#[command(subcommand)]
command: MemorySettingsCommand,
},
Gateway {
#[command(subcommand)]
command: GatewaySettingsCommand,
},
Tunnel {
#[command(subcommand)]
command: TunnelSettingsCommand,
},
Runtime {
#[command(subcommand)]
command: RuntimeSettingsCommand,
},
Browser {
#[command(subcommand)]
command: BrowserSettingsCommand,
},
}
#[derive(Debug, Subcommand)]
enum ModelSettingsCommand {
Get,
Set(ModelSetArgs),
}
#[derive(Debug, Args)]
struct ModelSetArgs {
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
api_url: Option<String>,
#[arg(long)]
provider: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
temperature: Option<f64>,
}
#[derive(Debug, Subcommand)]
enum MemorySettingsCommand {
Get,
Set(MemorySetArgs),
}
#[derive(Debug, Args)]
struct MemorySetArgs {
#[arg(long)]
backend: Option<String>,
#[arg(long)]
auto_save: Option<bool>,
#[arg(long)]
embedding_provider: Option<String>,
#[arg(long)]
embedding_model: Option<String>,
#[arg(long)]
embedding_dimensions: Option<usize>,
}
#[derive(Debug, Subcommand)]
enum GatewaySettingsCommand {
Get,
Set(GatewaySetArgs),
}
#[derive(Debug, Args)]
struct GatewaySetArgs {
#[arg(long)]
host: Option<String>,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
require_pairing: Option<bool>,
#[arg(long)]
allow_public_bind: Option<bool>,
}
#[derive(Debug, Subcommand)]
enum TunnelSettingsCommand {
Get,
/// Replace tunnel settings with full JSON payload
Set(TunnelSetArgs),
}
#[derive(Debug, Args)]
struct TunnelSetArgs {
#[arg(long)]
json: String,
}
#[derive(Debug, Subcommand)]
enum RuntimeSettingsCommand {
Get,
Set(RuntimeSetArgs),
}
#[derive(Debug, Args)]
struct RuntimeSetArgs {
#[arg(long)]
kind: Option<String>,
#[arg(long)]
reasoning_enabled: Option<bool>,
}
#[derive(Debug, Subcommand)]
enum BrowserSettingsCommand {
Get,
Set(BrowserSetArgs),
}
#[derive(Debug, Subcommand)]
enum AccessibilityCommand {
/// Read current accessibility automation status
Status,
/// Diagnose accessibility permission readiness with actionable fixes
Doctor,
/// Request all accessibility-related permissions
RequestPermissions,
/// Request a specific permission kind
RequestPermission(RequestPermissionArgs),
/// Start a bounded accessibility session
StartSession(StartSessionCliArgs),
/// Stop the active accessibility session
StopSession(StopSessionCliArgs),
/// Force an immediate capture sample
CaptureNow,
/// Directly trigger capture_screen_image_ref (no active session required)
CaptureImageRef,
/// Fetch recent vision summaries
VisionRecent(VisionRecentCliArgs),
/// Flush immediate vision summary from latest frame
VisionFlush,
}
#[derive(Debug, Args)]
struct RequestPermissionArgs {
/// One of: screen_recording, accessibility, input_monitoring
#[arg(long)]
permission: String,
}
#[derive(Debug, Args)]
struct StartSessionCliArgs {
/// Explicit consent required to start
#[arg(long, default_value_t = false)]
consent: bool,
/// Optional session TTL in seconds (bounded server-side)
#[arg(long)]
ttl_secs: Option<u64>,
/// Optional override for screen monitoring
#[arg(long)]
screen_monitoring: Option<bool>,
/// Optional override for device control
#[arg(long)]
device_control: Option<bool>,
/// Optional override for predictive input
#[arg(long)]
predictive_input: Option<bool>,
}
#[derive(Debug, Args)]
struct StopSessionCliArgs {
#[arg(long)]
reason: Option<String>,
}
#[derive(Debug, Args)]
struct VisionRecentCliArgs {
#[arg(long)]
limit: Option<usize>,
}
#[derive(Debug, Args)]
struct BrowserSetArgs {
#[arg(long)]
enabled: Option<bool>,
}
#[derive(Debug, Subcommand)]
enum ConfigCommand {
/// Get full config snapshot
Get,
/// Update model settings with a JSON object
UpdateModel {
#[arg(long)]
json: String,
},
/// Update memory settings with a JSON object
UpdateMemory {
#[arg(long)]
json: String,
},
/// Update gateway settings with a JSON object
UpdateGateway {
#[arg(long)]
json: String,
},
/// Update runtime settings with a JSON object
UpdateRuntime {
#[arg(long)]
json: String,
},
/// Update browser settings with a JSON object
UpdateBrowser {
#[arg(long)]
json: String,
},
/// Replace tunnel settings with a JSON object
UpdateTunnel {
#[arg(long)]
json: String,
},
}
#[derive(Debug, Subcommand)]
enum ToolsCommand {
/// List tool wrappers exposed by this CLI
List,
/// Capture a screenshot using the screenshot tool
Screenshot(ToolsScreenshotArgs),
/// Capture image ref directly from accessibility engine
ScreenshotRef(ToolsScreenshotRefArgs),
/// Generic wrapper for available tool commands
Run(ToolsRunArgs),
}
#[derive(Debug, Args)]
struct ToolsScreenshotArgs {
/// Optional filename saved under workspace
#[arg(long)]
filename: Option<String>,
/// Optional region for macOS: selection | window
#[arg(long)]
region: Option<String>,
/// Optional output file path (copies or writes PNG to this path)
#[arg(long)]
output: Option<PathBuf>,
/// Include full data URL in JSON output
#[arg(long, default_value_t = false)]
print_data_url: bool,
}
#[derive(Debug, Args)]
struct ToolsScreenshotRefArgs {
/// Optional output file path (writes PNG to this path)
#[arg(long)]
output: Option<PathBuf>,
/// Include full data URL in JSON output
#[arg(long, default_value_t = false)]
print_data_url: bool,
}
#[derive(Debug, Args)]
struct ToolsRunArgs {
/// Tool wrapper name: screenshot | screenshot-ref
#[arg(long)]
name: String,
/// JSON arguments payload for selected wrapper
#[arg(long, default_value = "{}")]
args: String,
}
fn parse_json_arg(raw: &str) -> Result<serde_json::Value, String> {
serde_json::from_str(raw).map_err(|e| format!("invalid JSON for --json/--params: {e}"))
}
fn ensure_non_empty_payload(payload: &serde_json::Map<String, serde_json::Value>) -> Result<()> {
if payload.is_empty() {
return Err(anyhow::anyhow!("no fields provided for set operation"));
}
Ok(())
}
fn extract_data_url(raw: &str) -> Option<String> {
raw.lines()
.find_map(|line| {
let trimmed = line.trim();
trimmed
.starts_with("data:image/")
.then(|| trimmed.to_string())
})
}
fn extract_saved_path(raw: &str) -> Option<PathBuf> {
const PREFIX: &str = "Screenshot saved to: ";
raw.lines()
.find_map(|line| line.strip_prefix(PREFIX).map(PathBuf::from))
}
fn decode_data_url_bytes(data_url: &str) -> Result<Vec<u8>, String> {
let (meta, payload) = data_url
.split_once(',')
.ok_or_else(|| "invalid data URL: missing comma separator".to_string())?;
if !meta.starts_with("data:image/") || !meta.ends_with(";base64") {
return Err("invalid data URL: expected data:image/*;base64,...".to_string());
}
BASE64_STANDARD
.decode(payload)
.map_err(|e| format!("failed to decode base64 image payload: {e}"))
}
fn write_bytes_to_path(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create output directory: {e}"))?;
}
idx += 1;
}
std::fs::write(path, bytes).map_err(|e| format!("failed to write output file: {e}"))
}
async fn execute_tools_screenshot(args: ToolsScreenshotArgs) -> Result<serde_json::Value, String> {
let config = load_openhuman_config().await?;
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
));
let tool = ScreenshotTool::new(security);
let mut payload = serde_json::Map::new();
if let Some(filename) = args.filename {
payload.insert("filename".to_string(), json!(filename));
}
if let Some(region) = args.region {
payload.insert("region".to_string(), json!(region));
}
let tool_result = tool
.execute(serde_json::Value::Object(payload))
.await
.map_err(|e| format!("screenshot tool failed to execute: {e}"))?;
let mut logs = vec!["tools.screenshot executed".to_string()];
if let Some(output_path) = args.output.as_ref() {
if let Some(saved_path) = extract_saved_path(&tool_result.output) {
std::fs::copy(&saved_path, output_path).map_err(|e| {
format!(
"failed to copy screenshot from {} to {}: {e}",
saved_path.display(),
output_path.display()
)
})?;
logs.push(format!("copied screenshot to {}", output_path.display()));
} else if let Some(data_url) = extract_data_url(&tool_result.output) {
let bytes = decode_data_url_bytes(&data_url)?;
write_bytes_to_path(output_path, &bytes)?;
logs.push(format!(
"decoded data URL and wrote {} bytes to {}",
bytes.len(),
output_path.display()
));
} else {
return Err(
"screenshot tool response did not contain a saved path or image data URL"
.to_string(),
);
}
}
let data_url = extract_data_url(&tool_result.output);
let response = json!({
"result": {
"success": tool_result.success,
"error": tool_result.error,
"output_path": args.output.as_ref().map(|p| p.display().to_string()),
"tool_output": tool_result.output,
"data_url": if args.print_data_url { data_url } else { None::<String> },
},
"logs": logs
});
Ok(response)
}
async fn execute_tools_screenshot_ref(
args: ToolsScreenshotRefArgs,
) -> Result<serde_json::Value, String> {
let raw = call_method("openhuman.accessibility_capture_image_ref", json!({})).await?;
let payload: CommandResponse<CaptureImageRefResult> = serde_json::from_value(raw)
.map_err(|e| format!("failed to decode accessibility capture_image_ref response: {e}"))?;
let mut logs = payload.logs;
logs.push("tools.screenshot-ref executed".to_string());
if let Some(output_path) = args.output.as_ref() {
if let Some(data_url) = payload.result.image_ref.as_deref() {
let bytes = decode_data_url_bytes(data_url)?;
write_bytes_to_path(output_path, &bytes)?;
logs.push(format!(
"decoded image_ref and wrote {} bytes to {}",
bytes.len(),
output_path.display()
));
} else {
return Err("accessibility capture_image_ref did not return image_ref".to_string());
}
}
Ok(json!({
"result": {
"ok": payload.result.ok,
"mime_type": payload.result.mime_type,
"bytes_estimate": payload.result.bytes_estimate,
"message": payload.result.message,
"output_path": args.output.as_ref().map(|p| p.display().to_string()),
"image_ref": if args.print_data_url { payload.result.image_ref } else { None::<String> },
},
"logs": logs
}))
}
async fn get_config_snapshot() -> Result<CommandResponse<ConfigSnapshot>, String> {
let value = call_method("openhuman.get_config", json!({})).await?;
serde_json::from_value::<CommandResponse<ConfigSnapshot>>(value)
.map_err(|e| format!("failed to decode config snapshot: {e}"))
}
fn settings_view_response(
section: &'static str,
snapshot: CommandResponse<ConfigSnapshot>,
) -> CommandResponse<serde_json::Value> {
let cfg = &snapshot.result.config;
let settings = match section {
"model" => json!({
"api_key": cfg.get("api_key"),
"api_url": cfg.get("api_url"),
"default_provider": cfg.get("default_provider"),
"default_model": cfg.get("default_model"),
"default_temperature": cfg.get("default_temperature"),
}),
"memory" => cfg.get("memory").cloned().unwrap_or(serde_json::Value::Null),
"gateway" => cfg.get("gateway").cloned().unwrap_or(serde_json::Value::Null),
"tunnel" => cfg.get("tunnel").cloned().unwrap_or(serde_json::Value::Null),
"runtime" => cfg.get("runtime").cloned().unwrap_or(serde_json::Value::Null),
"browser" => cfg.get("browser").cloned().unwrap_or(serde_json::Value::Null),
_ => serde_json::Value::Null,
};
command_response(
json!({
"section": section,
"settings": settings,
"workspace_dir": snapshot.result.workspace_dir,
"config_path": snapshot.result.config_path,
}),
snapshot.logs,
)
}
async fn execute_core_cli(cli: CoreCli) -> Result<serde_json::Value, String> {
match cli.command {
CoreCommand::Run { port } => run_server(port)
.await
.map(|_| serde_json::Value::Null)
.map_err(|e| format!("run failed: {e}")),
CoreCommand::Ping => call_method("core.ping", json!({})).await,
CoreCommand::Version => call_method("core.version", json!({})).await,
CoreCommand::Health => call_method("openhuman.health_snapshot", json!({})).await,
CoreCommand::RuntimeFlags => call_method("openhuman.get_runtime_flags", json!({})).await,
CoreCommand::SecurityPolicy => {
call_method("openhuman.security_policy_info", json!({})).await
}
CoreCommand::Call { method, params } => call_method(&method, parse_json_arg(&params)?).await,
CoreCommand::Completions { shell } => {
let mut cmd = CoreCli::command();
let bin_name = cmd.get_name().to_string();
generate(shell, &mut cmd, bin_name, &mut io::stdout());
Ok(serde_json::Value::Null)
}
CoreCommand::Settings { command } => match command {
SettingsCommand::Model { command } => match command {
ModelSettingsCommand::Get => {
let snapshot = get_config_snapshot().await?;
serde_json::to_value(settings_view_response("model", snapshot))
.map_err(|e| e.to_string())
}
ModelSettingsCommand::Set(args) => {
let mut payload = serde_json::Map::new();
if let Some(v) = args.api_key {
payload.insert("api_key".to_string(), json!(v));
}
if let Some(v) = args.api_url {
payload.insert("api_url".to_string(), json!(v));
}
if let Some(v) = args.provider {
payload.insert("default_provider".to_string(), json!(v));
}
if let Some(v) = args.model {
payload.insert("default_model".to_string(), json!(v));
}
if let Some(v) = args.temperature {
payload.insert("default_temperature".to_string(), json!(v));
}
ensure_non_empty_payload(&payload).map_err(|e| e.to_string())?;
call_method("openhuman.update_model_settings", serde_json::Value::Object(payload))
.await
}
},
SettingsCommand::Memory { command } => match command {
MemorySettingsCommand::Get => {
let snapshot = get_config_snapshot().await?;
serde_json::to_value(settings_view_response("memory", snapshot))
.map_err(|e| e.to_string())
}
MemorySettingsCommand::Set(args) => {
let mut payload = serde_json::Map::new();
if let Some(v) = args.backend {
payload.insert("backend".to_string(), json!(v));
}
if let Some(v) = args.auto_save {
payload.insert("auto_save".to_string(), json!(v));
}
if let Some(v) = args.embedding_provider {
payload.insert("embedding_provider".to_string(), json!(v));
}
if let Some(v) = args.embedding_model {
payload.insert("embedding_model".to_string(), json!(v));
}
if let Some(v) = args.embedding_dimensions {
payload.insert("embedding_dimensions".to_string(), json!(v));
}
ensure_non_empty_payload(&payload).map_err(|e| e.to_string())?;
call_method(
"openhuman.update_memory_settings",
serde_json::Value::Object(payload),
)
.await
}
},
SettingsCommand::Gateway { command } => match command {
GatewaySettingsCommand::Get => {
let snapshot = get_config_snapshot().await?;
serde_json::to_value(settings_view_response("gateway", snapshot))
.map_err(|e| e.to_string())
}
GatewaySettingsCommand::Set(args) => {
let mut payload = serde_json::Map::new();
if let Some(v) = args.host {
payload.insert("host".to_string(), json!(v));
}
if let Some(v) = args.port {
payload.insert("port".to_string(), json!(v));
}
if let Some(v) = args.require_pairing {
payload.insert("require_pairing".to_string(), json!(v));
}
if let Some(v) = args.allow_public_bind {
payload.insert("allow_public_bind".to_string(), json!(v));
}
ensure_non_empty_payload(&payload).map_err(|e| e.to_string())?;
call_method(
"openhuman.update_gateway_settings",
serde_json::Value::Object(payload),
)
.await
}
},
SettingsCommand::Tunnel { command } => match command {
TunnelSettingsCommand::Get => {
let snapshot = get_config_snapshot().await?;
serde_json::to_value(settings_view_response("tunnel", snapshot))
.map_err(|e| e.to_string())
}
TunnelSettingsCommand::Set(args) => {
call_method("openhuman.update_tunnel_settings", parse_json_arg(&args.json)?).await
}
},
SettingsCommand::Runtime { command } => match command {
RuntimeSettingsCommand::Get => {
let snapshot = get_config_snapshot().await?;
serde_json::to_value(settings_view_response("runtime", snapshot))
.map_err(|e| e.to_string())
}
RuntimeSettingsCommand::Set(args) => {
let mut payload = serde_json::Map::new();
if let Some(v) = args.kind {
payload.insert("kind".to_string(), json!(v));
}
if let Some(v) = args.reasoning_enabled {
payload.insert("reasoning_enabled".to_string(), json!(v));
}
ensure_non_empty_payload(&payload).map_err(|e| e.to_string())?;
call_method(
"openhuman.update_runtime_settings",
serde_json::Value::Object(payload),
)
.await
}
},
SettingsCommand::Browser { command } => match command {
BrowserSettingsCommand::Get => {
let snapshot = get_config_snapshot().await?;
serde_json::to_value(settings_view_response("browser", snapshot))
.map_err(|e| e.to_string())
}
BrowserSettingsCommand::Set(args) => {
let mut payload = serde_json::Map::new();
if let Some(v) = args.enabled {
payload.insert("enabled".to_string(), json!(v));
}
ensure_non_empty_payload(&payload).map_err(|e| e.to_string())?;
call_method(
"openhuman.update_browser_settings",
serde_json::Value::Object(payload),
)
.await
}
},
},
CoreCommand::Accessibility { command } => match command {
AccessibilityCommand::Status => {
call_method("openhuman.accessibility_status", json!({})).await
}
AccessibilityCommand::Doctor => {
let raw = call_method("openhuman.accessibility_status", json!({})).await?;
let payload: CommandResponse<AccessibilityStatus> = serde_json::from_value(raw)
.map_err(|e| format!("failed to decode accessibility status: {e}"))?;
let permissions = &payload.result.permissions;
let screen_ready = permissions.screen_recording == PermissionState::Granted;
let control_ready = permissions.accessibility == PermissionState::Granted;
let monitoring_ready = permissions.input_monitoring == PermissionState::Granted;
let overall_ready = payload.result.platform_supported && screen_ready && control_ready;
let mut recommendations: Vec<String> = Vec::new();
if !payload.result.platform_supported {
recommendations.push(
"Accessibility automation is macOS-only in this build/runtime."
.to_string(),
);
}
if permissions.screen_recording != PermissionState::Granted {
recommendations.push(
"Grant Screen Recording in System Settings -> Privacy & Security -> Screen Recording."
.to_string(),
);
}
if permissions.accessibility != PermissionState::Granted {
recommendations.push(
"Grant Accessibility in System Settings -> Privacy & Security -> Accessibility."
.to_string(),
);
}
if permissions.input_monitoring != PermissionState::Granted {
recommendations.push(
"Grant Input Monitoring in System Settings -> Privacy & Security -> Input Monitoring (optional but recommended)."
.to_string(),
);
}
if recommendations.is_empty() {
recommendations.push("No action required. Accessibility automation is ready.".to_string());
}
Ok(json!({
"result": {
"summary": {
"overall_ready": overall_ready,
"platform_supported": payload.result.platform_supported,
"session_active": payload.result.session.active,
"screen_capture_ready": screen_ready,
"device_control_ready": control_ready,
"input_monitoring_ready": monitoring_ready
},
"permissions": permissions,
"features": payload.result.features,
"recommendations": recommendations
},
"logs": payload.logs
}))
}
AccessibilityCommand::RequestPermissions => {
call_method("openhuman.accessibility_request_permissions", json!({})).await
}
AccessibilityCommand::RequestPermission(args) => {
call_method(
"openhuman.accessibility_request_permission",
json!({ "permission": args.permission }),
)
.await
}
AccessibilityCommand::StartSession(args) => {
call_method(
"openhuman.accessibility_start_session",
json!({
"consent": args.consent,
"ttl_secs": args.ttl_secs,
"screen_monitoring": args.screen_monitoring,
"device_control": args.device_control,
"predictive_input": args.predictive_input,
}),
)
.await
}
AccessibilityCommand::StopSession(args) => {
call_method(
"openhuman.accessibility_stop_session",
json!({ "reason": args.reason }),
)
.await
}
AccessibilityCommand::CaptureNow => {
call_method("openhuman.accessibility_capture_now", json!({})).await
}
AccessibilityCommand::CaptureImageRef => {
call_method("openhuman.accessibility_capture_image_ref", json!({})).await
}
AccessibilityCommand::VisionRecent(args) => {
call_method(
"openhuman.accessibility_vision_recent",
json!({ "limit": args.limit }),
)
.await
}
AccessibilityCommand::VisionFlush => {
call_method("openhuman.accessibility_vision_flush", json!({})).await
}
},
CoreCommand::Tools { command } => match command {
ToolsCommand::List => Ok(json!({
"result": {
"wrappers": [
{
"name": "screenshot",
"description": "Capture a screenshot with screenshot tool wrapper."
},
{
"name": "screenshot-ref",
"description": "Capture data URL from accessibility capture_image_ref."
}
]
},
"logs": ["tools wrappers listed"]
})),
ToolsCommand::Screenshot(args) => execute_tools_screenshot(args).await,
ToolsCommand::ScreenshotRef(args) => execute_tools_screenshot_ref(args).await,
ToolsCommand::Run(args) => {
let parsed = parse_json_arg(&args.args)?;
match args.name.as_str() {
"screenshot" => {
let payload = parsed.as_object().cloned().unwrap_or_default();
let wrapped = ToolsScreenshotArgs {
filename: payload
.get("filename")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
region: payload
.get("region")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
output: payload
.get("output")
.and_then(serde_json::Value::as_str)
.map(PathBuf::from),
print_data_url: payload
.get("print_data_url")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
};
execute_tools_screenshot(wrapped).await
}
"screenshot-ref" | "screenshot_ref" => {
let payload = parsed.as_object().cloned().unwrap_or_default();
let wrapped = ToolsScreenshotRefArgs {
output: payload
.get("output")
.and_then(serde_json::Value::as_str)
.map(PathBuf::from),
print_data_url: payload
.get("print_data_url")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
};
execute_tools_screenshot_ref(wrapped).await
}
other => Err(format!(
"unsupported tool wrapper '{other}'. available: screenshot, screenshot-ref"
)),
}
}
},
CoreCommand::Config { command } => match command {
ConfigCommand::Get => call_method("openhuman.get_config", json!({})).await,
ConfigCommand::UpdateModel { json } => {
call_method("openhuman.update_model_settings", parse_json_arg(&json)?).await
}
ConfigCommand::UpdateMemory { json } => {
call_method("openhuman.update_memory_settings", parse_json_arg(&json)?).await
}
ConfigCommand::UpdateGateway { json } => {
call_method("openhuman.update_gateway_settings", parse_json_arg(&json)?).await
}
ConfigCommand::UpdateRuntime { json } => {
call_method("openhuman.update_runtime_settings", parse_json_arg(&json)?).await
}
ConfigCommand::UpdateBrowser { json } => {
call_method("openhuman.update_browser_settings", parse_json_arg(&json)?).await
}
ConfigCommand::UpdateTunnel { json } => {
call_method("openhuman.update_tunnel_settings", parse_json_arg(&json)?).await
}
},
}
}
pub fn run_from_cli_args(args: &[String]) -> Result<()> {
let mut argv = Vec::with_capacity(args.len() + 1);
argv.push("openhuman-core".to_string());
argv.extend(args.iter().cloned());
let cli =
CoreCli::try_parse_from(argv).map_err(|e| anyhow::anyhow!(e.render().to_string()))?;
let thread_stack_size = std::env::var("OPENHUMAN_CORE_THREAD_STACK_SIZE")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(8 * 1024 * 1024);
let runtime = tokio::runtime::Builder::new_multi_thread()
.thread_stack_size(thread_stack_size)
.enable_all()
.build()?;
runtime.block_on(run_server(port))
let output = runtime.block_on(execute_core_cli(cli)).map_err(anyhow::Error::msg)?;
if !output.is_null() {
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_else(|_| "null".to_string())
);
}
Ok(())
}
#[cfg(test)]
@@ -122,6 +122,15 @@ pub struct CaptureNowResult {
pub frame: Option<CaptureFrame>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureImageRefResult {
pub ok: bool,
pub image_ref: Option<String>,
pub mime_type: String,
pub bytes_estimate: Option<usize>,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisionSummary {
pub id: String,
@@ -535,6 +544,30 @@ impl AccessibilityEngine {
})
}
pub async fn capture_image_ref_test(&self) -> CaptureImageRefResult {
match capture_screen_image_ref() {
Ok(image_ref) => {
let bytes_estimate = image_ref
.strip_prefix("data:image/png;base64,")
.map(|payload| payload.len() * 3 / 4);
CaptureImageRefResult {
ok: true,
image_ref: Some(image_ref),
mime_type: "image/png".to_string(),
bytes_estimate,
message: "screen capture completed".to_string(),
}
}
Err(err) => CaptureImageRefResult {
ok: false,
image_ref: None,
mime_type: "image/png".to_string(),
bytes_estimate: None,
message: err,
},
}
}
pub async fn input_action(
&self,
action: InputActionParams,
@@ -60,15 +60,15 @@ fn default_provider() -> String {
}
fn default_model_id() -> String {
"qwen2.5:1.5b".to_string()
"gemma3:4b-it-qat".to_string()
}
fn default_chat_model_id() -> String {
"qwen2.5:1.5b".to_string()
"gemma3:4b-it-qat".to_string()
}
fn default_vision_model_id() -> String {
"qwen3-vl:2b".to_string()
"gemma3:4b-it-qat".to_string()
}
fn default_embedding_model_id() -> String {
+133 -20
View File
@@ -7,19 +7,79 @@ use std::fs;
use std::path::PathBuf;
use std::process::{Command, Stdio};
const SERVICE_LABEL: &str = "com.openhuman.daemon";
const LEGACY_SERVICE_LABEL: &str = "com.openhuman.app";
const WINDOWS_TASK_NAME: &str = "OpenHuman Daemon";
const SERVICE_LABEL: &str = "com.openhuman.core";
const LEGACY_SERVICE_LABEL: &str = "com.openhuman.daemon";
const LEGACY_APP_LABEL: &str = "com.openhuman.app";
const WINDOWS_TASK_NAME: &str = "OpenHuman Core";
fn windows_task_name() -> &'static str {
WINDOWS_TASK_NAME
}
fn resolve_daemon_executable() -> Result<PathBuf> {
if let Ok(path) = std::env::var("OPENHUMAN_CORE_BIN") {
let candidate = PathBuf::from(path);
if candidate.exists() {
return Ok(candidate);
}
}
let exe = std::env::current_exe().context("Failed to resolve current executable")?;
let exe_dir = exe
.parent()
.map(PathBuf::from)
.ok_or_else(|| anyhow::anyhow!("Failed to resolve executable directory"))?;
#[cfg(target_os = "macos")]
let mut search_dirs = vec![
exe_dir.clone(),
exe_dir
.parent()
.map(|p| p.join("Resources"))
.unwrap_or_else(|| exe_dir.clone()),
];
#[cfg(not(target_os = "macos"))]
let search_dirs = vec![exe_dir.clone()];
for dir in search_dirs.drain(..) {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() || is_current_executable(&path) {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
#[cfg(windows)]
let matches = name.starts_with("openhuman-")
|| name.starts_with("openhuman-core-")
|| name.eq_ignore_ascii_case("openhuman.exe")
|| name.eq_ignore_ascii_case("openhuman-core.exe");
#[cfg(not(windows))]
let matches = name.starts_with("openhuman-")
|| name.starts_with("openhuman-core-")
|| name == "openhuman"
|| name == "openhuman-core";
if matches {
return Ok(path);
}
}
}
Ok(exe)
}
fn daemon_program_args(exe: &std::path::Path) -> Vec<String> {
let raw_file_name = exe.file_name().and_then(|n| n.to_str()).unwrap_or_default();
let file_name = raw_file_name.to_ascii_lowercase();
let standalone_core_binary = !is_current_executable(exe)
&& (file_name.contains("openhuman-core")
|| file_name.starts_with("openhuman-")
|| file_name.starts_with("openhuman-core-")
|| raw_file_name == "openhuman"
|| raw_file_name == "openhuman.exe");
@@ -95,9 +155,25 @@ pub fn start(config: &Config) -> Result<ServiceStatus> {
let domain = macos_gui_domain()?;
let primary_target = macos_target(SERVICE_LABEL)?;
if !plist.exists() {
log::info!(
"[service] LaunchAgent plist missing, installing it before start: {}",
plist.display()
);
install_macos(config)?;
}
validate_macos_plist(&plist)?;
// Prefer modern launchctl lifecycle commands on macOS.
if !is_service_loaded_macos()? {
log::info!("[service] Loading macOS LaunchAgent service");
run_best_effort(
Command::new("launchctl")
.arg("bootout")
.arg(&domain)
.arg(&primary_target),
);
let bootstrap_ok = run_checked(
Command::new("launchctl")
.arg("bootstrap")
@@ -205,6 +281,8 @@ pub fn stop(config: &Config) -> Result<ServiceStatus> {
let legacy_plist = macos_service_file_for(LEGACY_SERVICE_LABEL)?;
let legacy_target = macos_target(LEGACY_SERVICE_LABEL)?;
let legacy_app_plist = macos_service_file_for(LEGACY_APP_LABEL)?;
let legacy_app_target = macos_target(LEGACY_APP_LABEL)?;
// Modern lifecycle path first.
run_best_effort(
@@ -231,6 +309,18 @@ pub fn stop(config: &Config) -> Result<ServiceStatus> {
.arg(&domain)
.arg(&legacy_plist),
);
run_best_effort(
Command::new("launchctl")
.arg("bootout")
.arg(&domain)
.arg(&legacy_app_target),
);
run_best_effort(
Command::new("launchctl")
.arg("bootout")
.arg(&domain)
.arg(&legacy_app_plist),
);
// Compatibility fallback.
run_best_effort(Command::new("launchctl").arg("stop").arg(SERVICE_LABEL));
@@ -239,6 +329,7 @@ pub fn stop(config: &Config) -> Result<ServiceStatus> {
.arg("stop")
.arg(LEGACY_SERVICE_LABEL),
);
run_best_effort(Command::new("launchctl").arg("stop").arg(LEGACY_APP_LABEL));
return status(config);
}
@@ -261,9 +352,11 @@ pub fn stop(config: &Config) -> Result<ServiceStatus> {
pub fn status(config: &Config) -> Result<ServiceStatus> {
if std::env::consts::OS == "macos" {
let out = run_capture(Command::new("launchctl").arg("list"))?;
let running = out
.lines()
.any(|line| line.contains(SERVICE_LABEL) || line.contains(LEGACY_SERVICE_LABEL));
let running = out.lines().any(|line| {
line.contains(SERVICE_LABEL)
|| line.contains(LEGACY_SERVICE_LABEL)
|| line.contains(LEGACY_APP_LABEL)
});
return Ok(ServiceStatus {
state: if running {
ServiceState::Running
@@ -342,6 +435,10 @@ pub fn uninstall(config: &Config) -> Result<ServiceStatus> {
if legacy_file.exists() {
let _ = fs::remove_file(&legacy_file);
}
let legacy_app_file = macos_service_file_for(LEGACY_APP_LABEL)?;
if legacy_app_file.exists() {
let _ = fs::remove_file(&legacy_app_file);
}
return Ok(ServiceStatus {
state: ServiceState::NotInstalled,
unit_path: Some(file),
@@ -395,7 +492,7 @@ fn install_macos(config: &Config) -> Result<()> {
fs::create_dir_all(parent)?;
}
let exe = std::env::current_exe().context("Failed to resolve current executable")?;
let exe = resolve_daemon_executable()?;
let logs_dir = config
.config_path
.parent()
@@ -412,9 +509,9 @@ fn install_macos(config: &Config) -> Result<()> {
.collect::<String>();
let plist = format!(
r#"<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
<plist version=\"1.0\">
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
@@ -465,7 +562,7 @@ fn install_linux(config: &Config) -> Result<()> {
fs::create_dir_all(parent)?;
}
let exe = std::env::current_exe().context("Failed to resolve current executable")?;
let exe = resolve_daemon_executable()?;
let logs_dir = config
.config_path
.parent()
@@ -490,7 +587,7 @@ fn install_linux(config: &Config) -> Result<()> {
}
fn install_windows(config: &Config) -> Result<()> {
let exe = std::env::current_exe().context("Failed to resolve current executable")?;
let exe = resolve_daemon_executable()?;
let logs_dir = config
.config_path
.parent()
@@ -555,6 +652,11 @@ fn macos_target(label: &str) -> Result<String> {
Ok(format!("{}/{}", macos_gui_domain()?, label))
}
fn validate_macos_plist(path: &std::path::Path) -> Result<()> {
run_checked(Command::new("plutil").arg("-lint").arg(path))
.with_context(|| format!("Invalid launch agent plist: {}", path.display()))
}
fn linux_service_file(config: &Config) -> Result<PathBuf> {
let config_dir = config
.config_path
@@ -598,24 +700,35 @@ fn run_best_effort(cmd: &mut Command) {
}
}
fn run_check_silent(cmd: &mut Command) -> bool {
cmd.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
/// Check if the macOS LaunchAgent service is loaded (regardless of running state)
fn is_service_loaded_macos() -> Result<bool> {
if run_checked(
if run_check_silent(
Command::new("launchctl")
.arg("print")
.arg(macos_target(SERVICE_LABEL)?),
)
.is_ok()
{
) {
return Ok(true);
}
if run_checked(
if run_check_silent(
Command::new("launchctl")
.arg("print")
.arg(macos_target(LEGACY_SERVICE_LABEL)?),
)
.is_ok()
{
) {
return Ok(true);
}
if run_check_silent(
Command::new("launchctl")
.arg("print")
.arg(macos_target(LEGACY_APP_LABEL)?),
) {
return Ok(true);
}
Ok(false)
+87 -23
View File
@@ -76,6 +76,32 @@ async fn call_core<T: DeserializeOwned>(
crate::core_rpc::call(method, params).await
}
async fn ensure_service_managed_core_running() -> Result<(), String> {
let config = load_config_local().await?;
let _ = service::install(&config);
let _ = service::start(&config);
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(),
)
}
async fn call_core_service_managed<T: DeserializeOwned>(
method: &str,
params: serde_json::Value,
) -> Result<T, String> {
ensure_service_managed_core_running().await?;
crate::core_rpc::call(method, params).await
}
async fn load_config_local() -> Result<openhuman_core::openhuman::config::Config, String> {
let timeout_duration = std::time::Duration::from_secs(30);
match tokio::time::timeout(
@@ -96,6 +122,12 @@ pub struct AgentServerStatus {
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DaemonHostConfigPayload {
pub show_tray: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalAiStatus {
pub state: String,
@@ -438,7 +470,8 @@ pub async fn openhuman_agent_chat(
pub async fn openhuman_accessibility_status(
app: tauri::AppHandle,
) -> Result<CommandResponse<AccessibilityStatus>, String> {
call_core(&app, "openhuman.accessibility_status", params_none()).await
let _ = app;
call_core_service_managed("openhuman.accessibility_status", params_none()).await
}
/// Request accessibility-related permissions on macOS.
@@ -446,12 +479,9 @@ pub async fn openhuman_accessibility_status(
pub async fn openhuman_accessibility_request_permissions(
app: tauri::AppHandle,
) -> Result<CommandResponse<PermissionStatus>, String> {
let response: CommandResponse<PermissionStatus> = call_core(
&app,
"openhuman.accessibility_request_permissions",
params_none(),
)
.await?;
let response: CommandResponse<PermissionStatus> =
call_core_service_managed("openhuman.accessibility_request_permissions", params_none())
.await?;
emit_accessibility_event(
&app,
"permissions_requested",
@@ -466,8 +496,7 @@ pub async fn openhuman_accessibility_request_permission(
app: tauri::AppHandle,
params: PermissionRequestParams,
) -> Result<CommandResponse<PermissionStatus>, String> {
let response: CommandResponse<PermissionStatus> = call_core(
&app,
let response: CommandResponse<PermissionStatus> = call_core_service_managed(
"openhuman.accessibility_request_permission",
serde_json::json!(params),
)
@@ -486,8 +515,7 @@ pub async fn openhuman_accessibility_start_session(
app: tauri::AppHandle,
params: StartSessionParams,
) -> Result<CommandResponse<SessionStatus>, String> {
let response: CommandResponse<SessionStatus> = call_core(
&app,
let response: CommandResponse<SessionStatus> = call_core_service_managed(
"openhuman.accessibility_start_session",
serde_json::json!(params),
)
@@ -502,8 +530,7 @@ pub async fn openhuman_accessibility_stop_session(
app: tauri::AppHandle,
params: Option<StopSessionParams>,
) -> Result<CommandResponse<SessionStatus>, String> {
let response: CommandResponse<SessionStatus> = call_core(
&app,
let response: CommandResponse<SessionStatus> = call_core_service_managed(
"openhuman.accessibility_stop_session",
serde_json::json!(params.unwrap_or(StopSessionParams { reason: None })),
)
@@ -517,7 +544,8 @@ pub async fn openhuman_accessibility_stop_session(
pub async fn openhuman_accessibility_capture_now(
app: tauri::AppHandle,
) -> Result<CommandResponse<CaptureNowResult>, String> {
call_core(&app, "openhuman.accessibility_capture_now", params_none()).await
let _ = app;
call_core_service_managed("openhuman.accessibility_capture_now", params_none()).await
}
/// Execute a validated input action in an active accessibility session.
@@ -526,8 +554,7 @@ pub async fn openhuman_accessibility_input_action(
app: tauri::AppHandle,
params: InputActionParams,
) -> Result<CommandResponse<InputActionResult>, String> {
let response: CommandResponse<InputActionResult> = call_core(
&app,
let response: CommandResponse<InputActionResult> = call_core_service_managed(
"openhuman.accessibility_input_action",
serde_json::json!(params),
)
@@ -544,8 +571,8 @@ pub async fn openhuman_accessibility_autocomplete_suggest(
app: tauri::AppHandle,
params: Option<AutocompleteSuggestParams>,
) -> Result<CommandResponse<AutocompleteSuggestResult>, String> {
call_core(
&app,
let _ = app;
call_core_service_managed(
"openhuman.accessibility_autocomplete_suggest",
serde_json::json!(params.unwrap_or(AutocompleteSuggestParams {
context: None,
@@ -561,8 +588,8 @@ pub async fn openhuman_accessibility_autocomplete_commit(
app: tauri::AppHandle,
params: AutocompleteCommitParams,
) -> Result<CommandResponse<AutocompleteCommitResult>, String> {
call_core(
&app,
let _ = app;
call_core_service_managed(
"openhuman.accessibility_autocomplete_commit",
serde_json::json!(params),
)
@@ -574,8 +601,8 @@ pub async fn openhuman_accessibility_vision_recent(
app: tauri::AppHandle,
limit: Option<usize>,
) -> Result<CommandResponse<VisionRecentResult>, String> {
call_core(
&app,
let _ = app;
call_core_service_managed(
"openhuman.accessibility_vision_recent",
serde_json::json!({ "limit": limit }),
)
@@ -586,7 +613,8 @@ pub async fn openhuman_accessibility_vision_recent(
pub async fn openhuman_accessibility_vision_flush(
app: tauri::AppHandle,
) -> Result<CommandResponse<VisionFlushResult>, String> {
call_core(&app, "openhuman.accessibility_vision_flush", params_none()).await
let _ = app;
call_core_service_managed("openhuman.accessibility_vision_flush", params_none()).await
}
#[tauri::command]
@@ -922,6 +950,42 @@ pub async fn openhuman_agent_server_status() -> Result<CommandResponse<AgentServ
})
}
/// Read local daemon-host settings (not synced, machine-local).
#[tauri::command]
pub async fn openhuman_get_daemon_host_config(
app: tauri::AppHandle,
) -> Result<CommandResponse<DaemonHostConfigPayload>, String> {
let cfg = crate::daemon_host_config::load(&app).await;
Ok(CommandResponse {
result: DaemonHostConfigPayload {
show_tray: cfg.show_tray,
},
logs: vec!["daemon host config loaded".to_string()],
})
}
/// Update local daemon-host settings (not synced, machine-local).
#[tauri::command]
pub async fn openhuman_set_daemon_host_config(
app: tauri::AppHandle,
show_tray: Option<bool>,
) -> Result<CommandResponse<DaemonHostConfigPayload>, String> {
let mut cfg = crate::daemon_host_config::load(&app).await;
if let Some(value) = show_tray {
cfg.show_tray = value;
}
crate::daemon_host_config::save(&app, &cfg).await?;
Ok(CommandResponse {
result: DaemonHostConfigPayload {
show_tray: cfg.show_tray,
},
logs: vec![
"daemon host config saved".to_string(),
"restart daemon host process to apply tray visibility changes".to_string(),
],
})
}
/// Install the OpenHuman daemon service.
#[tauri::command]
pub async fn openhuman_service_install(
+4 -4
View File
@@ -71,9 +71,9 @@ impl CoreProcessHandle {
// explicit subcommand path so we don't accidentally relaunch clients.
cmd.arg("core");
}
cmd.arg("serve").arg("--port").arg(self.port.to_string());
cmd.arg("run").arg("--port").arg(self.port.to_string());
log::info!(
"[core] spawning dedicated core binary: {:?} serve --port {}",
"[core] spawning dedicated core binary: {:?} run --port {}",
cmd.as_std().get_program(),
self.port
);
@@ -83,7 +83,7 @@ impl CoreProcessHandle {
.map_err(|e| format!("failed to resolve current executable: {e}"))?;
let mut cmd = Command::new(exe);
cmd.arg("core")
.arg("serve")
.arg("run")
.arg("--port")
.arg(self.port.to_string());
log::warn!(
@@ -218,7 +218,7 @@ pub fn default_core_bin() -> Option<PathBuf> {
}
// Dev ergonomics: in debug builds, prefer spawning this same executable with
// `core serve` so Cargo recompiles core logic changes as part of tauri dev.
// `core run` so Cargo recompiles core logic changes as part of tauri dev.
// Sidecar discovery remains enabled for packaged/release builds.
if cfg!(debug_assertions) {
return None;
+50
View File
@@ -0,0 +1,50 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
const DAEMON_HOST_CONFIG_FILE: &str = "daemon_host_config.json";
#[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 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)
}
pub async fn load(app: &AppHandle) -> DaemonHostConfig {
let path = 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()
}
pub async fn save(app: &AppHandle, config: &DaemonHostConfig) -> Result<(), String> {
let path = 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}"))
}
+12 -8
View File
@@ -14,6 +14,7 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supp
mod commands;
mod core_process;
mod core_rpc;
mod daemon_host_config;
pub mod memory;
mod models;
mod openhuman_daemon;
@@ -727,7 +728,15 @@ pub fn run() {
#[cfg(desktop)]
{
if daemon_mode {
setup_tray(app.handle())?;
let daemon_host_cfg =
tauri::async_runtime::block_on(daemon_host_config::load(app.handle()));
if daemon_host_cfg.show_tray {
setup_tray(app.handle())?;
} else {
log::info!(
"[app] Daemon host tray disabled by local config (show_tray=false)"
);
}
}
}
@@ -1117,6 +1126,8 @@ pub fn run() {
openhuman_service_status,
openhuman_service_uninstall,
openhuman_agent_server_status,
openhuman_get_daemon_host_config,
openhuman_set_daemon_host_config,
// Unified skill registry commands
unified_list_skills,
unified_execute_skill,
@@ -1168,13 +1179,6 @@ pub fn run() {
log::info!("[openhuman] Daemon shutdown signalled");
}
if let Some(core) = app_handle.try_state::<core_process::CoreProcessHandle>() {
let core_handle: core_process::CoreProcessHandle = (*core).clone();
tauri::async_runtime::spawn(async move {
core_handle.shutdown().await;
});
}
let _ = app_handle;
}
+61 -1
View File
@@ -13,11 +13,16 @@ import {
XCircleIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { formatRelativeTime, useDaemonHealth } from '../../hooks/useDaemonHealth';
import type { ComponentHealth, DaemonStatus } from '../../store/daemonSlice';
import { IS_DEV } from '../../utils/config';
import {
isTauri,
openhumanGetDaemonHostConfig,
openhumanSetDaemonHostConfig,
} from '../../utils/tauriCommands';
interface Props {
userId?: string;
@@ -28,6 +33,8 @@ interface Props {
const DaemonHealthPanel = ({ userId, onClose, className = '' }: Props) => {
const daemonHealth = useDaemonHealth(userId);
const [operationLoading, setOperationLoading] = useState<string | null>(null);
const [showTray, setShowTray] = useState<boolean>(true);
const [trayLoaded, setTrayLoaded] = useState<boolean>(false);
// Handle agent operations with loading states
const handleOperation = async (operation: () => Promise<unknown>, operationName: string) => {
@@ -83,6 +90,39 @@ const DaemonHealthPanel = ({ userId, onClose, className = '' }: Props) => {
const statusStyling = getStatusStyling(daemonHealth.status);
const StatusIcon = statusStyling.icon;
useEffect(() => {
if (!isTauri()) {
return;
}
const loadTray = async () => {
try {
const result = await openhumanGetDaemonHostConfig();
setShowTray(result.result.show_tray);
setTrayLoaded(true);
} catch (error) {
console.error('[AgentHealthPanel] Failed to load daemon host config:', error);
}
};
void loadTray();
}, []);
const updateTraySetting = async (enabled: boolean) => {
if (!isTauri()) {
return;
}
const previous = showTray;
setShowTray(enabled);
setOperationLoading('tray');
try {
await openhumanSetDaemonHostConfig(enabled);
} catch (error) {
setShowTray(previous);
console.error('[AgentHealthPanel] Failed to save daemon host config:', error);
} finally {
setOperationLoading(null);
}
};
return (
<div className={`bg-stone-900 rounded-lg border border-stone-700 p-6 space-y-6 ${className}`}>
{/* Header */}
@@ -186,6 +226,26 @@ const DaemonHealthPanel = ({ userId, onClose, className = '' }: Props) => {
</label>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-stone-800/40 border border-stone-700/60">
<div>
<div className="text-sm font-medium text-gray-300">Show Daemon Tray</div>
<div className="text-xs text-gray-500">Display tray icon in daemon host mode</div>
<div className="text-xs text-amber-400 mt-1">Requires daemon host restart to apply</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={showTray}
disabled={!trayLoaded || operationLoading !== null}
onChange={e => {
void updateTraySetting(e.target.checked);
}}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-disabled:opacity-60 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
{/* Control Actions */}
<div className="flex flex-wrap gap-2">
<button
@@ -18,6 +18,7 @@ import {
openhumanDoctorReport,
openhumanEncryptSecret,
openhumanGetConfig,
openhumanGetDaemonHostConfig,
openhumanGetIntegrationInfo,
openhumanHardwareDiscover,
openhumanHardwareIntrospect,
@@ -27,6 +28,7 @@ import {
openhumanServiceInstall,
openhumanServiceStatus,
openhumanServiceUninstall,
openhumanSetDaemonHostConfig,
openhumanUpdateGatewaySettings,
openhumanUpdateMemorySettings,
openhumanUpdateModelSettings,
@@ -104,6 +106,8 @@ const TauriCommandsPanel = () => {
const [chatModel, setChatModel] = useState<string>('');
const [chatTemperature, setChatTemperature] = useState<string>('0.7');
const [chatLog, setChatLog] = useState<Array<{ role: 'user' | 'agent'; text: string }>>([]);
const [daemonShowTray, setDaemonShowTray] = useState<boolean>(true);
const [daemonShowTrayLoaded, setDaemonShowTrayLoaded] = useState<boolean>(false);
// Loading states
const [operationLoading, setOperationLoading] = useState<string>('');
@@ -413,6 +417,45 @@ const TauriCommandsPanel = () => {
}
};
const loadDaemonHostConfig = useCallback(async () => {
if (!tauriAvailable) {
return;
}
try {
const result = await openhumanGetDaemonHostConfig();
setDaemonShowTray(result.result.show_tray);
setDaemonShowTrayLoaded(true);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
}
}, [tauriAvailable]);
const saveDaemonHostTraySetting = useCallback(
async (showTray: boolean) => {
if (!tauriAvailable) {
return;
}
const previous = daemonShowTray;
setDaemonShowTray(showTray);
setOperationLoading('saveDaemonHostConfig');
setError('');
try {
const result = await openhumanSetDaemonHostConfig(showTray);
setOutput(formatJson(result));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setDaemonShowTray(previous);
setError(message);
} finally {
setOperationLoading('');
}
},
[daemonShowTray, tauriAvailable]
);
const buildTunnelConfig = (): TunnelConfig => {
if (tunnelProvider === 'cloudflare') {
return { provider: 'cloudflare', cloudflare: { token: cloudflareToken } };
@@ -654,6 +697,10 @@ const TauriCommandsPanel = () => {
return true; // Always show all sections
};
useEffect(() => {
void loadDaemonHostConfig();
}, [loadDaemonHostConfig]);
const isCollapsed = (sectionId: string) => {
return !expandedSections.has(sectionId);
};
@@ -1090,6 +1137,33 @@ const TauriCommandsPanel = () => {
</label>
</div>
{/* Tray Toggle */}
<div className="flex items-center justify-between p-3 rounded-lg bg-stone-800/40 border border-stone-700/60">
<div>
<div className="text-sm font-medium text-gray-300">Show Daemon Tray</div>
<div className="text-xs text-gray-500">
Keep OpenHuman Core tray icon visible in daemon host mode
</div>
<div className="text-xs text-amber-400 mt-1">
Requires daemon host restart to fully apply
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={daemonShowTray}
disabled={
!daemonShowTrayLoaded || operationLoading === 'saveDaemonHostConfig'
}
onChange={event => {
void saveDaemonHostTraySetting(event.target.checked);
}}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 disabled:opacity-60 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
{/* Connection Info */}
{daemonHealth.connectionAttempts > 0 && (
<div className="p-3 rounded-lg bg-yellow-900/20 border border-yellow-500/30">
+3 -1
View File
@@ -142,7 +142,9 @@ const Home = () => {
</div>
<div className="mt-2 flex items-center justify-between text-xs">
<span className="text-stone-300">{localAiStatus?.model_id ?? 'qwen3-1.7b'}</span>
<span className="text-stone-300">
{localAiStatus?.model_id ?? 'gemma3:4b-it-qat'}
</span>
<span className="text-stone-200 capitalize">
{localAiStatus?.state ?? 'starting'}
</span>
+20
View File
@@ -427,6 +427,10 @@ export interface AgentServerStatus {
url: string;
}
export interface DaemonHostConfig {
show_tray: boolean;
}
export type AccessibilityPermissionState = 'granted' | 'denied' | 'unknown' | 'unsupported';
export type AccessibilityPermissionKind = 'screen_recording' | 'accessibility' | 'input_monitoring';
@@ -1187,6 +1191,22 @@ export async function openhumanAgentServerStatus(): Promise<CommandResponse<Agen
return await invoke('openhuman_agent_server_status');
}
export async function openhumanGetDaemonHostConfig(): Promise<CommandResponse<DaemonHostConfig>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('openhuman_get_daemon_host_config');
}
export async function openhumanSetDaemonHostConfig(
showTray?: boolean
): Promise<CommandResponse<DaemonHostConfig>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('openhuman_set_daemon_host_config', { showTray });
}
export async function openhumanAccessibilityStatus(): Promise<
CommandResponse<AccessibilityStatus>
> {