mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
* fix(macos): restart sidecar so permission grants show after System Settings Fixes #133 - Tauri: restart core after kill+wait; fail fast when port held by non-managed process - ACL: allow core_rpc_url and restart_core_process (IPC was blocked in Tauri 2) - Dev: default_core_bin falls through to release search when binaries/ empty - Core: expose permission_check_process_path on accessibility status - App: Restart & refresh UX, extractError for invoke, Vitest for RPC + slice - Stage script: optional dev codesign helper for stable TCC identity Made-with: Cursor * refactor(onboarding): update button text for clarity and enhance core process restart handling - Changed button text in ScreenPermissionsStep from 'Refresh Status' to 'Restart & Refresh Permissions' for better user understanding. - Introduced a restart lock in CoreProcessHandle to serialize overlapping restart requests, ensuring smoother core process management. - Updated restart_core_process function to acquire the restart lock before initiating a restart, improving reliability during the process. * fix: improve text clarity in AccessibilityPanel and ScreenIntelligencePanel - Adjusted text formatting in AccessibilityPanel for better readability. - Enhanced text clarity in ScreenIntelligencePanel regarding permission refresh instructions. - Standardized the formatting of permission_check_process_path in test files for consistency. --------- Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
a5788b88e3
commit
0dd4d1f60b
@@ -2,14 +2,21 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window (desktop only)",
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"windows": ["main"],
|
||||
"platforms": [
|
||||
"linux",
|
||||
"macOS",
|
||||
"windows"
|
||||
],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-unminimize",
|
||||
"deep-link:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"allow-core-process"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[[permission]]
|
||||
identifier = "allow-core-process"
|
||||
description = "Core RPC URL and sidecar restart"
|
||||
|
||||
[permission.commands]
|
||||
allow = [
|
||||
"core_rpc_url",
|
||||
"restart_core_process",
|
||||
]
|
||||
deny = []
|
||||
@@ -17,6 +17,7 @@ pub enum CoreRunMode {
|
||||
pub struct CoreProcessHandle {
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
restart_lock: Arc<Mutex<()>>,
|
||||
port: u16,
|
||||
core_bin: Option<PathBuf>,
|
||||
run_mode: CoreRunMode,
|
||||
@@ -27,6 +28,7 @@ impl CoreProcessHandle {
|
||||
Self {
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
task: Arc::new(Mutex::new(None)),
|
||||
restart_lock: Arc::new(Mutex::new(())),
|
||||
port,
|
||||
core_bin,
|
||||
run_mode,
|
||||
@@ -37,6 +39,11 @@ impl CoreProcessHandle {
|
||||
format!("http://127.0.0.1:{}/rpc", self.port)
|
||||
}
|
||||
|
||||
/// Acquire the restart lock to serialize overlapping restart requests.
|
||||
pub async fn restart_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
|
||||
self.restart_lock.lock().await
|
||||
}
|
||||
|
||||
async fn is_rpc_port_open(&self) -> bool {
|
||||
matches!(
|
||||
timeout(
|
||||
@@ -177,6 +184,76 @@ impl CoreProcessHandle {
|
||||
Err("core process did not become ready".to_string())
|
||||
}
|
||||
|
||||
/// Restart the core process to pick up updated macOS permission grants.
|
||||
///
|
||||
/// macOS caches permission state per-process; the running sidecar never sees
|
||||
/// a newly granted permission until it restarts. This method shuts down the
|
||||
/// current child, waits until the RPC port is free (so `ensure_running` does not
|
||||
/// fast-return while the old listener is still bound), then spawns a fresh instance.
|
||||
///
|
||||
/// If another process is listening on the core port (e.g. manual `openhuman core run`),
|
||||
/// shutdown does not stop it — we time out and return an error instead of a false success.
|
||||
///
|
||||
/// Issue: <https://github.com/tinyhumansai/openhuman/issues/133>
|
||||
pub async fn restart(&self) -> Result<(), String> {
|
||||
log::info!("[core] restarting core process for permission refresh");
|
||||
|
||||
let had_managed_child = {
|
||||
let guard = self.child.lock().await;
|
||||
guard.is_some()
|
||||
};
|
||||
log::debug!(
|
||||
"[core] restart: had_managed_child={} before shutdown",
|
||||
had_managed_child
|
||||
);
|
||||
|
||||
self.shutdown().await;
|
||||
log::debug!("[core] restart: shutdown complete, checking port {}", self.port);
|
||||
|
||||
// If we never spawned the sidecar (something else was already listening), we cannot free
|
||||
// the port — fail fast with a clear message instead of polling for 8s.
|
||||
if !had_managed_child && self.is_rpc_port_open().await {
|
||||
log::error!(
|
||||
"[core] restart: no child to stop but port {} is open — another process owns it",
|
||||
self.port
|
||||
);
|
||||
return Err(format!(
|
||||
"Core RPC port {} is already in use by another process (OpenHuman did not start it). Quit any `openhuman core run` in a terminal or free the port, then relaunch the app. You can also set OPENHUMAN_CORE_PORT to a different port.",
|
||||
self.port
|
||||
));
|
||||
}
|
||||
|
||||
// After kill+wait on our child, the port should close; poll briefly in case the OS is slow
|
||||
// to release the socket.
|
||||
const POLL_MS: u64 = 50;
|
||||
const MAX_WAIT_MS: u64 = 10_000;
|
||||
let mut waited_ms: u64 = 0;
|
||||
while self.is_rpc_port_open().await {
|
||||
if waited_ms >= MAX_WAIT_MS {
|
||||
log::error!(
|
||||
"[core] restart: port {} still in use after {}ms (had_managed_child={})",
|
||||
self.port,
|
||||
MAX_WAIT_MS,
|
||||
had_managed_child
|
||||
);
|
||||
return Err(format!(
|
||||
"Core RPC port {} did not become free after stopping the sidecar. Quit any other process using this port (e.g. `openhuman core run`) or change OPENHUMAN_CORE_PORT.",
|
||||
self.port
|
||||
));
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(POLL_MS)).await;
|
||||
waited_ms += POLL_MS;
|
||||
}
|
||||
|
||||
log::debug!("[core] restart: port free, calling ensure_running");
|
||||
let result = self.ensure_running().await;
|
||||
match &result {
|
||||
Ok(()) => log::info!("[core] restart: core process ready after restart"),
|
||||
Err(e) => log::error!("[core] restart: failed to restart core process: {e}"),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Stop the core process this handle spawned (child or in-process task). Safe to call if
|
||||
/// nothing was spawned or core was already external.
|
||||
pub async fn shutdown(&self) {
|
||||
@@ -186,6 +263,26 @@ impl CoreProcessHandle {
|
||||
if let Err(e) = child.kill().await {
|
||||
log::warn!("[core] failed to kill child core process: {e}");
|
||||
}
|
||||
// Wait for the process to exit so the RPC listen socket is released before restart
|
||||
// checks the port (otherwise we can spuriously hit "port still in use").
|
||||
match timeout(
|
||||
Duration::from_secs(12),
|
||||
child.wait(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(status)) => {
|
||||
log::debug!("[core] child core process reaped after kill: {status}");
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
log::warn!("[core] wait on child core process after kill: {e}");
|
||||
}
|
||||
Err(_) => {
|
||||
log::warn!(
|
||||
"[core] timed out waiting for child core process to exit after kill (12s)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut task_guard = self.task.lock().await;
|
||||
if let Some(task) = task_guard.take() {
|
||||
@@ -244,9 +341,12 @@ pub fn default_core_bin() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
// Dev ergonomics: allow an explicit staged sidecar from src-tauri/binaries in
|
||||
// debug builds before falling back to self-subcommand spawning.
|
||||
if cfg!(debug_assertions) {
|
||||
// Dev: prefer a staged sidecar under src-tauri/binaries, then use the same search as
|
||||
// release (next to the .app, Resources/, etc.). Previously we returned None here when the
|
||||
// folder was empty, which forced `core run` on the GUI binary — a different TCC identity than
|
||||
// `openhuman-core-*` and misleading "still denied" after granting the sidecar name.
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
let binaries_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("binaries");
|
||||
if let Ok(entries) = std::fs::read_dir(&binaries_dir) {
|
||||
for entry in entries.flatten() {
|
||||
@@ -267,8 +367,6 @@ pub fn default_core_bin() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
|
||||
@@ -96,6 +96,16 @@ async fn service_uninstall_direct() -> Result<String, String> {
|
||||
run_core_cli(vec!["service".into(), "uninstall".into()]).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restart_core_process(
|
||||
state: tauri::State<'_, core_process::CoreProcessHandle>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("[core] restart_core_process: command invoked from frontend");
|
||||
let _guard = state.inner().restart_lock().await;
|
||||
log::debug!("[core] restart_core_process: acquired restart lock");
|
||||
state.inner().restart().await
|
||||
}
|
||||
|
||||
fn is_daemon_mode() -> bool {
|
||||
std::env::args().any(|arg| arg == "daemon" || arg == "--daemon")
|
||||
}
|
||||
@@ -220,6 +230,7 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
core_rpc_url,
|
||||
restart_core_process,
|
||||
service_install_direct,
|
||||
service_start_direct,
|
||||
service_stop_direct,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
fetchAccessibilityStatus,
|
||||
fetchAccessibilityVisionRecent,
|
||||
flushAccessibilityVision,
|
||||
refreshPermissionsWithRestart,
|
||||
requestAccessibilityPermission,
|
||||
startAccessibilitySession,
|
||||
stopAccessibilitySession,
|
||||
@@ -50,6 +51,7 @@ const AccessibilityPanel = () => {
|
||||
status,
|
||||
isLoading,
|
||||
isRequestingPermissions,
|
||||
isRestartingCore,
|
||||
isStartingSession,
|
||||
isStoppingSession,
|
||||
isLoadingVision,
|
||||
@@ -82,6 +84,11 @@ const AccessibilityPanel = () => {
|
||||
void dispatch(fetchAccessibilityVisionRecent(10));
|
||||
}, [dispatch]);
|
||||
|
||||
const anyPermissionDenied =
|
||||
status?.permissions.screen_recording === 'denied' ||
|
||||
status?.permissions.accessibility === 'denied' ||
|
||||
status?.permissions.input_monitoring === 'denied';
|
||||
|
||||
const screenMonitoring =
|
||||
featureOverrides.screen_monitoring ?? status?.features.screen_monitoring ?? true;
|
||||
const deviceControl = featureOverrides.device_control ?? status?.features.device_control ?? true;
|
||||
@@ -125,34 +132,67 @@ const AccessibilityPanel = () => {
|
||||
value={status?.permissions.input_monitoring ?? 'unknown'}
|
||||
/>
|
||||
|
||||
{anyPermissionDenied && (
|
||||
<div className="rounded-xl border border-amber-700/40 bg-amber-900/20 p-3 text-sm text-amber-200 space-y-1">
|
||||
<p>
|
||||
After granting in System Settings, click “Restart & Refresh” below.
|
||||
</p>
|
||||
{status?.permission_check_process_path ? (
|
||||
<p className="opacity-75 text-xs">
|
||||
Enable the same app macOS lists for this path (TCC is per executable).{' '}
|
||||
<span className="font-mono break-all text-stone-300">
|
||||
{status.permission_check_process_path}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="opacity-75">
|
||||
Still stuck? Remove the old entry for this app in System Settings → Privacy, then
|
||||
click “Request” again. For dev, run{' '}
|
||||
<span className="font-mono text-xs">yarn core:stage</span> so the sidecar matches
|
||||
the staged binary name.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(requestAccessibilityPermission('screen_recording'))}
|
||||
disabled={isRequestingPermissions}
|
||||
disabled={isRequestingPermissions || isRestartingCore}
|
||||
className="mt-1 rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
|
||||
{isRequestingPermissions ? 'Requesting…' : 'Request Screen Recording'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(requestAccessibilityPermission('accessibility'))}
|
||||
disabled={isRequestingPermissions}
|
||||
disabled={isRequestingPermissions || isRestartingCore}
|
||||
className="rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
|
||||
{isRequestingPermissions ? 'Requesting…' : 'Request Accessibility'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(requestAccessibilityPermission('input_monitoring'))}
|
||||
disabled={isRequestingPermissions}
|
||||
disabled={isRequestingPermissions || isRestartingCore}
|
||||
className="rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
|
||||
{isRequestingPermissions ? 'Requesting…' : 'Open Input Monitoring'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(fetchAccessibilityStatus())}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg border border-stone-600 bg-stone-800/60 px-3 py-2 text-sm text-stone-200 disabled:opacity-50">
|
||||
{isLoading ? 'Refreshing…' : 'Refresh Status'}
|
||||
</button>
|
||||
|
||||
{anyPermissionDenied ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(refreshPermissionsWithRestart())}
|
||||
disabled={isRestartingCore || isLoading}
|
||||
className="rounded-lg border border-amber-500/60 bg-amber-500/20 px-3 py-2 text-sm text-amber-200 disabled:opacity-50">
|
||||
{isRestartingCore ? 'Restarting core…' : 'Restart & Refresh Permissions'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(fetchAccessibilityStatus())}
|
||||
disabled={isLoading || isRestartingCore}
|
||||
className="rounded-lg border border-stone-600 bg-stone-800/60 px-3 py-2 text-sm text-stone-200 disabled:opacity-50">
|
||||
{isLoading ? 'Refreshing…' : 'Refresh Status'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-stone-700 bg-black/30 p-4 space-y-3">
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
fetchAccessibilityStatus,
|
||||
fetchAccessibilityVisionRecent,
|
||||
flushAccessibilityVision,
|
||||
refreshPermissionsWithRestart,
|
||||
requestAccessibilityPermission,
|
||||
startAccessibilitySession,
|
||||
stopAccessibilitySession,
|
||||
@@ -52,6 +53,7 @@ const ScreenIntelligencePanel = () => {
|
||||
status,
|
||||
isLoading,
|
||||
isRequestingPermissions,
|
||||
isRestartingCore,
|
||||
isStartingSession,
|
||||
isStoppingSession,
|
||||
isLoadingVision,
|
||||
@@ -117,6 +119,11 @@ const ScreenIntelligencePanel = () => {
|
||||
[status?.session.remaining_ms]
|
||||
);
|
||||
|
||||
const anyPermissionDenied =
|
||||
status?.permissions.screen_recording === 'denied' ||
|
||||
status?.permissions.accessibility === 'denied' ||
|
||||
status?.permissions.input_monitoring === 'denied';
|
||||
|
||||
const startDisabled =
|
||||
isStartingSession ||
|
||||
isLoading ||
|
||||
@@ -172,34 +179,61 @@ const ScreenIntelligencePanel = () => {
|
||||
value={status?.permissions.input_monitoring ?? 'unknown'}
|
||||
/>
|
||||
|
||||
{anyPermissionDenied && (
|
||||
<div className="rounded-xl border border-amber-700/40 bg-amber-900/20 p-3 text-sm text-amber-200 space-y-1">
|
||||
<p>
|
||||
After granting in System Settings, click “Restart & Refresh
|
||||
Permissions” so a new core process picks up the grants.
|
||||
</p>
|
||||
{status?.permission_check_process_path ? (
|
||||
<p className="opacity-75 text-xs">
|
||||
macOS applies privacy to this executable:{' '}
|
||||
<span className="font-mono break-all text-stone-300">
|
||||
{status.permission_check_process_path}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(requestAccessibilityPermission('screen_recording'))}
|
||||
disabled={isRequestingPermissions}
|
||||
disabled={isRequestingPermissions || isRestartingCore}
|
||||
className="mt-1 rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
|
||||
{isRequestingPermissions ? 'Requesting…' : 'Request Screen Recording'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(requestAccessibilityPermission('accessibility'))}
|
||||
disabled={isRequestingPermissions}
|
||||
disabled={isRequestingPermissions || isRestartingCore}
|
||||
className="rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
|
||||
{isRequestingPermissions ? 'Requesting…' : 'Request Accessibility'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(requestAccessibilityPermission('input_monitoring'))}
|
||||
disabled={isRequestingPermissions}
|
||||
disabled={isRequestingPermissions || isRestartingCore}
|
||||
className="rounded-lg border border-primary-500/60 bg-primary-500/20 px-3 py-2 text-sm text-primary-200 disabled:opacity-50">
|
||||
{isRequestingPermissions ? 'Requesting…' : 'Open Input Monitoring'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(fetchAccessibilityStatus())}
|
||||
disabled={isLoading}
|
||||
className="rounded-lg border border-stone-600 bg-stone-800/60 px-3 py-2 text-sm text-stone-200 disabled:opacity-50">
|
||||
{isLoading ? 'Refreshing…' : 'Refresh Status'}
|
||||
</button>
|
||||
{anyPermissionDenied ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(refreshPermissionsWithRestart())}
|
||||
disabled={isRestartingCore || isLoading}
|
||||
className="rounded-lg border border-amber-500/60 bg-amber-500/20 px-3 py-2 text-sm text-amber-200 disabled:opacity-50">
|
||||
{isRestartingCore ? 'Restarting core…' : 'Restart & Refresh Permissions'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(fetchAccessibilityStatus())}
|
||||
disabled={isLoading || isRestartingCore}
|
||||
className="rounded-lg border border-stone-600 bg-stone-800/60 px-3 py-2 text-sm text-stone-200 disabled:opacity-50">
|
||||
{isLoading ? 'Refreshing…' : 'Refresh Status'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-stone-700 bg-black/30 p-4 space-y-3">
|
||||
|
||||
@@ -70,6 +70,7 @@ const createStore = () =>
|
||||
isCaptureTestRunning: false,
|
||||
isLoading: false,
|
||||
isRequestingPermissions: false,
|
||||
isRestartingCore: false,
|
||||
isStartingSession: false,
|
||||
isStoppingSession: false,
|
||||
isLoadingVision: false,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
fetchAccessibilityStatus,
|
||||
refreshPermissionsWithRestart,
|
||||
requestAccessibilityPermission,
|
||||
} from '../../../store/accessibilitySlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
@@ -15,9 +16,8 @@ interface ScreenPermissionsStepProps {
|
||||
const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsStepProps) => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { status, isLoading, isRequestingPermissions } = useAppSelector(
|
||||
state => state.accessibility
|
||||
);
|
||||
const { status, isLoading, isRequestingPermissions, isRestartingCore, lastError } =
|
||||
useAppSelector(state => state.accessibility);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchAccessibilityStatus());
|
||||
@@ -63,20 +63,39 @@ const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsSte
|
||||
</div>
|
||||
|
||||
{!isGranted ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(requestAccessibilityPermission('accessibility'))}
|
||||
disabled={isRequestingPermissions || isLoading}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-60">
|
||||
{isRequestingPermissions ? 'Requesting...' : 'Request Permissions'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings/accessibility')}
|
||||
className="w-full py-2.5 text-sm font-medium rounded-xl border border-stone-600 hover:border-stone-500 transition-colors">
|
||||
Open Accessibility
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void dispatch(requestAccessibilityPermission('accessibility'))}
|
||||
disabled={isRequestingPermissions || isLoading}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-60">
|
||||
{isRequestingPermissions ? 'Requesting...' : 'Request Permissions'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/settings/accessibility')}
|
||||
className="w-full py-2.5 text-sm font-medium rounded-xl border border-stone-600 hover:border-stone-500 transition-colors">
|
||||
Open Accessibility
|
||||
onClick={() => void dispatch(refreshPermissionsWithRestart())}
|
||||
disabled={isRestartingCore || isLoading}
|
||||
className="w-full py-2 text-sm font-medium rounded-xl border border-stone-700 hover:border-stone-500 opacity-70 hover:opacity-100 transition-all disabled:opacity-40">
|
||||
{isRestartingCore ? 'Restarting core...' : 'Restart & Refresh Permissions'}
|
||||
</button>
|
||||
{(lastError || status?.permission_check_process_path) && (
|
||||
<div className="text-xs text-stone-400 text-center px-2 space-y-1">
|
||||
{lastError ? <p className="text-coral-400">{lastError}</p> : null}
|
||||
{status?.permission_check_process_path ? (
|
||||
<p className="font-mono break-all text-stone-500">
|
||||
Grant access for: {status.permission_check_process_path}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -1,8 +1,56 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { dispatchLocalAiMethod } from '../../lib/ai/localCoreAiMemory';
|
||||
import type { AccessibilityStatus, CommandResponse } from '../../utils/tauriCommands';
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
function sampleAccessibilityStatus(
|
||||
overrides: Partial<AccessibilityStatus> = {}
|
||||
): AccessibilityStatus {
|
||||
return {
|
||||
platform_supported: true,
|
||||
permissions: {
|
||||
screen_recording: 'denied',
|
||||
accessibility: 'granted',
|
||||
input_monitoring: 'unknown',
|
||||
},
|
||||
features: { screen_monitoring: true, device_control: true, predictive_input: true },
|
||||
session: {
|
||||
active: false,
|
||||
started_at_ms: null,
|
||||
expires_at_ms: null,
|
||||
remaining_ms: null,
|
||||
ttl_secs: 300,
|
||||
panic_hotkey: 'Cmd+Shift+.',
|
||||
stop_reason: null,
|
||||
frames_in_memory: 0,
|
||||
last_capture_at_ms: null,
|
||||
last_context: null,
|
||||
vision_enabled: true,
|
||||
vision_state: 'idle',
|
||||
vision_queue_depth: 0,
|
||||
last_vision_at_ms: null,
|
||||
last_vision_summary: null,
|
||||
},
|
||||
config: {
|
||||
enabled: true,
|
||||
capture_policy: 'hybrid',
|
||||
policy_mode: 'all_except_blacklist',
|
||||
baseline_fps: 1,
|
||||
vision_enabled: true,
|
||||
session_ttl_secs: 300,
|
||||
panic_stop_hotkey: 'Cmd+Shift+.',
|
||||
autocomplete_enabled: true,
|
||||
allowlist: [],
|
||||
denylist: [],
|
||||
},
|
||||
denylist: [],
|
||||
is_context_blocked: false,
|
||||
permission_check_process_path: '/tmp/openhuman-core-aarch64-apple-darwin',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn(() => false) }));
|
||||
vi.mock('../../lib/ai/localCoreAiMemory', () => ({
|
||||
dispatchLocalAiMethod: vi.fn(async (_method: string) => ({ source: 'local-ai' })),
|
||||
@@ -43,6 +91,35 @@ describe('coreRpcClient', () => {
|
||||
expect(body.method).toBe('openhuman.screen_intelligence_status');
|
||||
});
|
||||
|
||||
test('fetches accessibility_status CommandResponse with permissions and process path', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
const status = sampleAccessibilityStatus({
|
||||
permission_check_process_path:
|
||||
'/Users/dev/openhuman/app/src-tauri/binaries/openhuman-core-aarch64-apple-darwin',
|
||||
});
|
||||
const envelope: CommandResponse<AccessibilityStatus> = {
|
||||
result: status,
|
||||
logs: ['screen intelligence status fetched'],
|
||||
};
|
||||
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 99, result: envelope }),
|
||||
} as Response);
|
||||
|
||||
const out = await callCoreRpc<CommandResponse<AccessibilityStatus>>({
|
||||
method: 'openhuman.accessibility_status',
|
||||
});
|
||||
|
||||
expect(out.logs).toContain('screen intelligence status fetched');
|
||||
expect(out.result.permissions.screen_recording).toBe('denied');
|
||||
expect(out.result.permissions.accessibility).toBe('granted');
|
||||
expect(out.result.permissions.input_monitoring).toBe('unknown');
|
||||
expect(out.result.permission_check_process_path).toBe(
|
||||
'/Users/dev/openhuman/app/src-tauri/binaries/openhuman-core-aarch64-apple-darwin'
|
||||
);
|
||||
});
|
||||
|
||||
test('throws clean error when JSON-RPC error payload is returned', async () => {
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AccessibilityStatus, CaptureTestResult } from '../../utils/tauriCo
|
||||
import reducer, {
|
||||
clearAccessibilityError,
|
||||
fetchAccessibilityStatus,
|
||||
refreshPermissionsWithRestart,
|
||||
runCaptureTest,
|
||||
setAccessibilityStatus,
|
||||
startAccessibilitySession,
|
||||
@@ -49,6 +50,7 @@ const sampleStatus: AccessibilityStatus = {
|
||||
},
|
||||
denylist: ['wallet'],
|
||||
is_context_blocked: false,
|
||||
permission_check_process_path: '/test/app/src-tauri/binaries/openhuman-core-aarch64-apple-darwin',
|
||||
};
|
||||
|
||||
describe('accessibilitySlice', () => {
|
||||
@@ -77,6 +79,27 @@ describe('accessibilitySlice', () => {
|
||||
expect(fulfilled.status?.permissions.accessibility).toBe('granted');
|
||||
});
|
||||
|
||||
it('stores permission_check_process_path from fetched status', () => {
|
||||
const fulfilled = reducer(
|
||||
undefined,
|
||||
fetchAccessibilityStatus.fulfilled(sampleStatus, 'req-path', undefined)
|
||||
);
|
||||
expect(fulfilled.status?.permission_check_process_path).toBe(
|
||||
'/test/app/src-tauri/binaries/openhuman-core-aarch64-apple-darwin'
|
||||
);
|
||||
});
|
||||
|
||||
it('stores permission_check_process_path after refreshPermissionsWithRestart', () => {
|
||||
const fulfilled = reducer(
|
||||
undefined,
|
||||
refreshPermissionsWithRestart.fulfilled(sampleStatus, 'req-restart', undefined)
|
||||
);
|
||||
expect(fulfilled.isRestartingCore).toBe(false);
|
||||
expect(fulfilled.status?.permission_check_process_path).toBe(
|
||||
'/test/app/src-tauri/binaries/openhuman-core-aarch64-apple-darwin'
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks session start/stop async flags', () => {
|
||||
const starting = reducer(undefined, { type: startAccessibilitySession.pending.type });
|
||||
expect(starting.isStartingSession).toBe(true);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
openhumanAccessibilityVisionFlush,
|
||||
openhumanAccessibilityVisionRecent,
|
||||
openhumanScreenIntelligenceCaptureTest,
|
||||
restartCoreProcess,
|
||||
} from '../utils/tauriCommands';
|
||||
|
||||
interface AccessibilityState {
|
||||
@@ -25,6 +26,7 @@ interface AccessibilityState {
|
||||
isCaptureTestRunning: boolean;
|
||||
isLoading: boolean;
|
||||
isRequestingPermissions: boolean;
|
||||
isRestartingCore: boolean;
|
||||
isStartingSession: boolean;
|
||||
isStoppingSession: boolean;
|
||||
isLoadingVision: boolean;
|
||||
@@ -39,6 +41,7 @@ const initialState: AccessibilityState = {
|
||||
isCaptureTestRunning: false,
|
||||
isLoading: false,
|
||||
isRequestingPermissions: false,
|
||||
isRestartingCore: false,
|
||||
isStartingSession: false,
|
||||
isStoppingSession: false,
|
||||
isLoadingVision: false,
|
||||
@@ -50,6 +53,15 @@ const extractError = (error: unknown, fallback: string): string => {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
return error;
|
||||
}
|
||||
if (error && typeof error === 'object') {
|
||||
const msg = (error as { message?: unknown }).message;
|
||||
if (typeof msg === 'string' && msg.trim()) {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
@@ -91,6 +103,56 @@ export const requestAccessibilityPermission = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Restart the core sidecar and re-fetch accessibility status.
|
||||
*
|
||||
* macOS caches permission state per-process. The running sidecar never sees a
|
||||
* newly granted permission until it exits and a fresh process starts. This thunk:
|
||||
* 1. Restarts the core sidecar via the `restart_core_process` Tauri command.
|
||||
* 2. Waits briefly, then re-fetches status with retries while the new sidecar binds.
|
||||
* 3. Updates Redux so the UI reflects the updated grants.
|
||||
*
|
||||
* @see https://github.com/tinyhumansai/openhuman/issues/133 — stale DENIED after granting in System Settings
|
||||
*/
|
||||
export const refreshPermissionsWithRestart = createAsyncThunk(
|
||||
'accessibility/refreshPermissionsWithRestart',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
console.debug('[accessibility] refreshPermissionsWithRestart: starting core restart');
|
||||
await restartCoreProcess();
|
||||
console.debug('[accessibility] refreshPermissionsWithRestart: waiting for sidecar ready');
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 400));
|
||||
console.debug('[accessibility] refreshPermissionsWithRestart: fetching updated status');
|
||||
for (let attempt = 1; attempt <= 5; attempt++) {
|
||||
try {
|
||||
const response = await openhumanAccessibilityStatus();
|
||||
console.debug(
|
||||
'[accessibility] refreshPermissionsWithRestart: done — screen_recording=%s accessibility=%s input_monitoring=%s',
|
||||
response.result.permissions.screen_recording,
|
||||
response.result.permissions.accessibility,
|
||||
response.result.permissions.input_monitoring
|
||||
);
|
||||
return response.result;
|
||||
} catch (e) {
|
||||
if (attempt === 5) {
|
||||
throw e;
|
||||
}
|
||||
console.debug(
|
||||
'[accessibility] refreshPermissionsWithRestart: status fetch failed (attempt %s), retrying…',
|
||||
attempt
|
||||
);
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 350 * attempt));
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to fetch accessibility status after core restart');
|
||||
} catch (error) {
|
||||
const msg = extractError(error, 'Failed to restart core and refresh permissions');
|
||||
console.error('[accessibility] refreshPermissionsWithRestart: error —', msg, error);
|
||||
return rejectWithValue(msg);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const startAccessibilitySession = createAsyncThunk(
|
||||
'accessibility/startSession',
|
||||
async (
|
||||
@@ -233,6 +295,19 @@ const accessibilitySlice = createSlice({
|
||||
state.lastError =
|
||||
(action.payload as string) ?? 'Failed to request accessibility permission';
|
||||
})
|
||||
.addCase(refreshPermissionsWithRestart.pending, state => {
|
||||
state.isRestartingCore = true;
|
||||
state.lastError = null;
|
||||
})
|
||||
.addCase(refreshPermissionsWithRestart.fulfilled, (state, action) => {
|
||||
state.isRestartingCore = false;
|
||||
state.status = action.payload;
|
||||
})
|
||||
.addCase(refreshPermissionsWithRestart.rejected, (state, action) => {
|
||||
state.isRestartingCore = false;
|
||||
state.lastError =
|
||||
(action.payload as string) ?? 'Failed to restart core and refresh permissions';
|
||||
})
|
||||
.addCase(startAccessibilitySession.pending, state => {
|
||||
state.isStartingSession = true;
|
||||
state.lastError = null;
|
||||
|
||||
@@ -180,6 +180,26 @@ export async function setWindowTitle(title: string): Promise<void> {
|
||||
await getCurrentWindow().setTitle(title);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the core sidecar process.
|
||||
*
|
||||
* macOS caches permission grants per-process; the running sidecar never sees
|
||||
* a newly granted permission until it restarts. Call this after the user grants
|
||||
* permissions in System Settings, then re-fetch accessibility status so the UI
|
||||
* reflects the updated grants.
|
||||
*
|
||||
* @see https://github.com/tinyhumansai/openhuman/issues/133
|
||||
*/
|
||||
export async function restartCoreProcess(): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
console.debug('[core] restartCoreProcess: skipped — not running in Tauri');
|
||||
return;
|
||||
}
|
||||
console.debug('[core] restartCoreProcess: invoking restart_core_process');
|
||||
await invoke<void>('restart_core_process');
|
||||
console.debug('[core] restartCoreProcess: done');
|
||||
}
|
||||
|
||||
// --- Memory Commands ---
|
||||
|
||||
/**
|
||||
@@ -597,6 +617,8 @@ export interface AccessibilityStatus {
|
||||
config: AccessibilityConfig;
|
||||
denylist: string[];
|
||||
is_context_blocked: boolean;
|
||||
/** Absolute path of the core binary; macOS TCC applies to this executable. */
|
||||
permission_check_process_path?: string | null;
|
||||
}
|
||||
|
||||
export interface AccessibilityStartSessionParams {
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time setup: create a stable local code-signing certificate for the
|
||||
# openhuman-core sidecar. Run this once per development machine.
|
||||
#
|
||||
# Why: macOS TCC identifies unsigned binaries by content hash (Mach-O UUID).
|
||||
# Every `yarn core:stage` recompiles the sidecar, changing its hash, so TCC
|
||||
# no longer matches the old grant. Signing with a stable certificate causes
|
||||
# TCC to use the certificate identity instead — grants persist across rebuilds.
|
||||
#
|
||||
# After running this script:
|
||||
# 1. yarn core:stage (signs the sidecar with the new cert)
|
||||
# 2. In OpenHuman → Request Permissions (removes old stale TCC entry,
|
||||
# registers current binary)
|
||||
# 3. Grant in System Settings → Refresh Status
|
||||
# From this point the grant survives future `yarn core:stage` runs.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IDENTITY="OpenHuman Dev Signer"
|
||||
KEYCHAIN="$HOME/Library/Keychains/login.keychain-db"
|
||||
TMPDIR_CERT=$(mktemp -d)
|
||||
KEY="$TMPDIR_CERT/openhuman-dev.key"
|
||||
CERT="$TMPDIR_CERT/openhuman-dev.crt"
|
||||
P12="$TMPDIR_CERT/openhuman-dev.p12"
|
||||
P12_PASS="${OPENHUMAN_P12_PASS:-openhuman-dev}"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TMPDIR_CERT"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Check if already set up ──────────────────────────────────────────────────
|
||||
if security find-identity -v -p codesigning 2>/dev/null | grep -q "$IDENTITY"; then
|
||||
echo "[setup-dev-codesign] Certificate \"$IDENTITY\" already exists — nothing to do."
|
||||
echo "[setup-dev-codesign] Run 'yarn core:stage' to sign the sidecar."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[setup-dev-codesign] Creating self-signed code-signing certificate: \"$IDENTITY\""
|
||||
|
||||
# ── Generate key + self-signed certificate ───────────────────────────────────
|
||||
openssl req \
|
||||
-newkey rsa:2048 \
|
||||
-nodes \
|
||||
-keyout "$KEY" \
|
||||
-x509 \
|
||||
-days 3650 \
|
||||
-out "$CERT" \
|
||||
-subj "/CN=$IDENTITY" \
|
||||
2>/dev/null
|
||||
|
||||
# ── Bundle to PKCS12 ─────────────────────────────────────────────────────────
|
||||
openssl pkcs12 \
|
||||
-export \
|
||||
-out "$P12" \
|
||||
-inkey "$KEY" \
|
||||
-in "$CERT" \
|
||||
-passout "pass:$P12_PASS" \
|
||||
2>/dev/null
|
||||
|
||||
# ── Import into login Keychain ───────────────────────────────────────────────
|
||||
security import "$P12" \
|
||||
-k "$KEYCHAIN" \
|
||||
-P "$P12_PASS" \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/security
|
||||
|
||||
# ── Trust for code signing ───────────────────────────────────────────────────
|
||||
security add-trusted-cert \
|
||||
-d \
|
||||
-r trustRoot \
|
||||
-p codeSign \
|
||||
-k "$KEYCHAIN" \
|
||||
"$CERT"
|
||||
|
||||
echo ""
|
||||
echo "[setup-dev-codesign] Done. Certificate \"$IDENTITY\" added to login Keychain."
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. yarn core:stage — rebuilds and signs the sidecar"
|
||||
echo " 2. In OpenHuman click 'Request Permissions' to register the signed binary"
|
||||
echo " 3. Grant in System Settings → Privacy & Security → Accessibility"
|
||||
echo " 4. Click 'Refresh Status'"
|
||||
echo ""
|
||||
echo "After this, accessibility grants will survive future 'yarn core:stage' runs."
|
||||
@@ -62,3 +62,25 @@ if (!isWindows) {
|
||||
}
|
||||
|
||||
console.log(`[core:stage] Staged sidecar: ${dest}`);
|
||||
|
||||
// macOS: sign with a stable local dev certificate so macOS TCC uses certificate
|
||||
// identity (stable across rebuilds) instead of binary content hash (changes
|
||||
// every compile). Without this, each recompile breaks existing TCC grants.
|
||||
if (process.platform === "darwin") {
|
||||
const DEV_IDENTITY = "OpenHuman Dev Signer";
|
||||
const check = spawnSync(
|
||||
"bash",
|
||||
["-c", `security find-identity -v -p codesigning 2>/dev/null | grep "${DEV_IDENTITY}" || true`],
|
||||
{ cwd: root, encoding: "utf8" },
|
||||
);
|
||||
if (check.stdout && check.stdout.includes(DEV_IDENTITY)) {
|
||||
run("codesign", ["--force", "--sign", DEV_IDENTITY, "--timestamp=none", dest]);
|
||||
console.log(`[core:stage] Signed sidecar with "${DEV_IDENTITY}"`);
|
||||
} else {
|
||||
console.warn(
|
||||
`[core:stage] Dev signing identity "${DEV_IDENTITY}" not found.\n` +
|
||||
`[core:stage] Run 'bash scripts/setup-dev-codesign.sh' once to enable stable TCC grants.\n` +
|
||||
`[core:stage] Without signing, macOS accessibility grants break on every recompile.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +251,9 @@ impl AccessibilityEngine {
|
||||
config,
|
||||
denylist,
|
||||
is_context_blocked: blocked,
|
||||
permission_check_process_path: std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.display().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
//! JSON-RPC / CLI controller surface for screen capture and accessibility automation.
|
||||
//!
|
||||
//! macOS permission UX (stale DENIED until sidecar restarts) is tracked in
|
||||
//! <https://github.com/tinyhumansai/openhuman/issues/133>.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
@@ -178,6 +181,32 @@ pub async fn accessibility_vision_flush() -> Result<RpcOutcome<VisionFlushResult
|
||||
))
|
||||
}
|
||||
|
||||
/// Re-detect current permission state. Intended to be called after the sidecar
|
||||
/// restarts so the new process reads freshly granted macOS permissions.
|
||||
///
|
||||
/// macOS caches permission grants per-process; the running sidecar never sees an
|
||||
/// updated grant until it restarts. After `restart_core_process` brings up a fresh
|
||||
/// sidecar, calling this endpoint returns the authoritative permission state as seen
|
||||
/// by that new process.
|
||||
pub async fn accessibility_refresh_permissions() -> Result<RpcOutcome<PermissionStatus>, String> {
|
||||
log::info!("[screen_intelligence] refresh_permissions:re-detecting permissions");
|
||||
// `status()` unconditionally calls `detect_permissions()` before returning, so
|
||||
// fetching the full status and extracting the permissions field is the correct
|
||||
// way to get a freshly computed permission state.
|
||||
let full_status = screen_intelligence::global_engine().status().await;
|
||||
let permissions = full_status.permissions;
|
||||
log::debug!(
|
||||
"[screen_intelligence] accessibility_refresh_permissions: screen_recording={:?} accessibility={:?} input_monitoring={:?}",
|
||||
permissions.screen_recording,
|
||||
permissions.accessibility,
|
||||
permissions.input_monitoring,
|
||||
);
|
||||
Ok(RpcOutcome::single_log(
|
||||
permissions,
|
||||
"accessibility permissions refreshed",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn accessibility_capture_test() -> Result<RpcOutcome<CaptureTestResult>, String> {
|
||||
let result: CaptureTestResult = screen_intelligence::global_engine().capture_test().await;
|
||||
Ok(RpcOutcome::single_log(
|
||||
|
||||
@@ -19,6 +19,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("status"),
|
||||
schemas("request_permissions"),
|
||||
schemas("request_permission"),
|
||||
schemas("refresh_permissions"),
|
||||
schemas("start_session"),
|
||||
schemas("stop_session"),
|
||||
schemas("capture_now"),
|
||||
@@ -44,6 +45,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: schemas("request_permission"),
|
||||
handler: handle_request_permission,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("refresh_permissions"),
|
||||
handler: handle_refresh_permissions,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("start_session"),
|
||||
handler: handle_start_session,
|
||||
@@ -107,6 +112,14 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
}],
|
||||
outputs: vec![json_output("permissions", "Permission status payload.")],
|
||||
},
|
||||
"refresh_permissions" => ControllerSchema {
|
||||
namespace: "screen_intelligence",
|
||||
function: "refresh_permissions",
|
||||
description: "Re-detect current macOS permission state without requesting new grants. \
|
||||
Call this after the sidecar restarts to read freshly granted permissions.",
|
||||
inputs: vec![],
|
||||
outputs: vec![json_output("permissions", "Freshly detected permission status.")],
|
||||
},
|
||||
"start_session" => ControllerSchema {
|
||||
namespace: "screen_intelligence",
|
||||
function: "start_session",
|
||||
@@ -223,6 +236,14 @@ fn handle_request_permissions(_params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_refresh_permissions(_params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async {
|
||||
to_json(
|
||||
crate::openhuman::screen_intelligence::rpc::accessibility_refresh_permissions().await?,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_request_permission(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let payload = deserialize_params::<PermissionRequestParams>(params)?;
|
||||
|
||||
@@ -66,6 +66,10 @@ pub struct AccessibilityStatus {
|
||||
pub config: ScreenIntelligenceConfig,
|
||||
pub denylist: Vec<String>,
|
||||
pub is_context_blocked: bool,
|
||||
/// Absolute path of this core process. macOS privacy (TCC) is per executable; the UI should
|
||||
/// show this so users enable the same binary in System Settings (see GH #133).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permission_check_process_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user