#![allow(unexpected_cfgs)]
use crate::app::AppTarget;
use crate::config::EffectiveSettings;
use std::ffi::{CStr, CString};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use accessibility::Error as AXError;
use accessibility::action::AXUIElementActions;
use accessibility::attribute::AXUIElementAttributes;
use accessibility::ui_element::AXUIElement;
use objc::runtime::Object;
use objc::{class, msg_send, sel, sel_impl};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[link(name = "AppKit", kind = "framework")]
unsafe extern "C" {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AppAction {
Launch,
Focus,
Cycle,
AlreadyFocused,
LaunchDisabled,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ObservedFrontmost {
Yes,
No,
NotChecked,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DecisionContext {
pub is_running: bool,
pub frontmost: ObservedFrontmost,
pub launch_when_missing: bool,
pub cycle_when_focused: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ObservedAppState {
pub is_running: bool,
pub is_frontmost: bool,
pub pid: Option<i32>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct WindowCycleSnapshot {
pub target: String,
#[serde(default)]
pub pid: i32,
#[serde(default)]
pub current_window_index: Option<usize>,
#[serde(default)]
pub current_cyclable_index: Option<usize>,
#[serde(default)]
pub ordered_cyclable_window_indexes: Vec<usize>,
pub windows: Vec<WindowCycleSnapshotWindow>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
pub struct WindowCycleSnapshotWindow {
pub title: Option<String>,
pub role: Option<String>,
pub minimized: bool,
pub is_main: bool,
#[serde(default)]
pub is_cyclable: bool,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ControllerError {
#[error("Could not launch {target}: {reason}")]
LaunchFailed {
target: String,
reason: String,
},
#[error("Could not focus {target}: {reason}")]
FocusFailed {
target: String,
reason: String,
},
#[error(
"Accessibility permission denied for the current process.\n Current executable: {executable}\n Parent process: {parent}\n Likely process to enable: {likely_process}\n Open: System Settings -> Privacy & Security -> Accessibility"
)]
PermissionDenied {
executable: String,
parent: String,
likely_process: String,
},
#[error("Could not find PID for {target}")]
PidLookupFailed {
target: String,
},
#[error("{target} has no windows to cycle")]
NoWindows {
target: String,
},
#[error("{target} has only one cyclable window, so there is nothing to cycle")]
OnlyOneCyclableWindow {
target: String,
title: Option<String>,
},
#[error(
"No cyclable windows for {target} ({total_windows} total, {rejected_windows} rejected)"
)]
NoCyclableWindows {
target: String,
total_windows: usize,
rejected_windows: usize,
},
#[error("Raised a window for {target}, but macOS did not report it as focused afterwards")]
RaiseVerificationFailed {
target: String,
title: Option<String>,
},
#[error("Accessibility API error: {0}")]
AxApi(String),
}
pub trait AppController {
fn is_running(&self, target: &AppTarget) -> bool;
fn is_frontmost(&self, target: &AppTarget) -> bool;
fn launch(&self, target: &AppTarget) -> Result<(), ControllerError>;
fn focus(&self, target: &AppTarget) -> Result<(), ControllerError>;
fn cycle_window(&self, target: &AppTarget) -> Result<(), ControllerError>;
}
pub fn decide_action(
controller: &dyn AppController,
target: &AppTarget,
settings: &EffectiveSettings,
) -> (AppAction, DecisionContext) {
let observation = ObservedAppState {
is_running: controller.is_running(target),
is_frontmost: controller.is_frontmost(target),
pid: None,
};
decide_action_for_observation(observation, settings)
}
pub fn decide_action_for_observation(
observation: ObservedAppState,
settings: &EffectiveSettings,
) -> (AppAction, DecisionContext) {
let frontmost = if observation.is_running {
if observation.is_frontmost {
ObservedFrontmost::Yes
} else {
ObservedFrontmost::No
}
} else {
ObservedFrontmost::NotChecked
};
let context = DecisionContext {
is_running: observation.is_running,
frontmost,
launch_when_missing: settings.launch_if_not_running,
cycle_when_focused: settings.cycle_when_focused,
};
let action = if !observation.is_running {
if settings.launch_if_not_running {
AppAction::Launch
} else {
AppAction::LaunchDisabled
}
} else if !observation.is_frontmost {
AppAction::Focus
} else if settings.cycle_when_focused {
AppAction::Cycle
} else {
AppAction::AlreadyFocused
};
(action, context)
}
pub fn execute_action(
controller: &dyn AppController,
target: &AppTarget,
action: AppAction,
) -> Result<(), ControllerError> {
match action {
AppAction::Launch => controller.launch(target),
AppAction::Focus => controller.focus(target),
AppAction::Cycle => controller.cycle_window(target),
AppAction::AlreadyFocused | AppAction::LaunchDisabled => Ok(()),
}
}
#[derive(Clone, Debug, Default)]
pub struct FakeAppController {
running: Vec<AppTarget>,
frontmost: Vec<AppTarget>,
}
impl FakeAppController {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn set_running(mut self, target: &AppTarget, running: bool) -> Self {
if running {
if !self.running.contains(target) {
self.running.push(target.clone());
}
} else {
self.running.retain(|t| t != target);
}
self
}
#[must_use]
pub fn set_frontmost(mut self, target: &AppTarget, frontmost: bool) -> Self {
if frontmost {
if !self.frontmost.contains(target) {
self.frontmost.push(target.clone());
}
} else {
self.frontmost.retain(|t| t != target);
}
self
}
}
impl AppController for FakeAppController {
fn is_running(&self, target: &AppTarget) -> bool {
self.running.contains(target)
}
fn is_frontmost(&self, target: &AppTarget) -> bool {
self.frontmost.contains(target)
}
fn launch(&self, _target: &AppTarget) -> Result<(), ControllerError> {
Ok(())
}
fn focus(&self, _target: &AppTarget) -> Result<(), ControllerError> {
Ok(())
}
fn cycle_window(&self, _target: &AppTarget) -> Result<(), ControllerError> {
Ok(())
}
}
pub trait AppStateProbe {
fn observe_target(&self, target: &AppTarget) -> Result<ObservedAppState, ControllerError>;
fn is_running(&self, target: &AppTarget) -> Result<bool, ControllerError>;
fn is_frontmost(&self, target: &AppTarget) -> Result<bool, ControllerError>;
fn pid_for_target(&self, target: &AppTarget) -> Result<i32, ControllerError>;
}
pub trait WindowCycler {
fn cycle_window(&self, target: &AppTarget, pid: i32) -> Result<(), ControllerError>;
}
pub struct MacAppController {
state_probe: MacAppStateProbe,
window_cycler: MacWindowCycler,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct MacAppStateProbe;
#[derive(Clone, Copy, Debug, Default)]
pub struct MacWindowCycler;
impl MacAppController {
#[must_use]
pub fn new() -> Self {
Self {
state_probe: MacAppStateProbe,
window_cycler: MacWindowCycler,
}
}
pub fn observe_target(&self, target: &AppTarget) -> Result<ObservedAppState, ControllerError> {
self.state_probe.observe_target(target)
}
pub fn execute_action_with_observation(
&self,
target: &AppTarget,
action: AppAction,
observation: ObservedAppState,
) -> Result<(), ControllerError> {
match action {
AppAction::Launch => self.launch(target),
AppAction::Focus => self.focus_with_observation(target, observation),
AppAction::Cycle => self.cycle_window_with_observation(target, observation),
AppAction::AlreadyFocused | AppAction::LaunchDisabled => Ok(()),
}
}
fn focus_with_observation(
&self,
target: &AppTarget,
observation: ObservedAppState,
) -> Result<(), ControllerError> {
if let Some(pid) = observation.pid
&& activate_pid(pid)
{
return Ok(());
}
self.launch(target).map_err(|err| match err {
ControllerError::LaunchFailed { target, reason } => {
ControllerError::FocusFailed { target, reason }
}
other => other,
})
}
fn cycle_window_with_observation(
&self,
target: &AppTarget,
observation: ObservedAppState,
) -> Result<(), ControllerError> {
let pid = observation
.pid
.or_else(|| self.state_probe.pid_for_target(target).ok())
.ok_or_else(|| ControllerError::PidLookupFailed {
target: target_display(target),
})?;
self.window_cycler.cycle_window(target, pid)
}
}
impl MacAppStateProbe {
#[must_use]
pub fn new() -> Self {
Self
}
fn app_name_from_path(path: &str) -> Option<&str> {
let p = std::path::Path::new(path);
if p.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("app"))
{
p.file_stem().and_then(|s| s.to_str())
} else {
None
}
}
fn running_apps_for_target(&self, target: &AppTarget) -> Vec<RunningApp> {
let candidates = match target {
AppTarget::BundleId(id) => {
let apps = running_apps_with_bundle_id(id);
if apps.is_empty() {
running_apps()
} else {
apps
}
}
AppTarget::AppPath(path) => {
let expanded = expand_tilde_path(path);
app_bundle_id(Path::new(&expanded))
.map(|bundle_id| running_apps_with_bundle_id(&bundle_id))
.filter(|apps| !apps.is_empty())
.unwrap_or_else(running_apps)
}
AppTarget::AppName(_) => running_apps(),
};
candidates
.into_iter()
.filter(|app| app_matches_target(app, target))
.collect()
}
fn frontmost_app(&self) -> Option<RunningApp> {
frontmost_running_app()
}
}
impl Default for MacAppController {
fn default() -> Self {
Self::new()
}
}
impl AppStateProbe for MacAppStateProbe {
fn observe_target(&self, target: &AppTarget) -> Result<ObservedAppState, ControllerError> {
let apps = self.running_apps_for_target(target);
let frontmost_app = self.frontmost_app();
let is_frontmost = frontmost_app
.as_ref()
.is_some_and(|app| app_matches_target(app, target));
let pid = if is_frontmost {
frontmost_app.map(|app| app.pid)
} else {
apps.first().map(|app| app.pid)
};
Ok(ObservedAppState {
is_running: !apps.is_empty() || is_frontmost,
is_frontmost,
pid,
})
}
fn is_running(&self, target: &AppTarget) -> Result<bool, ControllerError> {
self.observe_target(target).map(|state| state.is_running)
}
fn is_frontmost(&self, target: &AppTarget) -> Result<bool, ControllerError> {
self.observe_target(target).map(|state| state.is_frontmost)
}
fn pid_for_target(&self, target: &AppTarget) -> Result<i32, ControllerError> {
self.observe_target(target)?
.pid
.ok_or_else(|| ControllerError::PidLookupFailed {
target: target_display(target),
})
}
}
impl AppController for MacAppController {
fn is_running(&self, target: &AppTarget) -> bool {
self.state_probe.is_running(target).unwrap_or(false)
}
fn is_frontmost(&self, target: &AppTarget) -> bool {
self.state_probe.is_frontmost(target).unwrap_or(false)
}
fn launch(&self, target: &AppTarget) -> Result<(), ControllerError> {
if let AppTarget::BundleId(id) = target {
return launch_bundle_id(id).map_err(|reason| ControllerError::LaunchFailed {
target: target_display(target),
reason,
});
}
let result = match target {
AppTarget::BundleId(_) => unreachable!("bundle ids are handled above"),
AppTarget::AppName(name) => std::process::Command::new("open")
.args(["-a", name])
.output()
.map_err(|e| ControllerError::LaunchFailed {
target: target_display(target),
reason: format!("Failed to run open: {e}"),
})?,
AppTarget::AppPath(path) => std::process::Command::new("open")
.arg(path)
.output()
.map_err(|e| ControllerError::LaunchFailed {
target: target_display(target),
reason: format!("Failed to run open: {e}"),
})?,
};
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
return Err(ControllerError::LaunchFailed {
target: target_display(target),
reason: format_app_error(target, stderr.trim()),
});
}
Ok(())
}
fn focus(&self, target: &AppTarget) -> Result<(), ControllerError> {
let observation = self.observe_target(target)?;
self.focus_with_observation(target, observation)
}
fn cycle_window(&self, target: &AppTarget) -> Result<(), ControllerError> {
let observation = self.observe_target(target)?;
self.cycle_window_with_observation(target, observation)
}
}
fn launch_bundle_id(bundle_id: &str) -> Result<(), String> {
let output = std::process::Command::new("open")
.args(["-b", bundle_id])
.output()
.map_err(|e| format!("Failed to run open: {e}"))?;
if output.status.success() {
return Ok(());
}
let open_error = String::from_utf8_lossy(&output.stderr).trim().to_string();
let target = AppTarget::BundleId(bundle_id.to_string());
match MacAppStateProbe::new().pid_for_target(&target) {
Ok(pid) if activate_pid(pid) => return Ok(()),
_ => {}
}
let fallback_path = find_app_by_bundle_id(bundle_id);
let Some(path) = fallback_path else {
return Err(format!(
"Could not open app with bundle identifier \"{bundle_id}\": {open_error}"
));
};
register_app_with_launch_services(&path);
let retry_output = std::process::Command::new("open")
.args(["-b", bundle_id])
.output()
.map_err(|e| {
format!(
"Failed to retry open after registering {}: {e}",
path.display()
)
})?;
if retry_output.status.success() {
return Ok(());
}
let retry_error = String::from_utf8_lossy(&retry_output.stderr)
.trim()
.to_string();
let fallback_output = std::process::Command::new("open")
.arg(&path)
.output()
.map_err(|e| format!("Failed to run open fallback for {}: {e}", path.display()))?;
if fallback_output.status.success() {
return Ok(());
}
let fallback_error = String::from_utf8_lossy(&fallback_output.stderr)
.trim()
.to_string();
if launch_app_executable(&path).is_ok() {
return Ok(());
}
Err(format!(
"Could not open app with bundle identifier \"{bundle_id}\": {open_error}; retry after registering {} failed: {retry_error}; fallback {} failed: {fallback_error}",
path.display(),
path.display()
))
}
fn activate_pid(pid: i32) -> bool {
with_autorelease_pool(|| {
unsafe {
let app: *mut Object = msg_send![class!(NSRunningApplication), runningApplicationWithProcessIdentifier: pid];
if app.is_null() {
return false;
}
const NS_APPLICATION_ACTIVATE_ALL_WINDOWS: usize = 1 << 0;
const NS_APPLICATION_ACTIVATE_IGNORING_OTHER_APPS: usize = 1 << 1;
let options =
NS_APPLICATION_ACTIVATE_ALL_WINDOWS | NS_APPLICATION_ACTIVATE_IGNORING_OTHER_APPS;
let activated: bool = msg_send![app, activateWithOptions: options];
activated
}
})
}
fn find_app_by_bundle_id(bundle_id: &str) -> Option<PathBuf> {
app_search_roots()
.into_iter()
.flat_map(|root| app_dirs_under(&root, 3))
.find(|app| app_bundle_id(app).as_deref() == Some(bundle_id))
}
fn register_app_with_launch_services(app_path: &Path) {
let _ = std::process::Command::new(
"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
)
.args(["-f"])
.arg(app_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
fn app_search_roots() -> Vec<PathBuf> {
let mut roots = vec![
PathBuf::from("/Applications"),
PathBuf::from("/System/Applications"),
PathBuf::from("/System/Library/CoreServices"),
];
if let Ok(home) = std::env::var("HOME") {
roots.push(PathBuf::from(home).join("Applications"));
}
roots
}
fn app_dirs_under(root: &Path, max_depth: usize) -> Vec<PathBuf> {
let mut apps = Vec::new();
collect_app_dirs(root, max_depth, &mut apps);
apps
}
fn collect_app_dirs(path: &Path, depth_remaining: usize, apps: &mut Vec<PathBuf>) {
if depth_remaining == 0 || !path.is_dir() {
return;
}
let Ok(entries) = std::fs::read_dir(path) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("app"))
{
apps.push(path);
} else if path.is_dir() {
collect_app_dirs(&path, depth_remaining - 1, apps);
}
}
}
fn app_bundle_id(app_path: &Path) -> Option<String> {
app_info_plist_value(app_path, "CFBundleIdentifier")
}
fn app_executable_path(app_path: &Path) -> Option<PathBuf> {
let executable_name = app_info_plist_value(app_path, "CFBundleExecutable")?;
let executable = app_path.join("Contents/MacOS").join(executable_name);
executable.is_file().then_some(executable)
}
fn app_info_plist_value(app_path: &Path, key: &str) -> Option<String> {
let info_plist = app_path.join("Contents/Info.plist");
let output = std::process::Command::new("/usr/libexec/PlistBuddy")
.args(["-c", &format!("Print :{key}")])
.arg(info_plist)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!value.is_empty()).then_some(value)
}
fn launch_app_executable(app_path: &Path) -> Result<(), String> {
let executable = app_executable_path(app_path)
.ok_or_else(|| format!("Could not find executable for {}", app_path.display()))?;
std::process::Command::new(&executable)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(|_| ())
.map_err(|e| format!("Failed to run {}: {e}", executable.display()))
}
fn format_app_error(target: &AppTarget, stderr: &str) -> String {
match target {
AppTarget::BundleId(id) => {
format!("Could not open app with bundle identifier \"{id}\": {stderr}")
}
AppTarget::AppName(name) => {
format!("Could not open app \"{name}\": {stderr}")
}
AppTarget::AppPath(path) => {
format!("Could not open app at \"{path}\": {stderr}")
}
}
}
pub fn target_display(target: &AppTarget) -> String {
match target {
AppTarget::BundleId(id) => id.clone(),
AppTarget::AppName(name) => name.clone(),
AppTarget::AppPath(path) => path.clone(),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct RunningApp {
pid: i32,
bundle_id: Option<String>,
localized_name: Option<String>,
bundle_path: Option<String>,
}
fn running_apps() -> Vec<RunningApp> {
with_autorelease_pool(|| {
unsafe {
let workspace: *mut Object = msg_send![class!(NSWorkspace), sharedWorkspace];
if workspace.is_null() {
return Vec::new();
}
let apps: *mut Object = msg_send![workspace, runningApplications];
nsarray_to_running_apps(apps)
}
})
}
fn running_apps_with_bundle_id(bundle_id: &str) -> Vec<RunningApp> {
with_autorelease_pool(|| {
unsafe {
let Some(bundle_id) = nsstring_from_str(bundle_id) else {
return Vec::new();
};
let apps: *mut Object = msg_send![
class!(NSRunningApplication),
runningApplicationsWithBundleIdentifier: bundle_id
];
nsarray_to_running_apps(apps)
}
})
}
fn frontmost_running_app() -> Option<RunningApp> {
with_autorelease_pool(|| {
unsafe {
let workspace: *mut Object = msg_send![class!(NSWorkspace), sharedWorkspace];
if workspace.is_null() {
return None;
}
let app: *mut Object = msg_send![workspace, frontmostApplication];
running_app_from_ns(app)
}
})
}
unsafe fn nsarray_to_running_apps(apps: *mut Object) -> Vec<RunningApp> {
if apps.is_null() {
return Vec::new();
}
let count: usize = unsafe { msg_send![apps, count] };
let mut result = Vec::with_capacity(count);
for idx in 0..count {
let app: *mut Object = unsafe { msg_send![apps, objectAtIndex: idx] };
if let Some(app) = unsafe { running_app_from_ns(app) } {
result.push(app);
}
}
result
}
unsafe fn running_app_from_ns(app: *mut Object) -> Option<RunningApp> {
if app.is_null() {
return None;
}
let pid: i32 = unsafe { msg_send![app, processIdentifier] };
let bundle_id: *mut Object = unsafe { msg_send![app, bundleIdentifier] };
let localized_name: *mut Object = unsafe { msg_send![app, localizedName] };
let bundle_url: *mut Object = unsafe { msg_send![app, bundleURL] };
let bundle_path = if bundle_url.is_null() {
None
} else {
let path: *mut Object = unsafe { msg_send![bundle_url, path] };
unsafe { nsstring_to_string(path) }
};
Some(RunningApp {
pid,
bundle_id: unsafe { nsstring_to_string(bundle_id) },
localized_name: unsafe { nsstring_to_string(localized_name) },
bundle_path,
})
}
unsafe fn nsstring_to_string(value: *mut Object) -> Option<String> {
if value.is_null() {
return None;
}
let bytes: *const libc::c_char = unsafe { msg_send![value, UTF8String] };
if bytes.is_null() {
return None;
}
Some(
unsafe { CStr::from_ptr(bytes) }
.to_string_lossy()
.into_owned(),
)
}
unsafe fn nsstring_from_str(value: &str) -> Option<*mut Object> {
let value = CString::new(value).ok()?;
let string: *mut Object =
unsafe { msg_send![class!(NSString), stringWithUTF8String: value.as_ptr()] };
(!string.is_null()).then_some(string)
}
fn app_matches_target(app: &RunningApp, target: &AppTarget) -> bool {
match target {
AppTarget::BundleId(id) => app.bundle_id.as_deref() == Some(id.as_str()),
AppTarget::AppName(name) => {
app.localized_name.as_deref() == Some(name.as_str())
|| app
.bundle_path
.as_deref()
.and_then(MacAppStateProbe::app_name_from_path)
.is_some_and(|app_name| app_name == name)
}
AppTarget::AppPath(path) => {
let expanded = expand_tilde_path(path);
app.bundle_path.as_deref() == Some(expanded.as_str())
}
}
}
fn expand_tilde_path(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
std::env::var("HOME")
.map(|home| format!("{home}/{rest}"))
.unwrap_or_else(|_| path.to_string())
} else {
path.to_string()
}
}
impl WindowCycler for MacWindowCycler {
fn cycle_window(&self, target: &AppTarget, pid: i32) -> Result<(), ControllerError> {
let app = AXUIElement::application(pid);
app.set_messaging_timeout(1.0).map_err(map_ax_error)?;
let windows = ax_windows(&app)?;
let snapshots = snapshots_for_windows(&windows)?;
let total_windows = snapshots.len();
if total_windows == 0 {
return Err(ControllerError::NoWindows {
target: target_display(target),
});
}
let cyclable = cyclable_windows(snapshots);
if cyclable.is_empty() {
return Err(ControllerError::NoCyclableWindows {
target: target_display(target),
total_windows,
rejected_windows: total_windows,
});
}
if cyclable.len() == 1 {
return Err(ControllerError::OnlyOneCyclableWindow {
target: target_display(target),
title: cyclable[0].title.clone(),
});
}
let current_window = current_window(&app)?;
let current_identity = current_window
.as_ref()
.map(snapshot_for_window)
.transpose()?
.map(|snapshot| snapshot.identity);
let current_idx = current_cyclable_window_index(
&cyclable,
current_window.as_ref(),
current_identity.as_ref(),
)
.unwrap_or(0);
let next_idx = (current_idx + 1) % cyclable.len();
let next = &cyclable[next_idx];
next.element.raise().map_err(map_ax_error)?;
let _ = next.element.set_main(true);
if verify_current_window(&app, &next.element, &next.identity)? {
Ok(())
} else {
Err(ControllerError::RaiseVerificationFailed {
target: target_display(target),
title: next.title.clone(),
})
}
}
}
#[derive(Clone, Debug)]
struct WindowSnapshot {
element: AXUIElement,
identity: WindowIdentity,
title: Option<String>,
role: Option<String>,
minimized: bool,
size: Option<WindowSize>,
is_main: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct WindowIdentity {
title: Option<String>,
role: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct WindowSize {
width: i64,
height: i64,
}
impl WindowSnapshot {
fn is_cyclable(&self) -> bool {
self.role.as_deref() == Some("AXWindow")
&& !self.minimized
&& self
.size
.is_none_or(|size| size.width > 0 && size.height > 0)
}
}
fn ax_windows(app: &AXUIElement) -> Result<Vec<AXUIElement>, ControllerError> {
app.windows()
.map(|windows| windows.iter().map(|window| window.clone()).collect())
.map_err(map_ax_error)
}
fn snapshots_for_windows(windows: &[AXUIElement]) -> Result<Vec<WindowSnapshot>, ControllerError> {
windows
.iter()
.map(snapshot_for_window)
.collect::<Result<Vec<_>, _>>()
}
fn snapshot_for_window(window: &AXUIElement) -> Result<WindowSnapshot, ControllerError> {
let title = optional_ax(window.title()).map(|title| title.map(|value| value.to_string()))?;
let role = optional_ax(window.role()).map(|role| role.map(|value| value.to_string()))?;
let minimized = optional_ax(window.minimized())?
.map(bool::from)
.unwrap_or(false);
let size = None;
let is_main = optional_ax(window.main())?.map(bool::from).unwrap_or(false);
let identity = WindowIdentity {
title: title.clone(),
role: role.clone(),
};
Ok(WindowSnapshot {
element: window.clone(),
identity,
title,
role,
minimized,
size,
is_main,
})
}
fn cyclable_windows(windows: Vec<WindowSnapshot>) -> Vec<WindowSnapshot> {
let mut windows: Vec<_> = windows
.into_iter()
.filter(WindowSnapshot::is_cyclable)
.collect();
windows.sort_by_key(window_sort_key);
windows
}
fn window_sort_key(window: &WindowSnapshot) -> String {
window
.title
.as_deref()
.map(str::to_lowercase)
.unwrap_or_default()
}
fn current_window(app: &AXUIElement) -> Result<Option<AXUIElement>, ControllerError> {
if let Some(focused) = optional_ax(app.focused_window())? {
return Ok(Some(focused));
}
if let Some(main) = optional_ax(app.main_window())? {
return Ok(Some(main));
}
Ok(None)
}
fn current_cyclable_window_index(
cyclable: &[WindowSnapshot],
current_window: Option<&AXUIElement>,
current_identity: Option<&WindowIdentity>,
) -> Option<usize> {
current_window
.and_then(|window| {
cyclable
.iter()
.position(|snapshot| snapshot.element == *window)
})
.or_else(|| {
current_identity
.and_then(|identity| cyclable.iter().position(|w| w.identity == *identity))
})
.or_else(|| cyclable.iter().position(|w| w.is_main))
}
pub fn capture_window_cycle_snapshot(
target: &AppTarget,
) -> Result<WindowCycleSnapshot, ControllerError> {
with_autorelease_pool(|| {
let pid = MacAppStateProbe::new().pid_for_target(target)?;
let app = AXUIElement::application(pid);
app.set_messaging_timeout(1.0).map_err(map_ax_error)?;
let windows = ax_windows(&app)?;
let snapshots = snapshots_for_windows(&windows)?;
let current = current_window(&app)?;
build_window_cycle_snapshot(target, pid, snapshots, current.as_ref())
})
}
fn build_window_cycle_snapshot(
target: &AppTarget,
pid: i32,
snapshots: Vec<WindowSnapshot>,
current_window: Option<&AXUIElement>,
) -> Result<WindowCycleSnapshot, ControllerError> {
let current_window_index = current_window.and_then(|window| {
snapshots
.iter()
.position(|snapshot| snapshot.element == *window)
});
let current_identity = current_window
.map(snapshot_for_window)
.transpose()?
.map(|snapshot| snapshot.identity);
let mut cyclable: Vec<_> = snapshots
.iter()
.cloned()
.enumerate()
.filter(|(_, snapshot)| snapshot.is_cyclable())
.collect();
cyclable.sort_by_key(|(_, snapshot)| window_sort_key(snapshot));
let ordered_cyclable_window_indexes = cyclable.iter().map(|(idx, _)| *idx).collect::<Vec<_>>();
let current_cyclable_index = {
let cyclable_snapshots = cyclable
.iter()
.map(|(_, snapshot)| snapshot.clone())
.collect::<Vec<_>>();
current_cyclable_window_index(
&cyclable_snapshots,
current_window,
current_identity.as_ref(),
)
};
Ok(WindowCycleSnapshot {
target: target_display(target),
pid,
current_window_index,
current_cyclable_index,
ordered_cyclable_window_indexes,
windows: snapshots
.into_iter()
.map(|snapshot| {
let is_cyclable = snapshot.is_cyclable();
WindowCycleSnapshotWindow {
title: snapshot.title,
role: snapshot.role,
minimized: snapshot.minimized,
is_main: snapshot.is_main,
is_cyclable,
}
})
.collect(),
})
}
fn current_window_identity(app: &AXUIElement) -> Result<Option<WindowIdentity>, ControllerError> {
if let Some(focused) = current_window(app)? {
return snapshot_for_window(&focused).map(|snapshot| Some(snapshot.identity));
}
Ok(None)
}
fn verify_current_window(
app: &AXUIElement,
expected_window: &AXUIElement,
expected: &WindowIdentity,
) -> Result<bool, ControllerError> {
if let Some(current) = current_window(app)? {
return Ok(current == *expected_window);
}
current_window_identity(app).map(|current| {
current
.as_ref()
.map(|identity| identity == expected)
.unwrap_or(true)
})
}
fn optional_ax<T>(result: Result<T, AXError>) -> Result<Option<T>, ControllerError> {
match result {
Ok(value) => Ok(Some(value)),
Err(err) if is_ax_no_value(&err) => Ok(None),
Err(err) => Err(map_ax_error(err)),
}
}
fn is_ax_no_value(error: &AXError) -> bool {
error.to_string().contains("kAXErrorNoValue")
}
fn map_ax_error(error: AXError) -> ControllerError {
let message = error.to_string();
if message.contains("APIDisabled") || message.contains("API disabled") {
accessibility_permission_error()
} else {
ControllerError::AxApi(message)
}
}
fn accessibility_permission_error() -> ControllerError {
let executable = std::env::current_exe()
.map(|path| path.display().to_string())
.unwrap_or_else(|_| "unknown".to_string());
let parent = parent_process_info();
let likely_process = parent
.as_ref()
.map(|info| info.name.clone())
.unwrap_or_else(|| "the terminal, launcher, or hotkey daemon that invokes summon".into());
ControllerError::PermissionDenied {
executable,
parent: parent
.map(|info| format!("{} (pid {})", info.name, info.pid))
.unwrap_or_else(|| "unknown".to_string()),
likely_process,
}
}
pub(crate) fn with_autorelease_pool<F, R>(operation: F) -> R
where
F: FnOnce() -> R,
{
let pool = unsafe { AutoreleasePool::new() };
let result = operation();
drop(pool);
result
}
struct AutoreleasePool(*mut Object);
impl AutoreleasePool {
unsafe fn new() -> Self {
let pool: *mut Object = unsafe { msg_send![class!(NSAutoreleasePool), new] };
Self(pool)
}
}
impl Drop for AutoreleasePool {
fn drop(&mut self) {
if self.0.is_null() {
return;
}
unsafe {
let _: () = msg_send![self.0, drain];
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProcessInfo {
pub pid: i32,
pub name: String,
}
pub fn current_process_info() -> Option<ProcessInfo> {
process_info(std::process::id() as i32)
}
pub fn parent_process_info() -> Option<ProcessInfo> {
let pid = unsafe { libc::getppid() };
process_info(pid)
}
pub fn grandparent_process_info() -> Option<ProcessInfo> {
let parent = parent_process_info()?;
parent_pid(parent.pid).and_then(process_info)
}
fn parent_pid(pid: i32) -> Option<i32> {
let output = std::process::Command::new("ps")
.args(["-o", "ppid=", "-p", &pid.to_string()])
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<i32>()
.ok()
}
fn process_info(pid: i32) -> Option<ProcessInfo> {
let output = std::process::Command::new("ps")
.args(["-o", "comm=", "-p", &pid.to_string()])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(ProcessInfo { pid, name })
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
use std::thread;
use std::time::Duration;
fn finder() -> AppTarget {
AppTarget::BundleId("com.apple.finder".into())
}
fn launch_settings() -> EffectiveSettings {
EffectiveSettings {
launch_if_not_running: true,
cycle_when_focused: false,
..EffectiveSettings::default()
}
}
fn cycle_settings() -> EffectiveSettings {
EffectiveSettings {
launch_if_not_running: true,
cycle_when_focused: true,
..EffectiveSettings::default()
}
}
#[test]
fn not_running_with_launch_returns_launch() {
let target = finder();
let controller = FakeAppController::new();
let (action, context) = decide_action(&controller, &target, &launch_settings());
assert_eq!(action, AppAction::Launch);
assert_eq!(context.frontmost, ObservedFrontmost::NotChecked);
assert!(context.launch_when_missing);
}
#[test]
fn not_running_without_launch_returns_noop() {
let target = finder();
let controller = FakeAppController::new();
let settings = EffectiveSettings {
launch_if_not_running: false,
..EffectiveSettings::default()
};
let (action, context) = decide_action(&controller, &target, &settings);
assert_eq!(action, AppAction::LaunchDisabled);
assert_eq!(context.frontmost, ObservedFrontmost::NotChecked);
assert!(!context.launch_when_missing);
}
#[test]
fn running_not_frontmost_returns_focus() {
let target = finder();
let controller = FakeAppController::new()
.set_running(&target, true)
.set_frontmost(&target, false);
let (action, context) = decide_action(&controller, &target, &launch_settings());
assert_eq!(action, AppAction::Focus);
assert_eq!(context.frontmost, ObservedFrontmost::No);
}
#[test]
fn running_not_frontmost_ignores_cycle_setting() {
let target = finder();
let controller = FakeAppController::new()
.set_running(&target, true)
.set_frontmost(&target, false);
let (action, context) = decide_action(&controller, &target, &cycle_settings());
assert_eq!(action, AppAction::Focus);
assert!(context.cycle_when_focused);
}
#[test]
fn frontmost_with_cycle_returns_cycle() {
let target = finder();
let controller = FakeAppController::new()
.set_running(&target, true)
.set_frontmost(&target, true);
let (action, context) = decide_action(&controller, &target, &cycle_settings());
assert_eq!(action, AppAction::Cycle);
assert_eq!(context.frontmost, ObservedFrontmost::Yes);
}
#[test]
fn frontmost_without_cycle_returns_already_focused() {
let target = finder();
let controller = FakeAppController::new()
.set_running(&target, true)
.set_frontmost(&target, true);
let (action, context) = decide_action(&controller, &target, &launch_settings());
assert_eq!(action, AppAction::AlreadyFocused);
assert!(!context.cycle_when_focused);
}
#[test]
fn all_defaults_not_running_is_noop() {
let target = finder();
let controller = FakeAppController::new();
let settings = EffectiveSettings::default();
let (action, context) = decide_action(&controller, &target, &settings);
assert_eq!(action, AppAction::LaunchDisabled);
assert!(!context.is_running);
}
#[test]
fn all_defaults_running_not_frontmost_is_focus() {
let target = finder();
let controller = FakeAppController::new()
.set_running(&target, true)
.set_frontmost(&target, false);
let settings = EffectiveSettings::default();
let (action, context) = decide_action(&controller, &target, &settings);
assert_eq!(action, AppAction::Focus);
assert!(context.is_running);
}
#[test]
fn all_defaults_frontmost_is_already_focused() {
let target = finder();
let controller = FakeAppController::new()
.set_running(&target, true)
.set_frontmost(&target, true);
let settings = EffectiveSettings::default();
let (action, context) = decide_action(&controller, &target, &settings);
assert_eq!(action, AppAction::AlreadyFocused);
assert_eq!(context.frontmost, ObservedFrontmost::Yes);
}
#[test]
fn decide_with_app_name_target() {
let target = AppTarget::AppName("Preview".into());
let controller = FakeAppController::new();
let (action, _context) = decide_action(&controller, &target, &launch_settings());
assert_eq!(action, AppAction::Launch);
}
#[test]
fn decide_with_app_path_target() {
let target = AppTarget::AppPath("/Applications/My App.app".into());
let controller = FakeAppController::new().set_running(&target, true);
let (action, _context) = decide_action(&controller, &target, &launch_settings());
assert_eq!(action, AppAction::Focus);
}
#[test]
fn execute_noop_succeeds() {
let target = finder();
let controller = FakeAppController::new();
let result = execute_action(&controller, &target, AppAction::AlreadyFocused);
assert!(result.is_ok());
let result = execute_action(&controller, &target, AppAction::LaunchDisabled);
assert!(result.is_ok());
}
#[test]
fn execute_launch_succeeds_with_fake() {
let target = finder();
let controller = FakeAppController::new();
let result = execute_action(&controller, &target, AppAction::Launch);
assert!(result.is_ok());
}
#[test]
fn execute_focus_succeeds_with_fake() {
let target = finder();
let controller = FakeAppController::new();
let result = execute_action(&controller, &target, AppAction::Focus);
assert!(result.is_ok());
}
#[test]
fn execute_cycle_succeeds_with_fake() {
let target = finder();
let controller = FakeAppController::new();
let result = execute_action(&controller, &target, AppAction::Cycle);
assert!(result.is_ok());
}
#[test]
fn fake_controller_default_is_empty() {
let controller = FakeAppController::new();
let target = finder();
assert!(!controller.is_running(&target));
assert!(!controller.is_frontmost(&target));
}
#[test]
fn fake_controller_set_running() {
let target = finder();
let controller = FakeAppController::new().set_running(&target, true);
assert!(controller.is_running(&target));
let controller = controller.set_running(&target, false);
assert!(!controller.is_running(&target));
}
#[test]
fn fake_controller_set_frontmost() {
let target = finder();
let controller = FakeAppController::new().set_frontmost(&target, true);
assert!(controller.is_frontmost(&target));
let controller = controller.set_frontmost(&target, false);
assert!(!controller.is_frontmost(&target));
}
#[test]
fn fake_controller_multiple_targets() {
let finder = AppTarget::BundleId("com.apple.finder".into());
let zed = AppTarget::BundleId("dev.zed.Zed".into());
let controller = FakeAppController::new()
.set_running(&finder, true)
.set_running(&zed, true)
.set_frontmost(&finder, true);
assert!(controller.is_running(&finder));
assert!(controller.is_running(&zed));
assert!(controller.is_frontmost(&finder));
assert!(!controller.is_frontmost(&zed));
}
#[test]
fn fake_controller_idempotent_set() {
let target = finder();
let controller = FakeAppController::new()
.set_running(&target, true)
.set_running(&target, true);
assert!(controller.is_running(&target));
}
#[test]
fn mac_controller_new_works() {
let _controller = super::MacAppController::new();
}
#[test]
fn mac_controller_default_works() {
let _controller: super::MacAppController = Default::default();
}
#[test]
fn app_name_from_path_standard() {
assert_eq!(
super::MacAppStateProbe::app_name_from_path("/Applications/Safari.app"),
Some("Safari")
);
}
#[test]
fn app_name_from_path_with_spaces() {
assert_eq!(
super::MacAppStateProbe::app_name_from_path("/Applications/Visual Studio Code.app"),
Some("Visual Studio Code")
);
}
#[test]
fn app_name_from_path_nested() {
assert_eq!(
super::MacAppStateProbe::app_name_from_path("/Applications/Utilities/Terminal.app"),
Some("Terminal")
);
}
#[test]
fn app_name_from_path_tilde() {
assert_eq!(
super::MacAppStateProbe::app_name_from_path("~/Applications/My App.app"),
Some("My App")
);
}
#[test]
fn app_name_from_path_no_app_extension() {
assert_eq!(
super::MacAppStateProbe::app_name_from_path("/Applications/Safari"),
None
);
}
#[test]
fn app_name_from_path_empty() {
assert_eq!(super::MacAppStateProbe::app_name_from_path(""), None);
}
#[test]
fn format_app_error_bundle_id() {
let target = AppTarget::BundleId("com.example.app".into());
let msg = super::format_app_error(&target, "not found");
assert!(msg.contains("com.example.app"), "should contain bundle ID");
assert!(msg.contains("not found"), "should contain stderr");
}
#[test]
fn format_app_error_app_name() {
let target = AppTarget::AppName("Safari".into());
let msg = super::format_app_error(&target, "unable to find");
assert!(msg.contains("Safari"), "should contain app name");
assert!(msg.contains("unable to find"), "should contain stderr");
}
#[test]
fn format_app_error_app_path() {
let target = AppTarget::AppPath("/Apps/Test.app".into());
let msg = super::format_app_error(&target, "does not exist");
assert!(msg.contains("/Apps/Test.app"), "should contain path");
assert!(msg.contains("does not exist"), "should contain stderr");
}
#[test]
#[ignore]
fn mac_controller_cycle_runs_without_panic() {
let controller = super::MacAppController::new();
let target = finder();
let _result = controller.cycle_window(&target);
}
#[test]
fn app_matches_target_bundle_id() {
let app = fake_running_app(
Some("com.apple.finder"),
Some("Finder"),
Some("/System/Library/CoreServices/Finder.app"),
);
assert!(app_matches_target(
&app,
&AppTarget::BundleId("com.apple.finder".into())
));
}
#[test]
fn app_matches_target_app_name() {
let app = fake_running_app(
Some("com.apple.Preview"),
Some("Preview"),
Some("/System/Applications/Preview.app"),
);
assert!(app_matches_target(
&app,
&AppTarget::AppName("Preview".into())
));
}
#[test]
fn app_matches_target_app_path_with_spaces() {
let app = fake_running_app(
Some("com.microsoft.VSCode"),
Some("Visual Studio Code"),
Some("/Applications/Visual Studio Code.app"),
);
assert!(app_matches_target(
&app,
&AppTarget::AppPath("/Applications/Visual Studio Code.app".into())
));
}
#[test]
fn window_snapshot_filters_minimized_windows() {
let window = fake_window_snapshot(Some("AXWindow"), true, Some((0, 0)), Some((100, 100)));
assert!(!window.is_cyclable());
}
#[test]
fn window_snapshot_filters_non_window_roles() {
let window = fake_window_snapshot(Some("AXSheet"), false, Some((0, 0)), Some((100, 100)));
assert!(!window.is_cyclable());
}
#[test]
fn window_snapshot_filters_zero_size_windows() {
let window = fake_window_snapshot(Some("AXWindow"), false, Some((0, 0)), Some((0, 100)));
assert!(!window.is_cyclable());
}
#[test]
fn window_snapshot_accepts_normal_window() {
let window = fake_window_snapshot(Some("AXWindow"), false, Some((0, 0)), Some((100, 100)));
assert!(window.is_cyclable());
}
#[test]
fn current_cyclable_window_index_prefers_actual_window_over_duplicate_titles() {
let first = fake_named_window_snapshot(
AXUIElement::application(101),
Some("project"),
Some("AXWindow"),
false,
);
let second = fake_named_window_snapshot(
AXUIElement::application(202),
Some("project"),
Some("AXWindow"),
false,
);
let cyclable = vec![first.clone(), second.clone()];
let idx =
current_cyclable_window_index(&cyclable, Some(&second.element), Some(&second.identity));
assert_eq!(idx, Some(1));
}
#[test]
fn zed_live_snapshot_fixture_selects_a_distinct_next_window() {
let fixture: WindowCycleSnapshot = serde_json::from_str(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/zed-live-window-cycle.json"
)))
.expect("fixture should parse");
let snapshots = snapshots_from_fixture(&fixture);
let current_window_index = fixture
.current_window_index
.expect("fixture should include a current window index");
let current_snapshot = snapshots[current_window_index].clone();
let cyclable = cyclable_windows(snapshots);
assert!(
cyclable.len() > 1,
"fixture should contain at least two cyclable windows"
);
let current_idx = current_cyclable_window_index(
&cyclable,
Some(¤t_snapshot.element),
Some(¤t_snapshot.identity),
)
.expect("fixture should identify the current cyclable window");
let next_idx = (current_idx + 1) % cyclable.len();
assert_ne!(next_idx, current_idx);
assert_ne!(cyclable[next_idx].element, current_snapshot.element);
}
#[test]
#[ignore]
fn print_live_zed_window_cycle_fixture() {
let target = AppTarget::BundleId("dev.zed.Zed".into());
let fixture =
capture_window_cycle_snapshot(&target).expect("fixture capture should succeed");
println!(
"{}",
serde_json::to_string_pretty(&fixture).expect("fixture should serialize")
);
}
#[test]
#[ignore]
fn live_zed_cycle_changes_current_window_when_frontmost() {
let target = AppTarget::BundleId("dev.zed.Zed".into());
let probe = MacAppStateProbe::new();
let pid = probe
.pid_for_target(&target)
.expect("Zed should be running for the smoke test");
assert!(activate_pid(pid), "Zed should be activatable");
thread::sleep(Duration::from_millis(150));
let app = AXUIElement::application(pid);
app.set_messaging_timeout(1.0)
.expect("AX messaging timeout should be set");
let live_fixture =
capture_window_cycle_snapshot(&target).expect("live fixture capture should succeed");
let live_cyclable = live_fixture
.windows
.iter()
.filter(|window| window.is_cyclable)
.count();
assert!(
live_cyclable > 1,
"need at least two cyclable Zed windows for the smoke test"
);
let before = current_window(&app)
.expect("current window lookup should succeed")
.expect("Zed should have a current window");
MacWindowCycler
.cycle_window(&target, pid)
.expect("Zed cycle should succeed");
let after = current_window(&app)
.expect("current window lookup after cycle should succeed")
.expect("Zed should still have a current window");
assert_ne!(
before, after,
"cycling should switch to a different Zed window"
);
}
fn fake_window_snapshot(
role: Option<&str>,
minimized: bool,
_position: Option<(i64, i64)>,
size: Option<(i64, i64)>,
) -> WindowSnapshot {
let size = size.map(|(width, height)| WindowSize { width, height });
fake_named_window_snapshot_with_size(
AXUIElement::system_wide(),
Some("Test"),
role,
minimized,
size,
)
}
fn fake_named_window_snapshot(
element: AXUIElement,
title: Option<&str>,
role: Option<&str>,
minimized: bool,
) -> WindowSnapshot {
fake_named_window_snapshot_with_size(
element,
title,
role,
minimized,
Some(WindowSize {
width: 100,
height: 100,
}),
)
}
fn fake_named_window_snapshot_with_size(
element: AXUIElement,
title: Option<&str>,
role: Option<&str>,
minimized: bool,
size: Option<WindowSize>,
) -> WindowSnapshot {
WindowSnapshot {
element,
identity: WindowIdentity {
title: title.map(str::to_string),
role: role.map(str::to_string),
},
title: title.map(str::to_string),
role: role.map(str::to_string),
minimized,
size,
is_main: false,
}
}
fn snapshots_from_fixture(fixture: &WindowCycleSnapshot) -> Vec<WindowSnapshot> {
fixture
.windows
.iter()
.enumerate()
.map(|(idx, window)| WindowSnapshot {
element: AXUIElement::application((idx + 1) as i32),
identity: WindowIdentity {
title: window.title.clone(),
role: window.role.clone(),
},
title: window.title.clone(),
role: window.role.clone(),
minimized: window.minimized,
size: Some(WindowSize {
width: 100,
height: 100,
}),
is_main: window.is_main,
})
.collect()
}
fn fake_running_app(
bundle_id: Option<&str>,
localized_name: Option<&str>,
bundle_path: Option<&str>,
) -> RunningApp {
RunningApp {
pid: 123,
bundle_id: bundle_id.map(str::to_string),
localized_name: localized_name.map(str::to_string),
bundle_path: bundle_path.map(str::to_string),
}
}
}