feat: accessibility automation v1 (core + tauri + settings) (#47)

* feat(accessibility): add core automation RPC + settings panel

* added accessibility code
This commit is contained in:
Steven Enamakel
2026-03-27 17:28:13 -07:00
committed by GitHub
parent 0f2530bcbf
commit 23c4e11ffe
18 changed files with 2121 additions and 16 deletions
+133 -1
View File
@@ -12,7 +12,15 @@ use crate::openhuman::config::Config;
use crate::openhuman::health;
use crate::openhuman::local_ai::{self, Suggestion};
use crate::openhuman::security::{SecretStore, SecurityPolicy};
use crate::openhuman::{doctor, hardware, integrations, migration, onboard, service};
use crate::openhuman::{
accessibility, doctor, hardware, integrations, migration, onboard, service,
};
pub use crate::openhuman::accessibility::{
AccessibilityStatus, AutocompleteCommitParams, AutocompleteCommitResult,
AutocompleteSuggestParams, AutocompleteSuggestResult, CaptureNowResult, InputActionParams,
InputActionResult, PermissionStatus, SessionStatus, StartSessionParams, StopSessionParams,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandResponse<T> {
@@ -728,6 +736,83 @@ async fn dispatch(
))
}
"openhuman.accessibility_status" => {
let status = accessibility::global_engine().status().await;
to_json_value(command_response(
status,
vec!["accessibility status fetched".to_string()],
))
}
"openhuman.accessibility_request_permissions" => {
let permissions = accessibility::global_engine().request_permissions().await?;
to_json_value(command_response(
permissions,
vec!["accessibility permissions requested".to_string()],
))
}
"openhuman.accessibility_start_session" => {
let payload: StartSessionParams = parse_params(params)?;
let session = accessibility::global_engine()
.start_session(payload)
.await?;
to_json_value(command_response(
session,
vec!["accessibility session started".to_string()],
))
}
"openhuman.accessibility_stop_session" => {
let payload: StopSessionParams = parse_params(params)?;
let session = accessibility::global_engine()
.stop_session(payload.reason)
.await;
to_json_value(command_response(
session,
vec!["accessibility session stopped".to_string()],
))
}
"openhuman.accessibility_capture_now" => {
let result = accessibility::global_engine().capture_now().await?;
to_json_value(command_response(
result,
vec!["accessibility manual capture requested".to_string()],
))
}
"openhuman.accessibility_input_action" => {
let payload: InputActionParams = parse_params(params)?;
let result = accessibility::global_engine().input_action(payload).await?;
to_json_value(command_response(
result,
vec!["accessibility input action processed".to_string()],
))
}
"openhuman.accessibility_autocomplete_suggest" => {
let payload: AutocompleteSuggestParams = parse_params(params)?;
let result = accessibility::global_engine()
.autocomplete_suggest(payload)
.await?;
to_json_value(command_response(
result,
vec!["accessibility autocomplete suggestions generated".to_string()],
))
}
"openhuman.accessibility_autocomplete_commit" => {
let payload: AutocompleteCommitParams = parse_params(params)?;
let result = accessibility::global_engine()
.autocomplete_commit(payload)
.await?;
to_json_value(command_response(
result,
vec!["accessibility autocomplete suggestion committed".to_string()],
))
}
_ => Err(format!("unknown method: {method}")),
}
}
@@ -846,3 +931,50 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> {
.build()?;
runtime.block_on(run_server(port))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn accessibility_status_rpc_returns_valid_schema() {
let raw = call_method("openhuman.accessibility_status", json!({}))
.await
.expect("status rpc should return");
let payload: CommandResponse<AccessibilityStatus> =
serde_json::from_value(raw).expect("status payload should decode");
assert!(!payload.logs.is_empty());
assert!(!payload.result.config.capture_policy.is_empty());
}
#[tokio::test]
async fn accessibility_start_session_requires_consent() {
let err = call_method(
"openhuman.accessibility_start_session",
json!({
"consent": false,
"ttl_secs": 60
}),
)
.await
.expect_err("session start without consent should fail");
assert!(err.contains("consent"));
}
#[tokio::test]
async fn accessibility_input_action_rejects_invalid_envelope() {
let err = call_method(
"openhuman.accessibility_input_action",
json!({
"x": 10,
"y": 20
}),
)
.await
.expect_err("missing action should fail envelope validation");
assert!(err.contains("invalid params"));
}
}
@@ -0,0 +1,956 @@
use crate::openhuman::config::AccessibilityAutomationConfig;
use chrono::Utc;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::time::{self, Duration};
const MAX_EPHEMERAL_FRAMES: usize = 120;
const MAX_CONTEXT_CHARS: usize = 256;
const MAX_SUGGESTION_CHARS: usize = 128;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PermissionState {
Granted,
Denied,
Unknown,
Unsupported,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionStatus {
pub screen_recording: PermissionState,
pub accessibility: PermissionState,
pub input_monitoring: PermissionState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessibilityFeatures {
pub screen_monitoring: bool,
pub device_control: bool,
pub predictive_input: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionStatus {
pub active: bool,
pub started_at_ms: Option<i64>,
pub expires_at_ms: Option<i64>,
pub remaining_ms: Option<i64>,
pub ttl_secs: u64,
pub panic_hotkey: String,
pub stop_reason: Option<String>,
pub frames_in_memory: usize,
pub last_capture_at_ms: Option<i64>,
pub last_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessibilityHealth {
pub last_error: Option<String>,
pub last_event: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessibilityStatus {
pub platform_supported: bool,
pub permissions: PermissionStatus,
pub features: AccessibilityFeatures,
pub session: SessionStatus,
pub config: AccessibilityAutomationConfig,
pub denylist: Vec<String>,
pub is_context_blocked: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StartSessionParams {
pub consent: bool,
pub ttl_secs: Option<u64>,
pub screen_monitoring: Option<bool>,
pub device_control: Option<bool>,
pub predictive_input: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StopSessionParams {
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureFrame {
pub captured_at_ms: i64,
pub reason: String,
pub app_name: Option<String>,
pub window_title: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptureNowResult {
pub accepted: bool,
pub frame: Option<CaptureFrame>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputActionParams {
pub action: String,
pub x: Option<i32>,
pub y: Option<i32>,
pub button: Option<String>,
pub text: Option<String>,
pub key: Option<String>,
pub modifiers: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputActionResult {
pub accepted: bool,
pub blocked: bool,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutocompleteSuggestParams {
pub context: Option<String>,
pub max_results: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutocompleteSuggestion {
pub value: String,
pub confidence: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutocompleteSuggestResult {
pub suggestions: Vec<AutocompleteSuggestion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutocompleteCommitParams {
pub suggestion: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutocompleteCommitResult {
pub committed: bool,
}
#[derive(Debug, Clone)]
struct AppContext {
app_name: Option<String>,
window_title: Option<String>,
}
impl AppContext {
fn same_as(&self, other: &AppContext) -> bool {
self.app_name == other.app_name && self.window_title == other.window_title
}
fn as_compound_text(&self) -> String {
format!(
"{} {}",
self.app_name.clone().unwrap_or_default(),
self.window_title.clone().unwrap_or_default()
)
.to_lowercase()
}
}
struct SessionRuntime {
started_at_ms: i64,
expires_at_ms: i64,
ttl_secs: u64,
panic_hotkey: String,
stop_reason: Option<String>,
last_capture_at_ms: Option<i64>,
frames: VecDeque<CaptureFrame>,
last_context: Option<AppContext>,
task: Option<JoinHandle<()>>,
}
struct EngineState {
config: AccessibilityAutomationConfig,
permissions: PermissionStatus,
features: AccessibilityFeatures,
session: Option<SessionRuntime>,
last_error: Option<String>,
last_event: Option<String>,
autocomplete_context: String,
}
impl EngineState {
fn new(config: AccessibilityAutomationConfig) -> Self {
Self {
permissions: PermissionStatus {
screen_recording: PermissionState::Unknown,
accessibility: PermissionState::Unknown,
input_monitoring: PermissionState::Unknown,
},
features: AccessibilityFeatures {
screen_monitoring: true,
device_control: true,
predictive_input: config.autocomplete_enabled,
},
config,
session: None,
last_error: None,
last_event: None,
autocomplete_context: String::new(),
}
}
}
pub struct AccessibilityEngine {
inner: Mutex<EngineState>,
}
static ACCESSIBILITY_ENGINE: Lazy<Arc<AccessibilityEngine>> = Lazy::new(|| {
Arc::new(AccessibilityEngine {
inner: Mutex::new(EngineState::new(AccessibilityAutomationConfig::default())),
})
});
pub fn global_engine() -> Arc<AccessibilityEngine> {
ACCESSIBILITY_ENGINE.clone()
}
impl AccessibilityEngine {
pub async fn status(&self) -> AccessibilityStatus {
let mut state = self.inner.lock().await;
state.permissions = detect_permissions();
let context = foreground_context();
let blocked = context
.as_ref()
.map(|ctx| self.is_context_blocked_by(ctx, &state.config.denylist))
.unwrap_or(false);
let (session, denylist, config, permissions, features) = {
let now = now_ms();
let session = match &state.session {
Some(session) => SessionStatus {
active: true,
started_at_ms: Some(session.started_at_ms),
expires_at_ms: Some(session.expires_at_ms),
remaining_ms: Some((session.expires_at_ms - now).max(0)),
ttl_secs: session.ttl_secs,
panic_hotkey: session.panic_hotkey.clone(),
stop_reason: session.stop_reason.clone(),
frames_in_memory: session.frames.len(),
last_capture_at_ms: session.last_capture_at_ms,
last_context: session
.last_context
.as_ref()
.and_then(|c| c.app_name.clone()),
},
None => SessionStatus {
active: false,
started_at_ms: None,
expires_at_ms: None,
remaining_ms: None,
ttl_secs: state.config.session_ttl_secs,
panic_hotkey: state.config.panic_stop_hotkey.clone(),
stop_reason: None,
frames_in_memory: 0,
last_capture_at_ms: None,
last_context: None,
},
};
(
session,
state.config.denylist.clone(),
state.config.clone(),
state.permissions.clone(),
state.features.clone(),
)
};
AccessibilityStatus {
platform_supported: cfg!(target_os = "macos"),
permissions,
features,
session,
config,
denylist,
is_context_blocked: blocked,
}
}
pub async fn request_permissions(&self) -> Result<PermissionStatus, String> {
if !cfg!(target_os = "macos") {
return Ok(PermissionStatus {
screen_recording: PermissionState::Unsupported,
accessibility: PermissionState::Unsupported,
input_monitoring: PermissionState::Unsupported,
});
}
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open")
.arg(
"x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
)
.status();
let _ = std::process::Command::new("open")
.arg(
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
)
.status();
let _ = std::process::Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")
.status();
}
let mut state = self.inner.lock().await;
state.permissions = detect_permissions();
state.last_event = Some("permissions_requested".to_string());
Ok(state.permissions.clone())
}
pub async fn start_session(
self: &Arc<Self>,
params: StartSessionParams,
) -> Result<SessionStatus, String> {
if !params.consent {
return Err("explicit consent is required to start accessibility session".to_string());
}
if !cfg!(target_os = "macos") {
return Err("accessibility automation is macOS-only in V1".to_string());
}
let ttl_secs = params
.ttl_secs
.unwrap_or(AccessibilityAutomationConfig::default().session_ttl_secs)
.clamp(30, 3600);
{
let mut state = self.inner.lock().await;
if state.session.is_some() {
return Err("session already active".to_string());
}
state.permissions = detect_permissions();
if state.permissions.accessibility != PermissionState::Granted {
return Err("accessibility permission is not granted".to_string());
}
let now = now_ms();
let expires_at_ms = now + (ttl_secs as i64 * 1000);
state.features.screen_monitoring = params.screen_monitoring.unwrap_or(true);
state.features.device_control = params.device_control.unwrap_or(true);
state.features.predictive_input = params
.predictive_input
.unwrap_or(state.config.autocomplete_enabled);
state.session = Some(SessionRuntime {
started_at_ms: now,
expires_at_ms,
ttl_secs,
panic_hotkey: state.config.panic_stop_hotkey.clone(),
stop_reason: None,
last_capture_at_ms: None,
frames: VecDeque::new(),
last_context: None,
task: None,
});
state.last_event = Some("session_started".to_string());
state.last_error = None;
}
let engine = self.clone();
let handle = tokio::spawn(async move {
engine.run_capture_worker().await;
});
{
let mut state = self.inner.lock().await;
if let Some(session) = state.session.as_mut() {
session.task = Some(handle);
}
}
Ok(self.status().await.session)
}
pub async fn stop_session(&self, reason: Option<String>) -> SessionStatus {
self.stop_session_internal(reason.unwrap_or_else(|| "manual_stop".to_string()))
.await;
self.status().await.session
}
pub async fn capture_now(&self) -> Result<CaptureNowResult, String> {
let mut state = self.inner.lock().await;
let reason = "manual_capture".to_string();
let context = foreground_context();
let Some(session) = state.session.as_mut() else {
return Ok(CaptureNowResult {
accepted: false,
frame: None,
});
};
let frame = CaptureFrame {
captured_at_ms: now_ms(),
reason,
app_name: context.as_ref().and_then(|c| c.app_name.clone()),
window_title: context.as_ref().and_then(|c| c.window_title.clone()),
};
push_ephemeral_frame(&mut session.frames, frame.clone());
session.last_capture_at_ms = Some(frame.captured_at_ms);
session.last_context = context;
state.last_event = Some("capture_now".to_string());
Ok(CaptureNowResult {
accepted: true,
frame: Some(frame),
})
}
pub async fn input_action(
&self,
action: InputActionParams,
) -> Result<InputActionResult, String> {
let mut state = self.inner.lock().await;
if action.action == "panic_stop" {
drop(state);
self.stop_session_internal("panic_stop".to_string()).await;
return Ok(InputActionResult {
accepted: true,
blocked: false,
reason: Some("panic stop executed".to_string()),
});
}
if state.session.is_none() {
return Ok(InputActionResult {
accepted: false,
blocked: true,
reason: Some("session is not active".to_string()),
});
}
if !state.features.device_control {
return Ok(InputActionResult {
accepted: false,
blocked: true,
reason: Some("device control is disabled".to_string()),
});
}
let context = foreground_context();
if let Some(ctx) = &context {
if self.is_context_blocked_by(ctx, &state.config.denylist) {
return Ok(InputActionResult {
accepted: false,
blocked: true,
reason: Some("action blocked by denylisted context".to_string()),
});
}
}
validate_input_action(&action)?;
if let Some(text) = action.text.as_ref() {
if !text.is_empty() {
if !state.autocomplete_context.is_empty() {
state.autocomplete_context.push(' ');
}
state.autocomplete_context.push_str(text);
state.autocomplete_context =
truncate_tail(&state.autocomplete_context, MAX_CONTEXT_CHARS);
}
}
let action_name = action.action.clone();
state.last_event = Some(format!("input_action:{action_name}"));
Ok(InputActionResult {
accepted: true,
blocked: false,
reason: None,
})
}
pub async fn autocomplete_suggest(
&self,
params: AutocompleteSuggestParams,
) -> Result<AutocompleteSuggestResult, String> {
let state = self.inner.lock().await;
if !state.features.predictive_input {
return Ok(AutocompleteSuggestResult {
suggestions: Vec::new(),
});
}
let mut context = params.context.unwrap_or_default();
if context.trim().is_empty() {
context = state.autocomplete_context.clone();
}
drop(state);
let max_results = params.max_results.unwrap_or(3).clamp(1, 8);
let suggestions = generate_suggestions(&context, max_results);
Ok(AutocompleteSuggestResult { suggestions })
}
pub async fn autocomplete_commit(
&self,
params: AutocompleteCommitParams,
) -> Result<AutocompleteCommitResult, String> {
let cleaned = params.suggestion.trim();
if cleaned.is_empty() {
return Err("suggestion cannot be empty".to_string());
}
if cleaned.len() > MAX_SUGGESTION_CHARS {
return Err("suggestion exceeds maximum length".to_string());
}
let mut state = self.inner.lock().await;
if !state.features.predictive_input {
return Ok(AutocompleteCommitResult { committed: false });
}
if !state.autocomplete_context.is_empty() {
state.autocomplete_context.push(' ');
}
state.autocomplete_context.push_str(cleaned);
state.autocomplete_context = truncate_tail(&state.autocomplete_context, MAX_CONTEXT_CHARS);
state.last_event = Some("autocomplete_commit".to_string());
Ok(AutocompleteCommitResult { committed: true })
}
async fn run_capture_worker(self: Arc<Self>) {
let mut tick = time::interval(Duration::from_millis(250));
loop {
tick.tick().await;
let should_stop = {
let state = self.inner.lock().await;
match &state.session {
Some(session) => now_ms() >= session.expires_at_ms,
None => return,
}
};
if should_stop {
self.stop_session_internal("ttl_expired".to_string()).await;
return;
}
let context = foreground_context();
let now = now_ms();
let mut state = self.inner.lock().await;
let baseline_ms = (1000.0 / state.config.baseline_fps.max(0.2)).round() as i64;
let denylist = state.config.denylist.clone();
let screen_monitoring = state.features.screen_monitoring;
let Some(session) = state.session.as_mut() else {
return;
};
if !screen_monitoring {
continue;
}
let is_blocked = context
.as_ref()
.map(|ctx| self.is_context_blocked_by(ctx, &denylist))
.unwrap_or(false);
if is_blocked {
continue;
}
let context_changed = match (&session.last_context, &context) {
(Some(prev), Some(curr)) => !prev.same_as(curr),
(None, Some(_)) => true,
_ => false,
};
let baseline_due = session
.last_capture_at_ms
.map(|last| now - last >= baseline_ms)
.unwrap_or(true);
if context_changed || baseline_due {
let reason = if context_changed {
"event:foreground_changed"
} else {
"baseline"
};
let frame = CaptureFrame {
captured_at_ms: now,
reason: reason.to_string(),
app_name: context.as_ref().and_then(|c| c.app_name.clone()),
window_title: context.as_ref().and_then(|c| c.window_title.clone()),
};
push_ephemeral_frame(&mut session.frames, frame);
session.last_capture_at_ms = Some(now);
session.last_context = context;
state.last_event = Some(reason.to_string());
}
}
}
async fn stop_session_internal(&self, reason: String) {
let mut state = self.inner.lock().await;
let Some(mut session) = state.session.take() else {
return;
};
session.stop_reason = Some(reason.clone());
if let Some(task) = session.task.take() {
task.abort();
}
state.last_event = Some(format!("session_stopped:{reason}"));
}
fn is_context_blocked_by(&self, ctx: &AppContext, denylist: &[String]) -> bool {
let compound = ctx.as_compound_text();
denylist
.iter()
.any(|d| !d.trim().is_empty() && compound.contains(&d.to_lowercase()))
}
}
fn validate_input_action(action: &InputActionParams) -> Result<(), String> {
match action.action.as_str() {
"mouse_move" | "mouse_click" | "mouse_drag" => {
let x = action
.x
.ok_or_else(|| "x coordinate is required".to_string())?;
let y = action
.y
.ok_or_else(|| "y coordinate is required".to_string())?;
if !(0..=10000).contains(&x) || !(0..=10000).contains(&y) {
return Err("coordinates must be between 0 and 10000".to_string());
}
}
"key_type" => {
let text = action
.text
.as_ref()
.ok_or_else(|| "text is required for key_type".to_string())?;
if text.is_empty() || text.len() > MAX_CONTEXT_CHARS {
return Err("text length must be between 1 and 256".to_string());
}
}
"key_press" => {
let key = action
.key
.as_ref()
.ok_or_else(|| "key is required for key_press".to_string())?;
if key.trim().is_empty() {
return Err("key cannot be empty".to_string());
}
}
other => {
return Err(format!("unsupported input action: {other}"));
}
}
Ok(())
}
fn push_ephemeral_frame(frames: &mut VecDeque<CaptureFrame>, frame: CaptureFrame) {
frames.push_back(frame);
while frames.len() > MAX_EPHEMERAL_FRAMES {
let _ = frames.pop_front();
}
}
fn truncate_tail(text: &str, max_chars: usize) -> String {
let chars: Vec<char> = text.chars().collect();
if chars.len() <= max_chars {
return text.to_string();
}
chars[chars.len() - max_chars..].iter().collect()
}
fn generate_suggestions(context: &str, max_results: usize) -> Vec<AutocompleteSuggestion> {
let trimmed = context.trim();
let lower = trimmed.to_lowercase();
let mut out = Vec::new();
if lower.ends_with("thanks") || lower.ends_with("thank you") {
out.push(AutocompleteSuggestion {
value: " for your help!".to_string(),
confidence: 0.89,
});
}
if lower.contains("meeting") {
out.push(AutocompleteSuggestion {
value: " tomorrow at 10am works for me.".to_string(),
confidence: 0.84,
});
}
if lower.contains("ship") || lower.contains("release") {
out.push(AutocompleteSuggestion {
value: " after we pass QA and smoke tests.".to_string(),
confidence: 0.81,
});
}
if out.is_empty() {
out.push(AutocompleteSuggestion {
value: " Please share any constraints and I can refine this.".to_string(),
confidence: 0.55,
});
}
out.truncate(max_results);
out
}
fn now_ms() -> i64 {
Utc::now().timestamp_millis()
}
#[cfg(target_os = "macos")]
fn foreground_context() -> Option<AppContext> {
let script = r#"
tell application "System Events"
set frontApp to name of first application process whose frontmost is true
set frontWindow to ""
try
tell process frontApp
if (count of windows) > 0 then
set frontWindow to name of front window
end if
end tell
end try
return frontApp & "\n" & frontWindow
end tell
"#;
let output = std::process::Command::new("osascript")
.arg("-e")
.arg(script)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let mut lines = text.lines();
let app = lines.next().map(|s| s.trim().to_string());
let title = lines.next().map(|s| s.trim().to_string());
Some(AppContext {
app_name: app.filter(|s| !s.is_empty()),
window_title: title.filter(|s| !s.is_empty()),
})
}
#[cfg(not(target_os = "macos"))]
fn foreground_context() -> Option<AppContext> {
None
}
#[cfg(target_os = "macos")]
fn detect_permissions() -> PermissionStatus {
let accessibility = std::process::Command::new("osascript")
.arg("-e")
.arg("tell application \"System Events\" to return UI elements enabled")
.output()
.map(|o| {
if o.status.success() {
let value = String::from_utf8_lossy(&o.stdout).to_lowercase();
if value.contains("true") {
PermissionState::Granted
} else {
PermissionState::Denied
}
} else {
PermissionState::Denied
}
})
.unwrap_or(PermissionState::Unknown);
PermissionStatus {
screen_recording: PermissionState::Unknown,
accessibility,
input_monitoring: PermissionState::Unknown,
}
}
#[cfg(not(target_os = "macos"))]
fn detect_permissions() -> PermissionStatus {
PermissionStatus {
screen_recording: PermissionState::Unsupported,
accessibility: PermissionState::Unsupported,
input_monitoring: PermissionState::Unsupported,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_coordinates_and_actions() {
let ok = InputActionParams {
action: "mouse_move".to_string(),
x: Some(10),
y: Some(20),
button: None,
text: None,
key: None,
modifiers: None,
};
assert!(validate_input_action(&ok).is_ok());
let bad = InputActionParams {
action: "mouse_click".to_string(),
x: Some(-1),
y: Some(20),
button: None,
text: None,
key: None,
modifiers: None,
};
assert!(validate_input_action(&bad).is_err());
let unsupported = InputActionParams {
action: "open_portal".to_string(),
x: None,
y: None,
button: None,
text: None,
key: None,
modifiers: None,
};
assert!(validate_input_action(&unsupported).is_err());
}
#[tokio::test]
async fn session_lifecycle_transitions_and_ttl_expiry() {
let engine = Arc::new(AccessibilityEngine {
inner: Mutex::new(EngineState::new(AccessibilityAutomationConfig {
capture_policy: "hybrid".to_string(),
baseline_fps: 8.0,
session_ttl_secs: 1,
panic_stop_hotkey: "Cmd+Shift+.".to_string(),
autocomplete_enabled: true,
denylist: vec!["1password".to_string()],
})),
});
let start = engine
.start_session(StartSessionParams {
consent: true,
ttl_secs: Some(1),
screen_monitoring: Some(true),
device_control: Some(true),
predictive_input: Some(true),
})
.await;
if cfg!(target_os = "macos") {
if start.is_ok() {
let active = engine.status().await;
assert!(active.session.active);
time::sleep(Duration::from_millis(1400)).await;
let ended = engine.status().await;
assert!(!ended.session.active);
}
} else {
assert!(start.is_err());
}
}
#[tokio::test]
async fn panic_stop_behavior_stops_session() {
if !cfg!(target_os = "macos") {
return;
}
let engine = global_engine();
let started = engine
.start_session(StartSessionParams {
consent: true,
ttl_secs: Some(60),
screen_monitoring: Some(true),
device_control: Some(true),
predictive_input: Some(true),
})
.await;
if started.is_err() {
return;
}
let result = engine
.input_action(InputActionParams {
action: "panic_stop".to_string(),
x: None,
y: None,
button: None,
text: None,
key: None,
modifiers: None,
})
.await
.expect("panic action should return");
assert!(result.accepted);
assert!(!engine.status().await.session.active);
}
#[tokio::test]
async fn capture_scheduler_adds_baseline_frames() {
if !cfg!(target_os = "macos") {
return;
}
let engine = Arc::new(AccessibilityEngine {
inner: Mutex::new(EngineState::new(AccessibilityAutomationConfig {
capture_policy: "hybrid".to_string(),
baseline_fps: 6.0,
session_ttl_secs: 2,
panic_stop_hotkey: "Cmd+Shift+.".to_string(),
autocomplete_enabled: true,
denylist: vec![],
})),
});
let started = engine
.start_session(StartSessionParams {
consent: true,
ttl_secs: Some(2),
screen_monitoring: Some(true),
device_control: Some(true),
predictive_input: Some(true),
})
.await;
if started.is_err() {
return;
}
time::sleep(Duration::from_millis(700)).await;
let status = engine.status().await;
assert!(status.session.frames_in_memory >= 1);
let _ = engine.stop_session(Some("test_end".to_string())).await;
}
}
+12 -11
View File
@@ -8,17 +8,18 @@ pub use daemon::DaemonConfig;
pub use schema::{
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig,
ChannelsConfig, ClassificationRule, CloudflareTunnelConfig, ComposioConfig, Config, CostConfig,
CronConfig, CustomTunnelConfig, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig,
EmbeddingRouteConfig, GatewayConfig, HardwareConfig, HardwareTransport, HeartbeatConfig,
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, LocalAiConfig, MatrixConfig,
MemoryConfig, ModelRouteConfig, MultimodalConfig, NgrokTunnelConfig, ObservabilityConfig,
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QueryClassificationConfig,
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig,
StorageProviderConfig, StorageProviderSection, StreamMode, TailscaleTunnelConfig,
TelegramConfig, TunnelConfig, WebSearchConfig, WebhookConfig,
AccessibilityAutomationConfig, AgentConfig, AuditConfig, AutonomyConfig,
BrowserComputerUseConfig, BrowserConfig, ChannelsConfig, ClassificationRule,
CloudflareTunnelConfig, ComposioConfig, Config, CostConfig, CronConfig, CustomTunnelConfig,
DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, EmbeddingRouteConfig, GatewayConfig,
HardwareConfig, HardwareTransport, HeartbeatConfig, HttpRequestConfig, IMessageConfig,
IdentityConfig, LarkConfig, LocalAiConfig, MatrixConfig, MemoryConfig, ModelRouteConfig,
MultimodalConfig, NgrokTunnelConfig, ObservabilityConfig, PeripheralBoardConfig,
PeripheralsConfig, ProxyConfig, ProxyScope, QueryClassificationConfig, ReliabilityConfig,
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
SecretsConfig, SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig,
StorageProviderSection, StreamMode, TailscaleTunnelConfig, TelegramConfig, TunnelConfig,
WebSearchConfig, WebhookConfig,
};
#[cfg(test)]
@@ -0,0 +1,56 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AccessibilityAutomationConfig {
#[serde(default = "default_capture_policy")]
pub capture_policy: String,
#[serde(default = "default_baseline_fps")]
pub baseline_fps: f32,
#[serde(default = "default_session_ttl_secs")]
pub session_ttl_secs: u64,
#[serde(default = "default_panic_stop_hotkey")]
pub panic_stop_hotkey: String,
#[serde(default = "default_autocomplete_enabled")]
pub autocomplete_enabled: bool,
#[serde(default)]
pub denylist: Vec<String>,
}
fn default_capture_policy() -> String {
"hybrid".to_string()
}
fn default_baseline_fps() -> f32 {
1.0
}
fn default_session_ttl_secs() -> u64 {
300
}
fn default_panic_stop_hotkey() -> String {
"Cmd+Shift+.".to_string()
}
fn default_autocomplete_enabled() -> bool {
true
}
impl Default for AccessibilityAutomationConfig {
fn default() -> Self {
Self {
capture_policy: default_capture_policy(),
baseline_fps: default_baseline_fps(),
session_ttl_secs: default_session_ttl_secs(),
panic_stop_hotkey: default_panic_stop_hotkey(),
autocomplete_enabled: default_autocomplete_enabled(),
denylist: vec![
"1password".to_string(),
"keychain".to_string(),
"wallet".to_string(),
"seed phrase".to_string(),
],
}
}
}
@@ -2,6 +2,7 @@
//!
//! Split into submodules; this module re-exports the main `Config` and all public types.
mod accessibility;
mod agent;
mod autonomy;
mod channels;
@@ -20,6 +21,7 @@ mod storage_memory;
mod tools;
mod tunnel;
pub use accessibility::AccessibilityAutomationConfig;
pub use agent::{AgentConfig, DelegateAgentConfig};
pub use autonomy::AutonomyConfig;
pub use channels::{
@@ -85,6 +87,9 @@ pub struct Config {
#[serde(default)]
pub runtime: RuntimeConfig,
#[serde(default)]
pub accessibility: AccessibilityAutomationConfig,
#[serde(default)]
pub reliability: ReliabilityConfig,
@@ -181,6 +186,7 @@ impl Default for Config {
observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(),
runtime: RuntimeConfig::default(),
accessibility: AccessibilityAutomationConfig::default(),
reliability: ReliabilityConfig::default(),
scheduler: SchedulerConfig::default(),
agent: AgentConfig::default(),
+1
View File
@@ -11,6 +11,7 @@
// Many types/functions are not yet consumed but are intentionally exported.
#![allow(dead_code)]
pub mod accessibility;
pub mod agent;
pub mod approval;
pub mod channels;
+1 -1
Submodule skills updated: 0685a6af4e...ce54fea606
+153 -3
View File
@@ -1,16 +1,50 @@
//! Tauri command proxies for the standalone openhuman core process.
use openhuman_core::core_server::{
BrowserSettingsUpdate, CommandResponse, ConfigSnapshot, GatewaySettingsUpdate,
MemorySettingsUpdate, ModelSettingsUpdate, RuntimeFlags, RuntimeSettingsUpdate,
AccessibilityStatus, AutocompleteCommitParams, AutocompleteCommitResult,
AutocompleteSuggestParams, AutocompleteSuggestResult, BrowserSettingsUpdate, CaptureNowResult,
CommandResponse, ConfigSnapshot, GatewaySettingsUpdate, InputActionParams, InputActionResult,
MemorySettingsUpdate, ModelSettingsUpdate, PermissionStatus, RuntimeFlags,
RuntimeSettingsUpdate, SessionStatus, StartSessionParams, StopSessionParams,
};
use openhuman_core::openhuman::{doctor, hardware, integrations, migration, onboard, service};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tauri::Manager;
use tauri::{Emitter, Manager};
#[cfg(desktop)]
use crate::services::notification_service::NotificationService;
const DEFAULT_CORE_RPC_URL: &str = "http://127.0.0.1:7788/rpc";
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccessibilityBridgeEvent {
event: String,
timestamp_ms: i64,
details: serde_json::Value,
}
fn emit_accessibility_event(app: &tauri::AppHandle, event: &str, details: serde_json::Value) {
let payload = AccessibilityBridgeEvent {
event: event.to_string(),
timestamp_ms: chrono::Utc::now().timestamp_millis(),
details,
};
let _ = app.emit("openhuman:accessibility", &payload);
#[cfg(desktop)]
{
let body = match event {
"session_started" => "Accessibility session started",
"session_stopped" => "Accessibility session stopped",
"permissions_requested" => "Accessibility permission request opened",
_ => "Accessibility automation event",
};
let _ = NotificationService::show(app, "Accessibility Automation", body);
}
}
fn params_none() -> serde_json::Value {
serde_json::json!({})
}
@@ -252,6 +286,122 @@ pub async fn openhuman_agent_chat(
.await
}
/// Fetch accessibility automation status.
#[tauri::command]
pub async fn openhuman_accessibility_status(
app: tauri::AppHandle,
) -> Result<CommandResponse<AccessibilityStatus>, String> {
call_core(&app, "openhuman.accessibility_status", params_none()).await
}
/// Request accessibility-related permissions on macOS.
#[tauri::command]
pub async fn openhuman_accessibility_request_permissions(
app: tauri::AppHandle,
) -> Result<CommandResponse<PermissionStatus>, String> {
let response: CommandResponse<PermissionStatus> = call_core(
&app,
"openhuman.accessibility_request_permissions",
params_none(),
)
.await?;
emit_accessibility_event(
&app,
"permissions_requested",
serde_json::json!(response.result),
);
Ok(response)
}
/// Start a bounded accessibility session with explicit consent.
#[tauri::command]
pub async fn openhuman_accessibility_start_session(
app: tauri::AppHandle,
params: StartSessionParams,
) -> Result<CommandResponse<SessionStatus>, String> {
let response: CommandResponse<SessionStatus> = call_core(
&app,
"openhuman.accessibility_start_session",
serde_json::json!(params),
)
.await?;
emit_accessibility_event(&app, "session_started", serde_json::json!(response.result));
Ok(response)
}
/// Stop the active accessibility session.
#[tauri::command]
pub async fn openhuman_accessibility_stop_session(
app: tauri::AppHandle,
params: Option<StopSessionParams>,
) -> Result<CommandResponse<SessionStatus>, String> {
let response: CommandResponse<SessionStatus> = call_core(
&app,
"openhuman.accessibility_stop_session",
serde_json::json!(params.unwrap_or(StopSessionParams { reason: None })),
)
.await?;
emit_accessibility_event(&app, "session_stopped", serde_json::json!(response.result));
Ok(response)
}
/// Force an immediate capture sample from the accessibility runtime.
#[tauri::command]
pub async fn openhuman_accessibility_capture_now(
app: tauri::AppHandle,
) -> Result<CommandResponse<CaptureNowResult>, String> {
call_core(&app, "openhuman.accessibility_capture_now", params_none()).await
}
/// Execute a validated input action in an active accessibility session.
#[tauri::command]
pub async fn openhuman_accessibility_input_action(
app: tauri::AppHandle,
params: InputActionParams,
) -> Result<CommandResponse<InputActionResult>, String> {
let response: CommandResponse<InputActionResult> = call_core(
&app,
"openhuman.accessibility_input_action",
serde_json::json!(params),
)
.await?;
if response.result.accepted {
emit_accessibility_event(&app, "input_action", serde_json::json!(response.result));
}
Ok(response)
}
/// Generate autocomplete suggestions from captured typing context.
#[tauri::command]
pub async fn openhuman_accessibility_autocomplete_suggest(
app: tauri::AppHandle,
params: Option<AutocompleteSuggestParams>,
) -> Result<CommandResponse<AutocompleteSuggestResult>, String> {
call_core(
&app,
"openhuman.accessibility_autocomplete_suggest",
serde_json::json!(params.unwrap_or(AutocompleteSuggestParams {
context: None,
max_results: None,
})),
)
.await
}
/// Commit an autocomplete suggestion into the runtime context.
#[tauri::command]
pub async fn openhuman_accessibility_autocomplete_commit(
app: tauri::AppHandle,
params: AutocompleteCommitParams,
) -> Result<CommandResponse<AutocompleteCommitResult>, String> {
call_core(
&app,
"openhuman.accessibility_autocomplete_commit",
serde_json::json!(params),
)
.await
}
#[tauri::command]
pub async fn openhuman_local_ai_status(
app: tauri::AppHandle,
+8
View File
@@ -1068,6 +1068,14 @@ pub fn run() {
openhuman_get_runtime_flags,
openhuman_set_browser_allow_all,
openhuman_agent_chat,
openhuman_accessibility_status,
openhuman_accessibility_request_permissions,
openhuman_accessibility_start_session,
openhuman_accessibility_stop_session,
openhuman_accessibility_capture_now,
openhuman_accessibility_input_action,
openhuman_accessibility_autocomplete_suggest,
openhuman_accessibility_autocomplete_commit,
openhuman_local_ai_status,
openhuman_local_ai_download,
openhuman_local_ai_summarize,
+17
View File
@@ -73,6 +73,23 @@ const SettingsHome = () => {
// Main settings menu items
const mainMenuItems = [
{
id: 'accessibility',
title: 'Accessibility Automation',
description: 'Screen monitoring, device control, and predictive input',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-7 9h8a2 2 0 002-2V7a2 2 0 00-2-2h-1l-.707-.707A1 1 0 0013.586 4h-3.172a1 1 0 00-.707.293L9 5H8a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
),
onClick: () => navigateToSettings('accessibility'),
dangerous: false,
},
// {
// id: "messaging",
// title: "Messaging",
@@ -13,6 +13,7 @@ export type SettingsRoute =
| 'team-members'
| 'team-invites'
| 'developer-options'
| 'accessibility'
| 'skills'
| 'ai'
| 'local-model'
@@ -49,6 +50,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
if (path.includes('/settings/advanced')) return 'advanced';
if (path.includes('/settings/billing')) return 'billing';
if (path.includes('/settings/developer-options')) return 'developer-options';
if (path.includes('/settings/accessibility')) return 'accessibility';
if (path.includes('/settings/skills')) return 'skills';
if (path.includes('/settings/ai')) return 'ai';
if (path.includes('/settings/local-model')) return 'local-model';
@@ -0,0 +1,226 @@
import { useEffect, useMemo, useState } from 'react';
import {
fetchAccessibilityStatus,
requestAccessibilityPermissions,
startAccessibilitySession,
stopAccessibilitySession,
} from '../../../store/accessibilitySlice';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const formatRemaining = (remainingMs: number | null): string => {
if (remainingMs === null || remainingMs <= 0) {
return '00:00';
}
const totalSeconds = Math.floor(remainingMs / 1000);
const mins = Math.floor(totalSeconds / 60)
.toString()
.padStart(2, '0');
const secs = (totalSeconds % 60).toString().padStart(2, '0');
return `${mins}:${secs}`;
};
const PermissionBadge = ({ label, value }: { label: string; value: string }) => {
const colorClass =
value === 'granted'
? 'bg-green-900/40 text-green-300 border-green-700/40'
: value === 'denied'
? 'bg-red-900/40 text-red-300 border-red-700/40'
: 'bg-stone-800/60 text-stone-300 border-stone-700';
return (
<div className="flex items-center justify-between rounded-xl border border-stone-700 bg-stone-900/50 p-3">
<span className="text-sm text-stone-200">{label}</span>
<span className={`rounded-md border px-2 py-1 text-xs uppercase tracking-wide ${colorClass}`}>
{value}
</span>
</div>
);
};
const AccessibilityPanel = () => {
const { navigateBack } = useSettingsNavigation();
const dispatch = useAppDispatch();
const {
status,
isLoading,
isRequestingPermissions,
isStartingSession,
isStoppingSession,
lastError,
} = useAppSelector(state => state.accessibility);
const [featureOverrides, setFeatureOverrides] = useState<{
screen_monitoring?: boolean;
device_control?: boolean;
predictive_input?: boolean;
}>({});
useEffect(() => {
void dispatch(fetchAccessibilityStatus());
}, [dispatch]);
useEffect(() => {
if (!status?.session.active) {
return;
}
const intervalId = window.setInterval(() => {
void dispatch(fetchAccessibilityStatus());
}, 1000);
return () => window.clearInterval(intervalId);
}, [dispatch, status?.session.active]);
const screenMonitoring =
featureOverrides.screen_monitoring ?? status?.features.screen_monitoring ?? true;
const deviceControl = featureOverrides.device_control ?? status?.features.device_control ?? true;
const predictiveInput =
featureOverrides.predictive_input ?? status?.features.predictive_input ?? true;
const remaining = useMemo(
() => formatRemaining(status?.session.remaining_ms ?? null),
[status?.session.remaining_ms]
);
const startDisabled =
isStartingSession ||
isLoading ||
!status?.platform_supported ||
status.session.active ||
status.permissions.accessibility !== 'granted';
const stopDisabled = isStoppingSession || !status?.session.active;
return (
<div className="overflow-hidden h-full flex flex-col z-10 relative">
<SettingsHeader
title="Accessibility Automation"
showBackButton={true}
onBack={navigateBack}
/>
<div className="flex-1 overflow-y-auto max-w-2xl mx-auto w-full p-4 space-y-4">
<section className="rounded-2xl border border-stone-700 bg-black/30 p-4 space-y-3">
<h3 className="text-sm font-semibold text-white">Permissions</h3>
<PermissionBadge
label="Screen Recording"
value={status?.permissions.screen_recording ?? 'unknown'}
/>
<PermissionBadge
label="Accessibility"
value={status?.permissions.accessibility ?? 'unknown'}
/>
<PermissionBadge
label="Input Monitoring"
value={status?.permissions.input_monitoring ?? 'unknown'}
/>
<button
type="button"
onClick={() => void dispatch(requestAccessibilityPermissions())}
disabled={isRequestingPermissions}
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…' : 'Check / Request Permissions'}
</button>
</section>
<section className="rounded-2xl border border-stone-700 bg-black/30 p-4 space-y-3">
<h3 className="text-sm font-semibold text-white">Features</h3>
<label className="flex items-center justify-between rounded-xl border border-stone-700 bg-stone-900/50 px-3 py-2">
<span className="text-sm text-stone-200">Screen Monitoring</span>
<input
type="checkbox"
checked={screenMonitoring}
onChange={event =>
setFeatureOverrides(current => ({
...current,
screen_monitoring: event.target.checked,
}))
}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-700 bg-stone-900/50 px-3 py-2">
<span className="text-sm text-stone-200">Device Control</span>
<input
type="checkbox"
checked={deviceControl}
onChange={event =>
setFeatureOverrides(current => ({
...current,
device_control: event.target.checked,
}))
}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-stone-700 bg-stone-900/50 px-3 py-2">
<span className="text-sm text-stone-200">Predictive Input</span>
<input
type="checkbox"
checked={predictiveInput}
onChange={event =>
setFeatureOverrides(current => ({
...current,
predictive_input: event.target.checked,
}))
}
/>
</label>
</section>
<section className="rounded-2xl border border-stone-700 bg-black/30 p-4 space-y-3">
<h3 className="text-sm font-semibold text-white">Session</h3>
<div className="text-sm text-stone-300 space-y-1">
<div>Status: {status?.session.active ? 'Active' : 'Stopped'}</div>
<div>Remaining: {remaining}</div>
<div>Frames (ephemeral): {status?.session.frames_in_memory ?? 0}</div>
<div>Panic stop: {status?.session.panic_hotkey ?? 'Cmd+Shift+.'}</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
void dispatch(
startAccessibilitySession({
consent: true,
ttl_secs: status?.config.session_ttl_secs ?? 300,
screen_monitoring: screenMonitoring,
device_control: deviceControl,
predictive_input: predictiveInput,
})
)
}
disabled={startDisabled}
className="rounded-lg border border-green-500/60 bg-green-500/20 px-3 py-2 text-sm text-green-200 disabled:opacity-50">
{isStartingSession ? 'Starting…' : 'Start Session'}
</button>
<button
type="button"
onClick={() => void dispatch(stopAccessibilitySession('manual_stop'))}
disabled={stopDisabled}
className="rounded-lg border border-red-500/60 bg-red-500/20 px-3 py-2 text-sm text-red-200 disabled:opacity-50">
{isStoppingSession ? 'Stopping…' : 'Stop Session'}
</button>
</div>
</section>
{!status?.platform_supported && (
<div className="rounded-xl border border-amber-700/40 bg-amber-900/20 p-3 text-sm text-amber-200">
Accessibility Automation V1 is currently supported on macOS only.
</div>
)}
{lastError && (
<div className="rounded-xl border border-red-700/40 bg-red-900/20 p-3 text-sm text-red-200">
{lastError}
</div>
)}
</div>
</div>
);
};
export default AccessibilityPanel;
@@ -0,0 +1,85 @@
import { configureStore } from '@reduxjs/toolkit';
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import accessibilityReducer from '../../../../store/accessibilitySlice';
import authReducer from '../../../../store/authSlice';
import socketReducer from '../../../../store/socketSlice';
import teamReducer from '../../../../store/teamSlice';
import userReducer from '../../../../store/userSlice';
import type { AccessibilityStatus } from '../../../../utils/tauriCommands';
import AccessibilityPanel from '../AccessibilityPanel';
const status: AccessibilityStatus = {
platform_supported: true,
permissions: {
screen_recording: 'unknown',
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,
},
config: {
capture_policy: 'hybrid',
baseline_fps: 1,
session_ttl_secs: 300,
panic_stop_hotkey: 'Cmd+Shift+.',
autocomplete_enabled: true,
denylist: ['wallet'],
},
denylist: ['wallet'],
is_context_blocked: false,
};
const createStore = () =>
configureStore({
reducer: {
auth: authReducer,
socket: socketReducer,
user: userReducer,
team: teamReducer,
accessibility: accessibilityReducer,
},
preloadedState: {
accessibility: {
status,
isLoading: false,
isRequestingPermissions: false,
isStartingSession: false,
isStoppingSession: false,
lastError: null,
},
},
});
describe('AccessibilityPanel', () => {
it('renders permission and session sections', () => {
const store = createStore();
render(
<Provider store={store}>
<MemoryRouter initialEntries={['/settings/accessibility']}>
<AccessibilityPanel />
</MemoryRouter>
</Provider>
);
expect(screen.getByText('Accessibility Automation')).toBeInTheDocument();
expect(screen.getByText('Permissions')).toBeInTheDocument();
expect(screen.getByText('Session')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Start Session' })).toBeInTheDocument();
});
});
+2
View File
@@ -1,5 +1,6 @@
import { Route, Routes } from 'react-router-dom';
import AccessibilityPanel from '../components/settings/panels/AccessibilityPanel';
import AdvancedPanel from '../components/settings/panels/AdvancedPanel';
import AgentChatPanel from '../components/settings/panels/AgentChatPanel';
import AIPanel from '../components/settings/panels/AIPanel';
@@ -31,6 +32,7 @@ const Settings = () => {
<Route path="advanced" element={<AdvancedPanel />} />
<Route path="agent-chat" element={<AgentChatPanel />} />
<Route path="ai" element={<AIPanel />} />
<Route path="accessibility" element={<AccessibilityPanel />} />
<Route path="local-model" element={<LocalModelPanel />} />
<Route path="billing" element={<BillingPanel />} />
<Route path="skills" element={<SkillsPanel />} />
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import type { AccessibilityStatus } from '../../utils/tauriCommands';
import reducer, {
clearAccessibilityError,
fetchAccessibilityStatus,
setAccessibilityStatus,
startAccessibilitySession,
stopAccessibilitySession,
} from '../accessibilitySlice';
const sampleStatus: AccessibilityStatus = {
platform_supported: true,
permissions: {
screen_recording: 'granted',
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,
},
config: {
capture_policy: 'hybrid',
baseline_fps: 1,
session_ttl_secs: 300,
panic_stop_hotkey: 'Cmd+Shift+.',
autocomplete_enabled: true,
denylist: ['wallet'],
},
denylist: ['wallet'],
is_context_blocked: false,
};
describe('accessibilitySlice', () => {
it('has expected initial state', () => {
const state = reducer(undefined, { type: '@@INIT' });
expect(state.status).toBeNull();
expect(state.isLoading).toBe(false);
expect(state.lastError).toBeNull();
});
it('stores status payload', () => {
const state = reducer(undefined, setAccessibilityStatus(sampleStatus));
expect(state.status?.platform_supported).toBe(true);
expect(state.status?.config.capture_policy).toBe('hybrid');
});
it('tracks fetch lifecycle', () => {
const pending = reducer(undefined, { type: fetchAccessibilityStatus.pending.type });
expect(pending.isLoading).toBe(true);
const fulfilled = reducer(
pending,
fetchAccessibilityStatus.fulfilled(sampleStatus, 'req-1', undefined)
);
expect(fulfilled.isLoading).toBe(false);
expect(fulfilled.status?.permissions.accessibility).toBe('granted');
});
it('tracks session start/stop async flags', () => {
const starting = reducer(undefined, { type: startAccessibilitySession.pending.type });
expect(starting.isStartingSession).toBe(true);
const started = reducer(
starting,
startAccessibilitySession.fulfilled(sampleStatus, 'req-2', { consent: true })
);
expect(started.isStartingSession).toBe(false);
const stopping = reducer(started, { type: stopAccessibilitySession.pending.type });
expect(stopping.isStoppingSession).toBe(true);
});
it('clears errors', () => {
const errored = reducer(undefined, {
type: fetchAccessibilityStatus.rejected.type,
payload: 'boom',
});
expect(errored.lastError).toBe('boom');
const cleared = reducer(errored, clearAccessibilityError());
expect(cleared.lastError).toBeNull();
});
});
+187
View File
@@ -0,0 +1,187 @@
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import {
type AccessibilityInputActionParams,
type AccessibilitySessionStatus,
type AccessibilityStatus,
openhumanAccessibilityInputAction,
openhumanAccessibilityRequestPermissions,
openhumanAccessibilityStartSession,
openhumanAccessibilityStatus,
openhumanAccessibilityStopSession,
} from '../utils/tauriCommands';
interface AccessibilityState {
status: AccessibilityStatus | null;
isLoading: boolean;
isRequestingPermissions: boolean;
isStartingSession: boolean;
isStoppingSession: boolean;
lastError: string | null;
}
const initialState: AccessibilityState = {
status: null,
isLoading: false,
isRequestingPermissions: false,
isStartingSession: false,
isStoppingSession: false,
lastError: null,
};
const extractError = (error: unknown, fallback: string): string => {
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return fallback;
};
export const fetchAccessibilityStatus = createAsyncThunk(
'accessibility/fetchStatus',
async (_, { rejectWithValue }) => {
try {
const response = await openhumanAccessibilityStatus();
return response.result;
} catch (error) {
return rejectWithValue(extractError(error, 'Failed to fetch accessibility status'));
}
}
);
export const requestAccessibilityPermissions = createAsyncThunk(
'accessibility/requestPermissions',
async (_, { rejectWithValue }) => {
try {
await openhumanAccessibilityRequestPermissions();
const response = await openhumanAccessibilityStatus();
return response.result;
} catch (error) {
return rejectWithValue(extractError(error, 'Failed to request accessibility permissions'));
}
}
);
export const startAccessibilitySession = createAsyncThunk(
'accessibility/startSession',
async (
params: {
consent: boolean;
ttl_secs?: number;
screen_monitoring?: boolean;
device_control?: boolean;
predictive_input?: boolean;
},
{ rejectWithValue }
) => {
try {
await openhumanAccessibilityStartSession(params);
const response = await openhumanAccessibilityStatus();
return response.result;
} catch (error) {
return rejectWithValue(extractError(error, 'Failed to start accessibility session'));
}
}
);
export const stopAccessibilitySession = createAsyncThunk(
'accessibility/stopSession',
async (reason: string | undefined, { rejectWithValue }) => {
try {
await openhumanAccessibilityStopSession(reason ? { reason } : undefined);
const response = await openhumanAccessibilityStatus();
return response.result;
} catch (error) {
return rejectWithValue(extractError(error, 'Failed to stop accessibility session'));
}
}
);
export const executeAccessibilityInputAction = createAsyncThunk(
'accessibility/inputAction',
async (params: AccessibilityInputActionParams, { rejectWithValue }) => {
try {
const response = await openhumanAccessibilityInputAction(params);
return response.result;
} catch (error) {
return rejectWithValue(extractError(error, 'Failed to execute input action'));
}
}
);
const accessibilitySlice = createSlice({
name: 'accessibility',
initialState,
reducers: {
clearAccessibilityError(state) {
state.lastError = null;
},
setAccessibilityStatus(state, action: PayloadAction<AccessibilityStatus | null>) {
state.status = action.payload;
},
setAccessibilitySessionFeatures(state, action: PayloadAction<AccessibilitySessionStatus>) {
if (state.status) {
state.status.session = action.payload;
}
},
},
extraReducers: builder => {
builder
.addCase(fetchAccessibilityStatus.pending, state => {
state.isLoading = true;
state.lastError = null;
})
.addCase(fetchAccessibilityStatus.fulfilled, (state, action) => {
state.isLoading = false;
state.status = action.payload;
})
.addCase(fetchAccessibilityStatus.rejected, (state, action) => {
state.isLoading = false;
state.lastError = (action.payload as string) ?? 'Failed to fetch accessibility status';
})
.addCase(requestAccessibilityPermissions.pending, state => {
state.isRequestingPermissions = true;
state.lastError = null;
})
.addCase(requestAccessibilityPermissions.fulfilled, (state, action) => {
state.isRequestingPermissions = false;
state.status = action.payload;
})
.addCase(requestAccessibilityPermissions.rejected, (state, action) => {
state.isRequestingPermissions = false;
state.lastError =
(action.payload as string) ?? 'Failed to request accessibility permissions';
})
.addCase(startAccessibilitySession.pending, state => {
state.isStartingSession = true;
state.lastError = null;
})
.addCase(startAccessibilitySession.fulfilled, (state, action) => {
state.isStartingSession = false;
state.status = action.payload;
})
.addCase(startAccessibilitySession.rejected, (state, action) => {
state.isStartingSession = false;
state.lastError = (action.payload as string) ?? 'Failed to start accessibility session';
})
.addCase(stopAccessibilitySession.pending, state => {
state.isStoppingSession = true;
state.lastError = null;
})
.addCase(stopAccessibilitySession.fulfilled, (state, action) => {
state.isStoppingSession = false;
state.status = action.payload;
})
.addCase(stopAccessibilitySession.rejected, (state, action) => {
state.isStoppingSession = false;
state.lastError = (action.payload as string) ?? 'Failed to stop accessibility session';
})
.addCase(executeAccessibilityInputAction.rejected, (state, action) => {
state.lastError = (action.payload as string) ?? 'Failed to execute accessibility action';
});
},
});
export const { clearAccessibilityError, setAccessibilitySessionFeatures, setAccessibilityStatus } =
accessibilitySlice.actions;
export default accessibilitySlice.reducer;
+2
View File
@@ -15,6 +15,7 @@ import storage from 'redux-persist/lib/storage';
import { setStoreForApiClient } from '../services/apiClient';
import { IS_DEV } from '../utils/config';
import { storeSession, syncMemoryClientToken } from '../utils/tauriCommands';
import accessibilityReducer from './accessibilitySlice';
import aiReducer from './aiSlice';
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
import daemonReducer from './daemonSlice';
@@ -108,6 +109,7 @@ export const store = configureStore({
intelligence: intelligenceReducer,
invite: inviteReducer,
notion: notionReducer,
accessibility: accessibilityReducer,
},
middleware: getDefaultMiddleware => {
const middleware = getDefaultMiddleware({
+180
View File
@@ -383,6 +383,114 @@ export interface AgentServerStatus {
url: string;
}
export type AccessibilityPermissionState = 'granted' | 'denied' | 'unknown' | 'unsupported';
export interface AccessibilityPermissionStatus {
screen_recording: AccessibilityPermissionState;
accessibility: AccessibilityPermissionState;
input_monitoring: AccessibilityPermissionState;
}
export interface AccessibilityFeatures {
screen_monitoring: boolean;
device_control: boolean;
predictive_input: boolean;
}
export interface AccessibilitySessionStatus {
active: boolean;
started_at_ms: number | null;
expires_at_ms: number | null;
remaining_ms: number | null;
ttl_secs: number;
panic_hotkey: string;
stop_reason: string | null;
frames_in_memory: number;
last_capture_at_ms: number | null;
last_context: string | null;
}
export interface AccessibilityConfig {
capture_policy: string;
baseline_fps: number;
session_ttl_secs: number;
panic_stop_hotkey: string;
autocomplete_enabled: boolean;
denylist: string[];
}
export interface AccessibilityStatus {
platform_supported: boolean;
permissions: AccessibilityPermissionStatus;
features: AccessibilityFeatures;
session: AccessibilitySessionStatus;
config: AccessibilityConfig;
denylist: string[];
is_context_blocked: boolean;
}
export interface AccessibilityStartSessionParams {
consent: boolean;
ttl_secs?: number;
screen_monitoring?: boolean;
device_control?: boolean;
predictive_input?: boolean;
}
export interface AccessibilityStopSessionParams {
reason?: string;
}
export interface AccessibilityCaptureFrame {
captured_at_ms: number;
reason: string;
app_name: string | null;
window_title: string | null;
}
export interface AccessibilityCaptureNowResult {
accepted: boolean;
frame: AccessibilityCaptureFrame | null;
}
export interface AccessibilityInputActionParams {
action: string;
x?: number;
y?: number;
button?: string;
text?: string;
key?: string;
modifiers?: string[];
}
export interface AccessibilityInputActionResult {
accepted: boolean;
blocked: boolean;
reason: string | null;
}
export interface AccessibilityAutocompleteSuggestion {
value: string;
confidence: number;
}
export interface AccessibilityAutocompleteSuggestParams {
context?: string;
max_results?: number;
}
export interface AccessibilityAutocompleteSuggestResult {
suggestions: AccessibilityAutocompleteSuggestion[];
}
export interface AccessibilityAutocompleteCommitParams {
suggestion: string;
}
export interface AccessibilityAutocompleteCommitResult {
committed: boolean;
}
export interface ConfigSnapshot {
config: Record<string, unknown>;
workspace_dir: string;
@@ -764,6 +872,78 @@ export async function openhumanAgentServerStatus(): Promise<CommandResponse<Agen
return await invoke('openhuman_agent_server_status');
}
export async function openhumanAccessibilityStatus(): Promise<
CommandResponse<AccessibilityStatus>
> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('openhuman_accessibility_status');
}
export async function openhumanAccessibilityRequestPermissions(): Promise<
CommandResponse<AccessibilityPermissionStatus>
> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('openhuman_accessibility_request_permissions');
}
export async function openhumanAccessibilityStartSession(
params: AccessibilityStartSessionParams
): Promise<CommandResponse<AccessibilitySessionStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('openhuman_accessibility_start_session', { params });
}
export async function openhumanAccessibilityStopSession(
params?: AccessibilityStopSessionParams
): Promise<CommandResponse<AccessibilitySessionStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('openhuman_accessibility_stop_session', { params: params ?? null });
}
export async function openhumanAccessibilityCaptureNow(): Promise<
CommandResponse<AccessibilityCaptureNowResult>
> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('openhuman_accessibility_capture_now');
}
export async function openhumanAccessibilityInputAction(
params: AccessibilityInputActionParams
): Promise<CommandResponse<AccessibilityInputActionResult>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('openhuman_accessibility_input_action', { params });
}
export async function openhumanAccessibilityAutocompleteSuggest(
params?: AccessibilityAutocompleteSuggestParams
): Promise<CommandResponse<AccessibilityAutocompleteSuggestResult>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('openhuman_accessibility_autocomplete_suggest', { params: params ?? null });
}
export async function openhumanAccessibilityAutocompleteCommit(
params: AccessibilityAutocompleteCommitParams
): Promise<CommandResponse<AccessibilityAutocompleteCommitResult>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('openhuman_accessibility_autocomplete_commit', { params });
}
export async function runtimeListSkills(): Promise<SkillSnapshot[]> {
if (!isTauri()) {
throw new Error('Not running in Tauri');