diff --git a/.prettierignore b/.prettierignore index 13cc3e2a98..a4d17617db 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,7 @@ node_modules dist coverage +app src-tauri rust-core skills diff --git a/Cargo.lock b/Cargo.lock index b487bf3daf..abc1d34098 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/app/.gitignore @@ -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? diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000000..102e366893 --- /dev/null +++ b/app/README.md @@ -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) diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000000..ff93803bbc --- /dev/null +++ b/app/index.html @@ -0,0 +1,14 @@ + + + + + + + Tauri + React + Typescript + + + +
+ + + diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000000..2af9184d2e --- /dev/null +++ b/app/package.json @@ -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" + } +} diff --git a/app/public/tauri.svg b/app/public/tauri.svg new file mode 100644 index 0000000000..31b62c9280 --- /dev/null +++ b/app/public/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/public/vite.svg b/app/public/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/app/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src-tauri/.gitignore b/app/src-tauri/.gitignore new file mode 100644 index 0000000000..b21bd681d9 --- /dev/null +++ b/app/src-tauri/.gitignore @@ -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 diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml new file mode 100644 index 0000000000..0eca656f7b --- /dev/null +++ b/app/src-tauri/Cargo.toml @@ -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" + diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs new file mode 100644 index 0000000000..d860e1e6a7 --- /dev/null +++ b/app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json new file mode 100644 index 0000000000..4cdbf49a76 --- /dev/null +++ b/app/src-tauri/capabilities/default.json @@ -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" + ] +} diff --git a/app/src-tauri/icons/128x128.png b/app/src-tauri/icons/128x128.png new file mode 100644 index 0000000000..6be5e50e9b Binary files /dev/null and b/app/src-tauri/icons/128x128.png differ diff --git a/app/src-tauri/icons/128x128@2x.png b/app/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000000..e81becee57 Binary files /dev/null and b/app/src-tauri/icons/128x128@2x.png differ diff --git a/app/src-tauri/icons/32x32.png b/app/src-tauri/icons/32x32.png new file mode 100644 index 0000000000..a437dd5174 Binary files /dev/null and b/app/src-tauri/icons/32x32.png differ diff --git a/app/src-tauri/icons/Square107x107Logo.png b/app/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 0000000000..0ca4f27198 Binary files /dev/null and b/app/src-tauri/icons/Square107x107Logo.png differ diff --git a/app/src-tauri/icons/Square142x142Logo.png b/app/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 0000000000..b81f820394 Binary files /dev/null and b/app/src-tauri/icons/Square142x142Logo.png differ diff --git a/app/src-tauri/icons/Square150x150Logo.png b/app/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 0000000000..624c7bfba0 Binary files /dev/null and b/app/src-tauri/icons/Square150x150Logo.png differ diff --git a/app/src-tauri/icons/Square284x284Logo.png b/app/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 0000000000..c021d2ba76 Binary files /dev/null and b/app/src-tauri/icons/Square284x284Logo.png differ diff --git a/app/src-tauri/icons/Square30x30Logo.png b/app/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 0000000000..6219700230 Binary files /dev/null and b/app/src-tauri/icons/Square30x30Logo.png differ diff --git a/app/src-tauri/icons/Square310x310Logo.png b/app/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 0000000000..f9bc048394 Binary files /dev/null and b/app/src-tauri/icons/Square310x310Logo.png differ diff --git a/app/src-tauri/icons/Square44x44Logo.png b/app/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 0000000000..d5fbfb2ab4 Binary files /dev/null and b/app/src-tauri/icons/Square44x44Logo.png differ diff --git a/app/src-tauri/icons/Square71x71Logo.png b/app/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 0000000000..63440d7984 Binary files /dev/null and b/app/src-tauri/icons/Square71x71Logo.png differ diff --git a/app/src-tauri/icons/Square89x89Logo.png b/app/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 0000000000..f3f705af2f Binary files /dev/null and b/app/src-tauri/icons/Square89x89Logo.png differ diff --git a/app/src-tauri/icons/StoreLogo.png b/app/src-tauri/icons/StoreLogo.png new file mode 100644 index 0000000000..4556388261 Binary files /dev/null and b/app/src-tauri/icons/StoreLogo.png differ diff --git a/app/src-tauri/icons/icon.icns b/app/src-tauri/icons/icon.icns new file mode 100644 index 0000000000..12a5bcee26 Binary files /dev/null and b/app/src-tauri/icons/icon.icns differ diff --git a/app/src-tauri/icons/icon.ico b/app/src-tauri/icons/icon.ico new file mode 100644 index 0000000000..b3636e4b22 Binary files /dev/null and b/app/src-tauri/icons/icon.ico differ diff --git a/app/src-tauri/icons/icon.png b/app/src-tauri/icons/icon.png new file mode 100644 index 0000000000..e1cd2619e0 Binary files /dev/null and b/app/src-tauri/icons/icon.png differ diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs new file mode 100644 index 0000000000..4a277ef350 --- /dev/null +++ b/app/src-tauri/src/lib.rs @@ -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"); +} diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs new file mode 100644 index 0000000000..212d0a3762 --- /dev/null +++ b/app/src-tauri/src/main.rs @@ -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() +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json new file mode 100644 index 0000000000..3d6a5d24d7 --- /dev/null +++ b/app/src-tauri/tauri.conf.json @@ -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" + ] + } +} diff --git a/app/src/App.css b/app/src/App.css new file mode 100644 index 0000000000..85f7a4a1c8 --- /dev/null +++ b/app/src/App.css @@ -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; + } +} diff --git a/app/src/App.tsx b/app/src/App.tsx new file mode 100644 index 0000000000..8286a76ec4 --- /dev/null +++ b/app/src/App.tsx @@ -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 ( +
+

Welcome to Tauri + React

+ +
+ + Vite logo + + + Tauri logo + + + React logo + +
+

Click on the Tauri, Vite, and React logos to learn more.

+ +
{ + e.preventDefault(); + greet(); + }} + > + setName(e.currentTarget.value)} + placeholder="Enter a name..." + /> + +
+

{greetMsg}

+
+ ); +} + +export default App; diff --git a/app/src/assets/react.svg b/app/src/assets/react.svg new file mode 100644 index 0000000000..6c87de9bb3 --- /dev/null +++ b/app/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/main.tsx b/app/src/main.tsx new file mode 100644 index 0000000000..2be325ed25 --- /dev/null +++ b/app/src/main.tsx @@ -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( + + + , +); diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/app/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/app/tsconfig.json b/app/tsconfig.json new file mode 100644 index 0000000000..a7fc6fbf23 --- /dev/null +++ b/app/tsconfig.json @@ -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" }] +} diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json new file mode 100644 index 0000000000..42872c59f5 --- /dev/null +++ b/app/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/app/vite.config.ts b/app/vite.config.ts new file mode 100644 index 0000000000..ddad22a3e2 --- /dev/null +++ b/app/vite.config.ts @@ -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/**"], + }, + }, +})); diff --git a/eslint.config.js b/eslint.config.js index 5d8e51a76f..aa37bca0e0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,6 +26,7 @@ export default [ '**/target/**', 'dist/**', 'coverage/**', + 'app/**', 'src-tauri/**', 'rust-core/**', 'skills/**', diff --git a/rust-core/Cargo.toml b/rust-core/Cargo.toml index d71feadff5..26c848b06c 100644 --- a/rust-core/Cargo.toml +++ b/rust-core/Cargo.toml @@ -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 } diff --git a/rust-core/src/bin/openhuman.rs b/rust-core/src/bin/openhuman.rs index 32bd30b970..06ceda722f 100644 --- a/rust-core/src/bin/openhuman.rs +++ b/rust-core/src/bin/openhuman.rs @@ -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, - }, - /// 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, - #[arg(long, default_value_t = false)] - force: bool, - }, - /// Migrate OpenClaw memory - MigrateOpenclaw { - #[arg(long)] - source_workspace: Option, - #[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, - #[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, - #[arg(long)] - model: Option, - #[arg(long)] - temperature: Option, -} - -fn parse_json_arg(raw: &str) -> Result { - 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 { - openhuman_core::core_server::call_method(method, params).await -} - -async fn execute(cli: Cli) -> Result { - 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(¶ms)?).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 = std::env::args().skip(1).collect(); + if let Err(err) = openhuman_core::run_core_from_args(&args) { + eprintln!("{err}"); + std::process::exit(1); } } diff --git a/rust-core/src/core_server.rs b/rust-core/src/core_server.rs index 3fd4291a57..b813c9da79 100644 --- a/rust-core/src/core_server.rs +++ b/rust-core/src/core_server.rs @@ -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) -> Result<()> { Ok(()) } -pub fn run_from_cli_args(args: &[String]) -> Result<()> { - let mut port: Option = None; - - 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::()?); - idx += 1; - } - _ => {} +#[derive(Debug, Parser)] +#[command(name = "openhuman-core")] +#[command(about = "OpenHuman core CLI")] +#[command(arg_required_else_help = true)] +struct CoreCli { + #[command(subcommand)] + command: CoreCommand, +} + +#[derive(Debug, Subcommand)] +enum CoreCommand { + /// Run JSON-RPC server + #[command(alias = "serve")] + Run { + #[arg(long)] + port: Option, + }, + /// 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, + #[arg(long)] + api_url: Option, + #[arg(long)] + provider: Option, + #[arg(long)] + model: Option, + #[arg(long)] + temperature: Option, +} + +#[derive(Debug, Subcommand)] +enum MemorySettingsCommand { + Get, + Set(MemorySetArgs), +} + +#[derive(Debug, Args)] +struct MemorySetArgs { + #[arg(long)] + backend: Option, + #[arg(long)] + auto_save: Option, + #[arg(long)] + embedding_provider: Option, + #[arg(long)] + embedding_model: Option, + #[arg(long)] + embedding_dimensions: Option, +} + +#[derive(Debug, Subcommand)] +enum GatewaySettingsCommand { + Get, + Set(GatewaySetArgs), +} + +#[derive(Debug, Args)] +struct GatewaySetArgs { + #[arg(long)] + host: Option, + #[arg(long)] + port: Option, + #[arg(long)] + require_pairing: Option, + #[arg(long)] + allow_public_bind: Option, +} + +#[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, + #[arg(long)] + reasoning_enabled: Option, +} + +#[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, + /// Optional override for screen monitoring + #[arg(long)] + screen_monitoring: Option, + /// Optional override for device control + #[arg(long)] + device_control: Option, + /// Optional override for predictive input + #[arg(long)] + predictive_input: Option, +} + +#[derive(Debug, Args)] +struct StopSessionCliArgs { + #[arg(long)] + reason: Option, +} + +#[derive(Debug, Args)] +struct VisionRecentCliArgs { + #[arg(long)] + limit: Option, +} + +#[derive(Debug, Args)] +struct BrowserSetArgs { + #[arg(long)] + enabled: Option, +} + +#[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, + /// Optional region for macOS: selection | window + #[arg(long)] + region: Option, + /// Optional output file path (copies or writes PNG to this path) + #[arg(long)] + output: Option, + /// 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, + /// 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::from_str(raw).map_err(|e| format!("invalid JSON for --json/--params: {e}")) +} + +fn ensure_non_empty_payload(payload: &serde_json::Map) -> Result<()> { + if payload.is_empty() { + return Err(anyhow::anyhow!("no fields provided for set operation")); + } + Ok(()) +} + +fn extract_data_url(raw: &str) -> Option { + 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 { + 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, 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}"))?; + } + } + std::fs::write(path, bytes).map_err(|e| format!("failed to write output file: {e}")) +} + +async fn execute_tools_screenshot(args: ToolsScreenshotArgs) -> Result { + 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:: }, + }, + "logs": logs + }); + + Ok(response) +} + +async fn execute_tools_screenshot_ref( + args: ToolsScreenshotRefArgs, +) -> Result { + let raw = call_method("openhuman.accessibility_capture_image_ref", json!({})).await?; + let payload: CommandResponse = 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()); } - idx += 1; } + 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:: }, + }, + "logs": logs + })) +} + +async fn get_config_snapshot() -> Result, String> { + let value = call_method("openhuman.get_config", json!({})).await?; + serde_json::from_value::>(value) + .map_err(|e| format!("failed to decode config snapshot: {e}")) +} + +fn settings_view_response( + section: &'static str, + snapshot: CommandResponse, +) -> CommandResponse { + 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 { + 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(¶ms)?).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 = 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 = 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::().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)] diff --git a/rust-core/src/openhuman/accessibility/mod.rs b/rust-core/src/openhuman/accessibility/mod.rs index 001c51997a..32fe03cc33 100644 --- a/rust-core/src/openhuman/accessibility/mod.rs +++ b/rust-core/src/openhuman/accessibility/mod.rs @@ -122,6 +122,15 @@ pub struct CaptureNowResult { pub frame: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CaptureImageRefResult { + pub ok: bool, + pub image_ref: Option, + pub mime_type: String, + pub bytes_estimate: Option, + 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, diff --git a/rust-core/src/openhuman/config/schema/local_ai.rs b/rust-core/src/openhuman/config/schema/local_ai.rs index 0fcb850626..b24116ef1c 100644 --- a/rust-core/src/openhuman/config/schema/local_ai.rs +++ b/rust-core/src/openhuman/config/schema/local_ai.rs @@ -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 { diff --git a/rust-core/src/openhuman/service/mod.rs b/rust-core/src/openhuman/service/mod.rs index 9beecefd37..c7e48fde68 100644 --- a/rust-core/src/openhuman/service/mod.rs +++ b/rust-core/src/openhuman/service/mod.rs @@ -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 { + 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 { 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 { 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 { 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 { .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 { .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 { pub fn status(config: &Config) -> Result { 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 { 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::(); let plist = format!( - r#" - - + r#" + + Label {label} @@ -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 { 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 { 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 { - 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) diff --git a/src-tauri/src/commands/openhuman.rs b/src-tauri/src/commands/openhuman.rs index 9cf5f40d20..19a3a41cea 100644 --- a/src-tauri/src/commands/openhuman.rs +++ b/src-tauri/src/commands/openhuman.rs @@ -76,6 +76,32 @@ async fn call_core( 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( + method: &str, + params: serde_json::Value, +) -> Result { + ensure_service_managed_core_running().await?; + crate::core_rpc::call(method, params).await +} + async fn load_config_local() -> Result { 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, 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, String> { - let response: CommandResponse = call_core( - &app, - "openhuman.accessibility_request_permissions", - params_none(), - ) - .await?; + let response: CommandResponse = + 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, String> { - let response: CommandResponse = call_core( - &app, + let response: CommandResponse = 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, String> { - let response: CommandResponse = call_core( - &app, + let response: CommandResponse = 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, ) -> Result, String> { - let response: CommandResponse = call_core( - &app, + let response: CommandResponse = 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, 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, String> { - let response: CommandResponse = call_core( - &app, + let response: CommandResponse = 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, ) -> Result, 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, 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, ) -> Result, 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, 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 Result, 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, +) -> Result, 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( diff --git a/src-tauri/src/core_process.rs b/src-tauri/src/core_process.rs index 8dd659b6f7..4885c1da58 100644 --- a/src-tauri/src/core_process.rs +++ b/src-tauri/src/core_process.rs @@ -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 { } // 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; diff --git a/src-tauri/src/daemon_host_config.rs b/src-tauri/src/daemon_host_config.rs new file mode 100644 index 0000000000..9b270cdc6b --- /dev/null +++ b/src-tauri/src/daemon_host_config.rs @@ -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::(&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}")) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42e0ebbe52..b85fd960a6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::() { - let core_handle: core_process::CoreProcessHandle = (*core).clone(); - tauri::async_runtime::spawn(async move { - core_handle.shutdown().await; - }); - } - let _ = app_handle; } diff --git a/src/components/daemon/DaemonHealthPanel.tsx b/src/components/daemon/DaemonHealthPanel.tsx index b85278f5e9..47cc8caa4f 100644 --- a/src/components/daemon/DaemonHealthPanel.tsx +++ b/src/components/daemon/DaemonHealthPanel.tsx @@ -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(null); + const [showTray, setShowTray] = useState(true); + const [trayLoaded, setTrayLoaded] = useState(false); // Handle agent operations with loading states const handleOperation = async (operation: () => Promise, 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 (
{/* Header */} @@ -186,6 +226,26 @@ const DaemonHealthPanel = ({ userId, onClose, className = '' }: Props) => {
+
+
+
Show Daemon Tray
+
Display tray icon in daemon host mode
+
Requires daemon host restart to apply
+
+ +
+ {/* Control Actions */}
+ {/* Tray Toggle */} +
+
+
Show Daemon Tray
+
+ Keep OpenHuman Core tray icon visible in daemon host mode +
+
+ Requires daemon host restart to fully apply +
+
+ +
+ {/* Connection Info */} {daemonHealth.connectionAttempts > 0 && (
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index d50868f3b0..3314e68ac6 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -142,7 +142,9 @@ const Home = () => {
- {localAiStatus?.model_id ?? 'qwen3-1.7b'} + + {localAiStatus?.model_id ?? 'gemma3:4b-it-qat'} + {localAiStatus?.state ?? 'starting'} diff --git a/src/utils/tauriCommands.ts b/src/utils/tauriCommands.ts index e096b9ff1b..cc2181f682 100644 --- a/src/utils/tauriCommands.ts +++ b/src/utils/tauriCommands.ts @@ -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> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await invoke('openhuman_get_daemon_host_config'); +} + +export async function openhumanSetDaemonHostConfig( + showTray?: boolean +): Promise> { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + return await invoke('openhuman_set_daemon_host_config', { showTray }); +} + export async function openhumanAccessibilityStatus(): Promise< CommandResponse > {