use std::path::{Path, PathBuf};
use crate::app::{self, AppTarget};
use crate::config;
use crate::controller::{
self, AppStateProbe, MacAppStateProbe, current_process_info, grandparent_process_info,
parent_process_info,
};
use accessibility::attribute::AXUIElementAttributes;
use accessibility::ui_element::AXUIElement;
use accessibility_sys::{
AXAPIEnabled, AXIsProcessTrusted, AXIsProcessTrustedWithOptions, kAXTrustedCheckOptionPrompt,
};
use core_foundation::base::TCFType;
use core_foundation::boolean::CFBoolean;
use core_foundation::dictionary::CFDictionary;
use core_foundation::string::CFString;
#[derive(Debug)]
pub struct DoctorResult {
pub checks: usize,
pub passed: usize,
pub warnings: usize,
pub failures: usize,
}
impl DoctorResult {
#[must_use]
pub fn new() -> Self {
Self {
checks: 0,
passed: 0,
warnings: 0,
failures: 0,
}
}
pub fn pass(&mut self) {
self.checks += 1;
self.passed += 1;
}
pub fn warn(&mut self) {
self.checks += 1;
self.warnings += 1;
}
pub fn fail(&mut self) {
self.checks += 1;
self.failures += 1;
}
#[must_use]
pub fn is_ok(&self) -> bool {
self.failures == 0
}
}
impl Default for DoctorResult {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct DoctorOptions<'a> {
pub request_accessibility: bool,
pub target: Option<&'a AppTarget>,
}
pub fn run_doctor(options: DoctorOptions<'_>) -> DoctorResult {
controller::with_autorelease_pool(|| {
let mut result = DoctorResult::new();
let config_path = check_config_path(&mut result);
let config = check_config_file(&config_path, &mut result);
if let Some(config) = config.as_ref() {
check_bindings(config, &mut result);
}
check_accessibility_permission(options.request_accessibility, &mut result);
check_accessibility_smoke(options.target, &mut result);
result
})
}
fn check_config_path(result: &mut DoctorResult) -> Option<PathBuf> {
match config::config_path() {
Ok(path) => {
println!("Config path: {}", path.display());
result.pass();
Some(path)
}
Err(err) => {
println!("Config path: could not resolve — {err}");
result.fail();
None
}
}
}
fn check_config_file(path: &Option<PathBuf>, result: &mut DoctorResult) -> Option<config::Config> {
let path = match path {
Some(p) => p,
None => {
println!(" Config file: skipped (path not resolved)");
return None;
}
};
if !path.exists() {
println!(" Config file: not found at {}", path.display());
println!(" Create one with:");
println!(" mkdir -p ~/.config/summon");
println!(" $EDITOR ~/.config/summon/summon.toml");
result.fail();
return None;
}
match config::load_from(path) {
Ok(config) => {
let count = config.bindings.len();
println!(" Config file: valid ({} binding(s))", count);
result.pass();
Some(config)
}
Err(err) => {
println!(" Config file: invalid — {err}");
result.fail();
None
}
}
}
pub(crate) fn check_bindings(config: &config::Config, result: &mut DoctorResult) {
if config.bindings.is_empty() {
println!(" Bindings: none configured");
return;
}
for (name, binding) in &config.bindings {
match app::classify_app_target(&binding.app) {
Ok(target) => {
let target_label = format_target_label(&target);
if let AppTarget::AppPath(p) = &target {
let expanded = if p.starts_with('~') {
shellexpand_home(p)
} else {
p.clone()
};
if Path::new(&expanded).exists() {
println!(" Binding \"{name}\": {target_label} (exists)");
result.pass();
} else {
println!(" Binding \"{name}\": {target_label} (path does not exist)");
result.warn();
}
} else {
println!(" Binding \"{name}\": {target_label}");
result.pass();
}
}
Err(err) => {
println!(" Binding \"{name}\": invalid — {err}");
result.fail();
}
}
}
}
fn format_target_label(target: &AppTarget) -> String {
match target {
AppTarget::BundleId(id) => format!("bundle identifier \"{id}\""),
AppTarget::AppName(n) => format!("app name \"{n}\""),
AppTarget::AppPath(p) => format!("app path \"{p}\""),
}
}
pub(crate) fn check_accessibility_permission(
request_accessibility: bool,
result: &mut DoctorResult,
) {
println!(
" Current executable: {}",
std::env::current_exe()
.map(|path| path.display().to_string())
.unwrap_or_else(|_| "unknown".to_string())
);
print_process("Current process", current_process_info());
print_process("Parent process", parent_process_info());
print_process("Grandparent process", grandparent_process_info());
let trusted = if request_accessibility {
ax_is_process_trusted_with_prompt()
} else {
ax_is_process_trusted()
};
if trusted {
println!(" Accessibility (AXIsProcessTrusted): granted");
result.pass();
} else {
println!(" Accessibility (AXIsProcessTrusted): denied");
println!(" AXIsProcessTrusted checks the current process, not an arbitrary parent.");
println!(
" Likely process to enable: {}",
likely_accessibility_process()
);
println!(" Open: System Settings -> Privacy & Security -> Accessibility");
result.fail();
}
}
fn check_accessibility_smoke(target: Option<&AppTarget>, result: &mut DoctorResult) {
if ax_api_enabled() {
println!(" Accessibility API enabled: yes");
result.pass();
} else {
println!(" Accessibility API enabled: no");
result.fail();
}
let system = AXUIElement::system_wide();
match system.attribute(&accessibility::attribute::AXAttribute::new(
&CFString::from_static_string("AXFocusedApplication"),
)) {
Ok(_app) => {
println!(" Frontmost application AX: readable");
result.pass();
}
Err(err) => {
let message = err.to_string();
if message.contains("APIDisabled") || message.contains("API disabled") {
println!(" Frontmost application AX: failed with APIDisabled");
result.fail();
} else {
println!(" Frontmost application AX: could not read — {err}");
result.warn();
}
}
}
if let Some(target) = target {
check_target_windows(target, result);
} else {
check_finder_windows(result);
}
}
fn check_target_windows(target: &AppTarget, result: &mut DoctorResult) {
let probe = MacAppStateProbe::new();
match probe.pid_for_target(target) {
Ok(pid) => {
println!(
" {} process: found pid {pid}",
controller::target_display(target)
);
result.pass();
check_windows_for_pid(&controller::target_display(target), pid, result);
}
Err(err) => {
println!(" {} process: {err}", controller::target_display(target));
result.fail();
}
}
}
fn check_finder_windows(result: &mut DoctorResult) {
check_windows_for_bundle_id("Finder", "com.apple.finder", result);
}
fn check_windows_for_bundle_id(label: &str, bundle_id: &str, result: &mut DoctorResult) {
let probe = MacAppStateProbe::new();
let target = AppTarget::BundleId(bundle_id.to_string());
match probe.pid_for_target(&target) {
Ok(pid) => check_windows_for_pid(label, pid, result),
Err(err) => {
println!(" {label} AX windows: skipped — {err}");
result.warn();
}
}
}
fn check_windows_for_pid(label: &str, pid: i32, result: &mut DoctorResult) {
let app = AXUIElement::application(pid);
match app.windows() {
Ok(windows) => {
println!(" {label} AX windows: {} reported", windows.len());
if let Ok(window) = app.focused_window() {
let title = window
.title()
.map(|title| title.to_string())
.unwrap_or_else(|_| "untitled".to_string());
println!(" {label} current window: {title}");
}
result.pass();
}
Err(err) => {
let message = err.to_string();
if message.contains("APIDisabled") || message.contains("API disabled") {
println!(" {label} AX windows: failed with APIDisabled");
result.fail();
} else {
println!(" {label} AX windows: could not enumerate — {err}");
result.warn();
}
}
}
}
fn print_process(label: &str, info: Option<controller::ProcessInfo>) {
match info {
Some(info) => println!(" {label}: {} (pid {})", info.name, info.pid),
None => println!(" {label}: unknown"),
}
}
fn likely_accessibility_process() -> String {
parent_process_info()
.map(|info| info.name)
.unwrap_or_else(|| "the terminal, launcher, or hotkey daemon that invokes summon".into())
}
fn ax_api_enabled() -> bool {
unsafe { AXAPIEnabled() }
}
fn ax_is_process_trusted() -> bool {
unsafe { AXIsProcessTrusted() }
}
fn ax_is_process_trusted_with_prompt() -> bool {
let key = unsafe { CFString::wrap_under_get_rule(kAXTrustedCheckOptionPrompt) };
let value = CFBoolean::true_value();
let options = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), value.as_CFType())]);
unsafe { AXIsProcessTrustedWithOptions(options.as_concrete_TypeRef()) }
}
fn shellexpand_home(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return format!("{home}/{rest}");
}
} else if let Some(rest) = path.strip_prefix('~') {
#[allow(clippy::collapsible_if)]
if let Ok(home) = std::env::var("HOME") {
return format!("{home}{rest}");
}
}
path.to_string()
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn doctor_result_new_is_empty() {
let r = DoctorResult::new();
assert_eq!(r.checks, 0);
assert_eq!(r.passed, 0);
assert_eq!(r.warnings, 0);
assert_eq!(r.failures, 0);
assert!(r.is_ok());
}
#[test]
fn doctor_result_pass_increments() {
let mut r = DoctorResult::new();
r.pass();
assert_eq!(r.checks, 1);
assert_eq!(r.passed, 1);
assert!(r.is_ok());
}
#[test]
fn doctor_result_warn_increments() {
let mut r = DoctorResult::new();
r.warn();
assert_eq!(r.checks, 1);
assert_eq!(r.warnings, 1);
assert!(r.is_ok()); }
#[test]
fn doctor_result_fail_increments() {
let mut r = DoctorResult::new();
r.fail();
assert_eq!(r.checks, 1);
assert_eq!(r.failures, 1);
assert!(!r.is_ok());
}
#[test]
fn doctor_result_mixed() {
let mut r = DoctorResult::new();
r.pass();
r.pass();
r.warn();
r.fail();
assert_eq!(r.checks, 4);
assert_eq!(r.passed, 2);
assert_eq!(r.warnings, 1);
assert_eq!(r.failures, 1);
assert!(!r.is_ok());
}
#[test]
fn shellexpand_home_without_tilde() {
assert_eq!(
shellexpand_home("/Applications/Safari.app"),
"/Applications/Safari.app"
);
}
#[test]
fn format_label_bundle_id() {
let target = AppTarget::BundleId("com.apple.finder".into());
assert_eq!(
format_target_label(&target),
"bundle identifier \"com.apple.finder\""
);
}
#[test]
fn format_label_app_name() {
let target = AppTarget::AppName("Preview".into());
assert_eq!(format_target_label(&target), "app name \"Preview\"");
}
#[test]
fn format_label_app_path() {
let target = AppTarget::AppPath("/Applications/Safari.app".into());
assert_eq!(
format_target_label(&target),
"app path \"/Applications/Safari.app\""
);
}
#[test]
fn check_config_file_missing() {
let mut result = DoctorResult::new();
let path = PathBuf::from("/tmp/summon_test_nonexistent/summon.toml");
let config = check_config_file(&Some(path), &mut result);
assert!(config.is_none());
assert_eq!(result.failures, 1);
assert_eq!(result.checks, 1);
}
#[test]
fn check_config_file_valid() {
let dir = std::env::temp_dir().join("summon_test_doctor_check_valid");
let summon_dir = dir.join("summon");
std::fs::create_dir_all(&summon_dir).unwrap();
let path = summon_dir.join("summon.toml");
std::fs::write(&path, "[bindings.finder]\napp = \"com.apple.finder\"\n").unwrap();
let mut result = DoctorResult::new();
let config = check_config_file(&Some(path), &mut result);
assert!(config.is_some());
assert_eq!(result.passed, 1);
assert_eq!(result.failures, 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn check_config_file_invalid_toml() {
let dir = std::env::temp_dir().join("summon_test_doctor_check_invalid");
let summon_dir = dir.join("summon");
std::fs::create_dir_all(&summon_dir).unwrap();
let path = summon_dir.join("summon.toml");
std::fs::write(&path, "[bindings.broken]\ncycle_when_focused = true\n").unwrap();
let mut result = DoctorResult::new();
let config = check_config_file(&Some(path), &mut result);
assert!(config.is_none());
assert_eq!(result.failures, 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn check_config_file_none_path() {
let mut result = DoctorResult::new();
let config = check_config_file(&None, &mut result);
assert!(config.is_none());
assert_eq!(result.checks, 0); }
#[test]
fn check_bindings_valid_bundle_id() {
let config = config::parse("[bindings.finder]\napp = \"com.apple.finder\"\n").unwrap();
let mut result = DoctorResult::new();
check_bindings(&config, &mut result);
assert_eq!(result.passed, 1);
assert_eq!(result.failures, 0);
}
#[test]
fn check_bindings_valid_app_name() {
let config = config::parse("[bindings.preview]\napp = \"Preview\"\n").unwrap();
let mut result = DoctorResult::new();
check_bindings(&config, &mut result);
assert_eq!(result.passed, 1);
assert_eq!(result.failures, 0);
}
#[test]
fn check_bindings_missing_app_path_warns() {
let config =
config::parse("[bindings.custom]\napp = \"/Applications/NonExistent.app\"\n").unwrap();
let mut result = DoctorResult::new();
check_bindings(&config, &mut result);
assert_eq!(result.warnings, 1, "missing path should warn: {:?}", result);
assert_eq!(
result.failures, 0,
"missing path should not fail: {:?}",
result
);
}
#[test]
fn check_bindings_invalid_target_fails() {
let toml = "[bindings.bad]\napp = \"/Applications/notanapp\"\n";
let config = config::parse(toml).unwrap();
let mut result = DoctorResult::new();
check_bindings(&config, &mut result);
assert_eq!(
result.failures, 1,
"invalid target should fail: {:?}",
result
);
}
#[test]
fn check_bindings_empty_config() {
let config = config::parse("").unwrap();
let mut result = DoctorResult::new();
check_bindings(&config, &mut result);
assert_eq!(result.checks, 0, "no bindings = no checks");
}
#[test]
fn check_bindings_multiple_mixed() {
let config = config::parse(
r#"
[bindings.finder]
app = "com.apple.finder"
[bindings.preview]
app = "Preview"
[bindings.missing]
app = "/Applications/NonExistent.app"
"#,
)
.unwrap();
let mut result = DoctorResult::new();
check_bindings(&config, &mut result);
assert_eq!(result.passed, 2, "finder + preview should pass");
assert_eq!(result.warnings, 1, "missing path should warn");
assert_eq!(result.failures, 0);
}
}