use std::sync::OnceLock;
use regex::Regex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MacosConsentState {
Granted,
Denied,
WouldPrompt,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MacosPermissionPreflight {
pub automation_target: Option<String>,
pub automation_state: Option<MacosConsentState>,
pub accessibility_state: Option<MacosConsentState>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AutomationTarget {
label: String,
bundle_id: String,
}
fn applescript_target_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r#"(?is)tell\s+application(?:\s+id)?\s+["']([^"']+)["']"#)
.expect("valid AppleScript target regex")
})
}
fn accessibility_ui_scripting_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"(?is)\bosascript\b.*(?:system\s+events|keystroke\b|key\s+code\b|click\s+(?:button|menu|menu item))"#,
)
.expect("valid accessibility regex")
})
}
fn shortcuts_run_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)\bshortcuts\s+run\b").expect("valid shortcuts regex"))
}
fn memo_notes_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?i)\bmemo\s+notes\b").expect("valid memo notes regex"))
}
fn known_bundle_id(app_name: &str) -> Option<&'static str> {
match app_name.trim().to_ascii_lowercase().as_str() {
"notes" => Some("com.apple.Notes"),
"system events" => Some("com.apple.systemevents"),
"shortcuts" => Some("com.apple.shortcuts"),
"finder" => Some("com.apple.finder"),
"terminal" => Some("com.apple.Terminal"),
"safari" => Some("com.apple.Safari"),
"mail" => Some("com.apple.mail"),
_ => None,
}
}
fn automation_target_from_applescript(script: &str) -> Option<AutomationTarget> {
let captures = applescript_target_regex().captures(script)?;
let raw_target = captures.get(1)?.as_str().trim();
if raw_target.starts_with("com.") {
return Some(AutomationTarget {
label: raw_target.into(),
bundle_id: raw_target.into(),
});
}
let bundle_id = known_bundle_id(raw_target)?;
Some(AutomationTarget {
label: raw_target.into(),
bundle_id: bundle_id.into(),
})
}
fn join_non_empty(parts: Vec<String>) -> Option<String> {
let parts = parts
.into_iter()
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if parts.is_empty() {
None
} else {
Some(parts.join("\n"))
}
}
fn extract_inline_osascript(command: &str) -> Option<String> {
let tokens = shell_words::split(command).ok()?;
let mut expressions = Vec::new();
let mut idx = 0usize;
while idx < tokens.len() {
let token = &tokens[idx];
let command_name = token.rsplit('/').next().unwrap_or(token);
if command_name != "osascript" {
idx += 1;
continue;
}
idx += 1;
while idx < tokens.len() {
match tokens[idx].as_str() {
"-e" => {
let expr = tokens.get(idx + 1)?;
expressions.push(expr.clone());
idx += 2;
}
"|" | "||" | "&&" | ";" => break,
_ => idx += 1,
}
}
}
join_non_empty(expressions)
}
fn extract_osascript_heredoc(command: &str) -> Option<String> {
let opener = command.lines().next()?.trim();
if !opener.contains("<<") {
return None;
}
let opens_osascript = opener.contains("osascript");
let pipes_to_osascript = opener.contains("| osascript") || opener.contains("|osascript");
if !opens_osascript && !pipes_to_osascript {
return None;
}
let marker = parse_heredoc_marker(opener)?;
let allows_tab_indented_terminator = opener.contains("<<-");
let mut body_lines = Vec::new();
for line in command.lines().skip(1) {
let terminator = if allows_tab_indented_terminator {
line.trim_start_matches('\t')
} else {
line
};
if terminator == marker {
break;
}
body_lines.push(line);
}
let body = body_lines.join("\n");
let body = body.trim();
if body.is_empty() {
return None;
}
Some(body.to_string())
}
fn parse_heredoc_marker(opener: &str) -> Option<String> {
let idx = opener.find("<<")?;
let mut marker = opener.get(idx + 2..)?.trim_start();
if let Some(stripped) = marker.strip_prefix('-') {
marker = stripped.trim_start();
}
if marker.is_empty() {
return None;
}
if let Some(quoted) = marker.strip_prefix('\'') {
return quoted.split('\'').next().map(str::to_string);
}
if let Some(quoted) = marker.strip_prefix('"') {
return quoted.split('"').next().map(str::to_string);
}
let end = marker
.find(|ch: char| ch.is_whitespace() || matches!(ch, '|' | ';'))
.unwrap_or(marker.len());
let marker = marker[..end].trim();
(!marker.is_empty()).then(|| marker.to_string())
}
fn extract_piped_literal_osascript(command: &str) -> Option<String> {
let tokens = shell_words::split(command).ok()?;
let pipe_idx = tokens.iter().position(|token| token == "|")?;
let rhs = tokens.get(pipe_idx + 1)?;
if rhs.rsplit('/').next().unwrap_or(rhs) != "osascript" {
return None;
}
let lhs = &tokens[..pipe_idx];
match lhs {
[cmd, script] if cmd == "echo" => Some(script.clone()),
[cmd, format, script] if cmd == "printf" && format.contains("%s") => Some(script.clone()),
[cmd, format, first, second]
if cmd == "printf" && format.contains("%s") && format.contains("\\n") =>
{
Some(format!("{first}\n{second}"))
}
_ => None,
}
}
fn extract_literal_applescript(command: &str) -> Option<String> {
extract_inline_osascript(command)
.or_else(|| extract_osascript_heredoc(command))
.or_else(|| extract_piped_literal_osascript(command))
}
fn automation_target_from_command(command: &str) -> Option<AutomationTarget> {
if memo_notes_regex().is_match(command) {
return Some(AutomationTarget {
label: "Notes".into(),
bundle_id: "com.apple.Notes".into(),
});
}
if shortcuts_run_regex().is_match(command) {
return Some(AutomationTarget {
label: "Shortcuts".into(),
bundle_id: "com.apple.shortcuts".into(),
});
}
extract_literal_applescript(command)
.and_then(|script| automation_target_from_applescript(&script))
.or_else(|| automation_target_from_applescript(command))
}
fn command_needs_accessibility(command: &str) -> bool {
accessibility_ui_scripting_regex().is_match(command)
}
#[cfg(target_os = "macos")]
fn macos_preflight(command: &str) -> MacosPermissionPreflight {
let automation_target = automation_target_from_command(command);
let automation_state = automation_target
.as_ref()
.map(|target| automation_consent_state(&target.bundle_id));
let accessibility_state =
command_needs_accessibility(command).then(accessibility_consent_state);
MacosPermissionPreflight {
automation_target: automation_target.map(|target| target.label),
automation_state,
accessibility_state,
}
}
#[cfg(not(target_os = "macos"))]
fn macos_preflight(_command: &str) -> MacosPermissionPreflight {
MacosPermissionPreflight::default()
}
pub fn preflight_command_permissions(command: &str) -> MacosPermissionPreflight {
macos_preflight(command)
}
#[cfg(target_os = "macos")]
fn accessibility_consent_state() -> MacosConsentState {
if unsafe { AXIsProcessTrusted() } != 0 {
MacosConsentState::Granted
} else {
MacosConsentState::Denied
}
}
#[cfg(target_os = "macos")]
fn automation_consent_state(bundle_id: &str) -> MacosConsentState {
let Some(status) = automation_consent_status(bundle_id) else {
return MacosConsentState::Unknown;
};
match status {
0 => MacosConsentState::Granted,
-1743 => MacosConsentState::Denied,
-1744 => MacosConsentState::WouldPrompt,
-600 => MacosConsentState::Unknown,
_ => MacosConsentState::Unknown,
}
}
#[cfg(target_os = "macos")]
fn automation_consent_status(bundle_id: &str) -> Option<i32> {
let mut desc = AEDesc {
descriptor_type: 0,
data_handle: std::ptr::null_mut(),
};
let status = unsafe {
AECreateDesc(
four_char_code(*b"bund"),
bundle_id.as_ptr().cast(),
i32::try_from(bundle_id.len()).ok()?,
&mut desc,
)
};
if status != 0 {
return None;
}
let permission_status = unsafe {
AEDeterminePermissionToAutomateTarget(
&desc,
four_char_code(*b"****"),
four_char_code(*b"****"),
0,
)
};
let _ = unsafe { AEDisposeDesc(&mut desc) };
Some(permission_status)
}
#[cfg(target_os = "macos")]
const fn four_char_code(code: [u8; 4]) -> u32 {
u32::from_be_bytes(code)
}
#[cfg(target_os = "macos")]
type Boolean = u8;
#[cfg(target_os = "macos")]
#[repr(C)]
struct AEDesc {
descriptor_type: u32,
data_handle: *mut std::ffi::c_void,
}
#[cfg(target_os = "macos")]
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
fn AXIsProcessTrusted() -> Boolean;
}
#[cfg(target_os = "macos")]
#[link(name = "CoreServices", kind = "framework")]
unsafe extern "C" {
fn AECreateDesc(
type_code: u32,
data_ptr: *const std::ffi::c_void,
data_size: i32,
result: *mut AEDesc,
) -> i32;
fn AEDisposeDesc(desc: *mut AEDesc) -> i32;
fn AEDeterminePermissionToAutomateTarget(
target: *const AEDesc,
event_class: u32,
event_id: u32,
ask_user_if_needed: Boolean,
) -> i32;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_notes_target_from_memo() {
let preflight = preflight_command_permissions("memo notes -s 'Title'");
assert_eq!(preflight.automation_target.as_deref(), Some("Notes"));
assert!(preflight.automation_state.is_some() || !cfg!(target_os = "macos"));
}
#[test]
fn detects_accessibility_ui_scripting() {
let preflight = preflight_command_permissions(
"osascript -e 'tell application \"System Events\" to keystroke \"v\"'",
);
assert_eq!(
preflight.automation_target.as_deref(),
Some("System Events")
);
assert!(preflight.accessibility_state.is_some() || !cfg!(target_os = "macos"));
}
#[test]
fn plain_osascript_delay_has_no_probe() {
let preflight = preflight_command_permissions("osascript -e 'delay 1'");
assert_eq!(preflight.automation_target, None);
assert_eq!(preflight.automation_state, None);
assert_eq!(preflight.accessibility_state, None);
}
#[test]
fn detects_target_from_multiple_inline_e_segments() {
let preflight = preflight_command_permissions(
"osascript -e 'tell application \"Notes\"' -e 'activate'",
);
assert_eq!(preflight.automation_target.as_deref(), Some("Notes"));
}
#[test]
fn detects_target_from_osascript_heredoc() {
let preflight = preflight_command_permissions(
"osascript <<'APPLESCRIPT'\ntell application \"Notes\" to activate\nAPPLESCRIPT",
);
assert_eq!(preflight.automation_target.as_deref(), Some("Notes"));
}
#[test]
fn detects_target_from_piped_heredoc() {
let preflight = preflight_command_permissions(
"cat <<'APPLESCRIPT' | osascript\ntell application id \"com.apple.Notes\" to activate\nAPPLESCRIPT",
);
assert_eq!(
preflight.automation_target.as_deref(),
Some("com.apple.Notes")
);
}
#[test]
fn detects_target_from_printf_pipe() {
let preflight = preflight_command_permissions(
"printf '%s' 'tell application \"Notes\" to activate' | osascript",
);
assert_eq!(preflight.automation_target.as_deref(), Some("Notes"));
}
#[test]
fn detects_target_from_tab_stripped_heredoc() {
let preflight = preflight_command_permissions(
"osascript <<-APPLESCRIPT\n\ttell application \"Notes\" to activate\n\tAPPLESCRIPT",
);
assert_eq!(preflight.automation_target.as_deref(), Some("Notes"));
}
}