mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix: service gate buttons unclickable on startup (#139)
* refactor: enhance service command handling and improve service state checks - Updated the ServiceBlockingGate component to include additional checks for service installation status, ensuring it accounts for 'Unknown' states. - Refactored tauriCommands to implement direct service command invocations with error handling, allowing fallback to CLI parsing for service operations (install, start, stop, status, uninstall). - Introduced a new utility function to parse CLI JSON output into the expected CommandResponse format, improving robustness in service command responses. - Added new Tauri commands for direct service interactions, enhancing the application's ability to manage service states effectively. * docs: add debug logging guidelines to CLAUDE.md and enhance ServiceBlockingGate component - Introduced a new section in CLAUDE.md outlining best practices for debug logging, emphasizing verbose diagnostics, critical checkpoints, structured context, and safety measures. - Updated the ServiceBlockingGate component to improve operation handling by adding an operating label state, enhancing user feedback during service operations, and refining error handling and logging for better traceability. * style: add pointer-events none to CSS for improved interaction handling - Updated index.css to include pointer-events: none; for specific elements, enhancing user experience by preventing unintended interactions. * style: improve text formatting and readability in ServiceBlockingGate and tauriCommands - Refactored text in the ServiceBlockingGate component for better readability by consolidating lines. - Enhanced the formatting of the openhumanServiceStart function in tauriCommands for improved clarity in the method call structure.
This commit is contained in:
@@ -300,6 +300,17 @@ In the parent **OpenHuman** desktop app, **Tauri / Rust is a delivery vehicle**:
|
||||
|
||||
---
|
||||
|
||||
## Debug logging rule (must follow)
|
||||
|
||||
- **Default to verbose diagnostics on new/changed flows**: Add substantial, development-oriented logs while implementing features or fixes so issues are easy to trace end-to-end.
|
||||
- **Log critical checkpoints**: Include logs at entry/exit points, branch decisions, external calls, retries/timeouts, state transitions, and error handling paths.
|
||||
- **Use structured, grep-friendly context**: Prefer stable prefixes (for example `[domain]`, `[rpc]`, `[ui-flow]`) and include correlation fields such as request IDs, method names, and entity IDs when available.
|
||||
- **Platform conventions**: In Rust, use `log` / `tracing` at `debug` or `trace`; in `app/`, use namespaced `debug` logs and dev-only detail as needed.
|
||||
- **Keep logs safe**: Never log secrets or sensitive payloads (API keys, JWTs, credentials, full PII). Redact or omit sensitive fields.
|
||||
- **Treat debuggability as a deliverable**: Changes lacking sufficient logging for diagnosis are incomplete and should be updated before handoff.
|
||||
|
||||
---
|
||||
|
||||
## Feature design workflow (new capabilities)
|
||||
|
||||
Follow this order so behavior is **specified**, **proven in Rust**, **proven over RPC**, then **surfaced in the UI** with matching tests.
|
||||
|
||||
@@ -18,6 +18,84 @@ fn core_rpc_url() -> String {
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".to_string())
|
||||
}
|
||||
|
||||
/// Resolve the core binary, preferring the staged sidecar.
|
||||
fn resolve_core_bin() -> Result<std::path::PathBuf, String> {
|
||||
if let Some(bin) = core_process::default_core_bin() {
|
||||
return Ok(bin);
|
||||
}
|
||||
std::env::current_exe().map_err(|e| format!("cannot resolve executable: {e}"))
|
||||
}
|
||||
|
||||
/// Run the core binary with the given CLI args and return its stdout.
|
||||
async fn run_core_cli(args: Vec<String>) -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let bin = resolve_core_bin()?;
|
||||
let is_self = {
|
||||
let current = std::env::current_exe().ok();
|
||||
current
|
||||
.as_ref()
|
||||
.and_then(|c| std::fs::canonicalize(c).ok())
|
||||
.zip(std::fs::canonicalize(&bin).ok())
|
||||
.map_or(false, |(a, b)| a == b)
|
||||
};
|
||||
|
||||
let mut cmd = std::process::Command::new(&bin);
|
||||
if is_self {
|
||||
cmd.arg("core");
|
||||
}
|
||||
cmd.args(&args);
|
||||
|
||||
log::info!(
|
||||
"[service-direct] running {:?} {}{}",
|
||||
bin,
|
||||
if is_self { "core " } else { "" },
|
||||
args.join(" ")
|
||||
);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| format!("failed to execute core binary: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"core binary exited with {}: {}",
|
||||
output.status,
|
||||
stderr.trim()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("task join error: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn service_install_direct() -> Result<String, String> {
|
||||
run_core_cli(vec!["service".into(), "install".into()]).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn service_start_direct() -> Result<String, String> {
|
||||
run_core_cli(vec!["service".into(), "start".into()]).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn service_stop_direct() -> Result<String, String> {
|
||||
run_core_cli(vec!["service".into(), "stop".into()]).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn service_status_direct() -> Result<String, String> {
|
||||
run_core_cli(vec!["service".into(), "status".into()]).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn service_uninstall_direct() -> Result<String, String> {
|
||||
run_core_cli(vec!["service".into(), "uninstall".into()]).await
|
||||
}
|
||||
|
||||
fn is_daemon_mode() -> bool {
|
||||
std::env::args().any(|arg| arg == "daemon" || arg == "--daemon")
|
||||
}
|
||||
@@ -138,7 +216,14 @@ pub fn run() {
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![core_rpc_url])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
core_rpc_url,
|
||||
service_install_direct,
|
||||
service_start_direct,
|
||||
service_stop_direct,
|
||||
service_status_direct,
|
||||
service_uninstall_direct
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(move |app_handle, event| match event {
|
||||
|
||||
@@ -34,6 +34,7 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
|
||||
const [serviceStateText, setServiceStateText] = useState('Unknown');
|
||||
const [agentRunning, setAgentRunning] = useState(false);
|
||||
const [isOperating, setIsOperating] = useState(false);
|
||||
const [operatingLabel, setOperatingLabel] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshStatus = useCallback(async (options: RefreshOptions = {}) => {
|
||||
@@ -124,27 +125,31 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
|
||||
};
|
||||
}, [refreshStatus]);
|
||||
|
||||
const installed = useMemo(() => serviceStateText !== 'NotInstalled', [serviceStateText]);
|
||||
const installed = useMemo(
|
||||
() => serviceStateText !== 'NotInstalled' && !serviceStateText.startsWith('Unknown'),
|
||||
[serviceStateText]
|
||||
);
|
||||
const serviceRunning = useMemo(() => serviceStateText === 'Running', [serviceStateText]);
|
||||
|
||||
const runOperation = useCallback(
|
||||
async (op: () => Promise<unknown>) => {
|
||||
const opName = op.name || 'anonymous-operation';
|
||||
console.info('[ServiceBlockingGate] Running operation', { operation: opName });
|
||||
async (label: string, op: () => Promise<unknown>) => {
|
||||
console.info('[ServiceBlockingGate] Running operation', { operation: label });
|
||||
setIsOperating(true);
|
||||
setOperatingLabel(label);
|
||||
setError(null);
|
||||
try {
|
||||
await op();
|
||||
console.info('[ServiceBlockingGate] Operation completed', { operation: opName });
|
||||
console.info('[ServiceBlockingGate] Operation completed', { operation: label });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
console.error('[ServiceBlockingGate] Operation failed', {
|
||||
operation: opName,
|
||||
operation: label,
|
||||
error: message,
|
||||
});
|
||||
} finally {
|
||||
setIsOperating(false);
|
||||
setOperatingLabel(null);
|
||||
await refreshStatus();
|
||||
}
|
||||
},
|
||||
@@ -161,12 +166,19 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Stop/Restart/Uninstall require the service to be installed.
|
||||
// Install and Start are always available as recovery actions (never disabled).
|
||||
const canStop = !isOperating && installed && serviceRunning;
|
||||
const canRestart = !isOperating && installed;
|
||||
const canUninstall = !isOperating && installed;
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-[#0a0d12] text-white px-6">
|
||||
<div className="w-full max-w-xl rounded-2xl border border-white/15 bg-black/30 p-6 space-y-4">
|
||||
<h1 className="text-xl font-semibold">OpenHuman Service Required</h1>
|
||||
<p className="text-sm text-white/70">
|
||||
The desktop service must be installed and running before the app can continue.
|
||||
The desktop service must be installed and running before the app can continue. Use the
|
||||
buttons below to set up or restart the service.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
@@ -180,7 +192,28 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
{isOperating ? (
|
||||
<div className="rounded-lg border border-blue-500/40 bg-blue-900/20 p-3 text-sm text-blue-300 flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
{operatingLabel ? `${operatingLabel}...` : 'Working...'}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error && !isOperating ? (
|
||||
<div className="rounded-lg border border-red-500/40 bg-red-900/20 p-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
@@ -188,44 +221,75 @@ const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
disabled={isOperating || installed}
|
||||
onClick={() => void runOperation(() => openhumanServiceInstall())}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-400">
|
||||
Install Service
|
||||
onClick={() => {
|
||||
console.warn('[ServiceGate] INSTALL clicked', { isOperating, serviceStateText });
|
||||
if (isOperating) return;
|
||||
void runOperation('Installing service', () => openhumanServiceInstall());
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-blue-600 hover:bg-blue-500 text-white cursor-pointer">
|
||||
{isOperating && operatingLabel === 'Installing service'
|
||||
? 'Installing...'
|
||||
: 'Install Service'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isOperating || !installed || serviceRunning}
|
||||
onClick={() => void runOperation(() => openhumanServiceStart())}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-green-600 hover:bg-green-700 disabled:bg-gray-700 disabled:text-gray-400">
|
||||
Start Service
|
||||
onClick={() => {
|
||||
console.warn('[ServiceGate] START clicked', { isOperating, serviceStateText });
|
||||
if (isOperating) return;
|
||||
void runOperation('Starting service', () => openhumanServiceStart());
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-green-600 hover:bg-green-500 text-white cursor-pointer">
|
||||
{isOperating && operatingLabel === 'Starting service' ? 'Starting...' : 'Start Service'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isOperating || !installed || !serviceRunning}
|
||||
onClick={() => void runOperation(() => openhumanServiceStop())}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-red-600 hover:bg-red-700 disabled:bg-gray-700 disabled:text-gray-400">
|
||||
disabled={!canStop}
|
||||
onClick={() => {
|
||||
console.warn('[ServiceGate] STOP clicked', { isOperating, canStop });
|
||||
void runOperation('Stopping service', () => openhumanServiceStop());
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
canStop
|
||||
? 'bg-red-600 hover:bg-red-500 text-white cursor-pointer'
|
||||
: 'bg-white/5 text-white/30 cursor-not-allowed'
|
||||
}`}>
|
||||
Stop Service
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isOperating || !installed}
|
||||
onClick={() => void runOperation(restartService)}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-cyan-700 hover:bg-cyan-800 disabled:bg-gray-700 disabled:text-gray-400">
|
||||
disabled={!canRestart}
|
||||
onClick={() => {
|
||||
console.warn('[ServiceGate] RESTART clicked', { isOperating, canRestart });
|
||||
void runOperation('Restarting service', restartService);
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
canRestart
|
||||
? 'bg-cyan-700 hover:bg-cyan-600 text-white cursor-pointer'
|
||||
: 'bg-white/5 text-white/30 cursor-not-allowed'
|
||||
}`}>
|
||||
Restart Service
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isOperating || !installed}
|
||||
onClick={() => void runOperation(() => openhumanServiceUninstall())}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-amber-700 hover:bg-amber-800 disabled:bg-gray-700 disabled:text-gray-400">
|
||||
disabled={!canUninstall}
|
||||
onClick={() => {
|
||||
console.warn('[ServiceGate] UNINSTALL clicked', { isOperating, canUninstall });
|
||||
void runOperation('Uninstalling service', () => openhumanServiceUninstall());
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
canUninstall
|
||||
? 'bg-amber-700 hover:bg-amber-600 text-white cursor-pointer'
|
||||
: 'bg-white/5 text-white/30 cursor-not-allowed'
|
||||
}`}>
|
||||
Uninstall Service
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isOperating}
|
||||
onClick={() => void refreshStatus({ showChecking: true, clearError: true })}
|
||||
className="px-3 py-2 rounded-lg text-sm bg-gray-700 hover:bg-gray-600 disabled:bg-gray-700/60 disabled:text-gray-400">
|
||||
onClick={() => {
|
||||
console.warn('[ServiceGate] REFRESH clicked');
|
||||
void refreshStatus({ showChecking: true, clearError: true });
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium transition-colors bg-white/10 hover:bg-white/20 text-white cursor-pointer">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
background-repeat: repeat;
|
||||
background-position: top right;
|
||||
background-size: 510px auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1515,41 +1515,78 @@ export async function openhumanHardwareIntrospect(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CLI JSON output from a direct service command into CommandResponse shape.
|
||||
*/
|
||||
function parseServiceCliOutput(raw: string): CommandResponse<ServiceStatus> {
|
||||
const parsed = JSON.parse(raw) as CommandResponse<ServiceStatus>;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function openhumanServiceInstall(): Promise<CommandResponse<ServiceStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_install' });
|
||||
try {
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({
|
||||
method: 'openhuman.service_install',
|
||||
});
|
||||
} catch {
|
||||
const raw = await invoke<string>('service_install_direct');
|
||||
return parseServiceCliOutput(raw);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openhumanServiceStart(): Promise<CommandResponse<ServiceStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_start' });
|
||||
try {
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_start' });
|
||||
} catch {
|
||||
const raw = await invoke<string>('service_start_direct');
|
||||
return parseServiceCliOutput(raw);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openhumanServiceStop(): Promise<CommandResponse<ServiceStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_stop' });
|
||||
try {
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_stop' });
|
||||
} catch {
|
||||
const raw = await invoke<string>('service_stop_direct');
|
||||
return parseServiceCliOutput(raw);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openhumanServiceStatus(): Promise<CommandResponse<ServiceStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({ method: 'openhuman.service_status' });
|
||||
try {
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({
|
||||
method: 'openhuman.service_status',
|
||||
});
|
||||
} catch {
|
||||
const raw = await invoke<string>('service_status_direct');
|
||||
return parseServiceCliOutput(raw);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openhumanServiceUninstall(): Promise<CommandResponse<ServiceStatus>> {
|
||||
if (!isTauri()) {
|
||||
throw new Error('Not running in Tauri');
|
||||
}
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({
|
||||
method: 'openhuman.service_uninstall',
|
||||
});
|
||||
try {
|
||||
return await callCoreRpc<CommandResponse<ServiceStatus>>({
|
||||
method: 'openhuman.service_uninstall',
|
||||
});
|
||||
} catch {
|
||||
const raw = await invoke<string>('service_uninstall_direct');
|
||||
return parseServiceCliOutput(raw);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openhumanAgentServerStatus(): Promise<CommandResponse<AgentServerStatus>> {
|
||||
|
||||
Reference in New Issue
Block a user