lcsa-core 0.1.0

Local context substrate for AI-native software - typed signals for clipboard, selection, and focus
Documentation
use std::process::Command;
use std::thread;
use std::time::Duration;

use crate::capabilities::{SignalSupport, SignalUnsupportedReason};
use crate::error::Error;
use crate::event_bus::EventBus;
use crate::signals::{FocusSignal, FocusTarget, SelectionSignal, StructuralSignal};

const POLL_INTERVAL: Duration = Duration::from_millis(250);

pub(crate) fn spawn_focus_monitor(bus: EventBus) -> Result<(), Error> {
    if !focus_support().is_supported() {
        return Err(Error::UnsupportedSignal(crate::signals::SignalType::Focus));
    }

    let _ = read_focus_source()?;

    thread::spawn(move || {
        let mut last_source: Option<String> = None;

        loop {
            if let Ok(source) = read_focus_source() {
                if source.is_empty() {
                    thread::sleep(POLL_INTERVAL);
                    continue;
                }

                let changed = last_source
                    .as_ref()
                    .map(|previous| previous != &source)
                    .unwrap_or(true);

                if changed {
                    last_source = Some(source.clone());
                    let target = classify_focus_target(&source);
                    let signal = FocusSignal::new(source, target, false);
                    bus.emit(StructuralSignal::Focus(signal));
                }
            }

            thread::sleep(POLL_INTERVAL);
        }
    });

    Ok(())
}

pub(crate) fn spawn_selection_monitor(bus: EventBus) -> Result<(), Error> {
    if !selection_support().is_supported() {
        return Err(Error::UnsupportedSignal(
            crate::signals::SignalType::Selection,
        ));
    }

    thread::spawn(move || {
        let mut last_fingerprint: Option<String> = None;

        loop {
            if let Ok(text) = read_selected_text() {
                if text.trim().is_empty() {
                    thread::sleep(POLL_INTERVAL);
                    continue;
                }

                let fingerprint = format!("text:{}", hash_string(&text));
                let changed = last_fingerprint
                    .as_ref()
                    .map(|previous| previous != &fingerprint)
                    .unwrap_or(true);

                if changed {
                    last_fingerprint = Some(fingerprint);
                    let source = read_focus_source().unwrap_or_else(|_| "unknown".to_string());
                    let signal = SelectionSignal::text(&text, source, true);
                    bus.emit(StructuralSignal::Selection(signal));
                }
            }

            thread::sleep(POLL_INTERVAL);
        }
    });

    Ok(())
}

pub(crate) fn focus_support() -> SignalSupport {
    match read_focus_source() {
        Ok(_) => SignalSupport::Supported,
        Err(error) if is_accessibility_permission_error(&error) => {
            SignalSupport::Unsupported(SignalUnsupportedReason::RequiresAccessibilityPermission)
        }
        Err(_) => SignalSupport::Unsupported(SignalUnsupportedReason::RuntimeDependencyMissing),
    }
}

pub(crate) fn selection_support() -> SignalSupport {
    match probe_accessibility() {
        Ok(_) => SignalSupport::Supported,
        Err(error) if is_accessibility_permission_error(&error) => {
            SignalSupport::Unsupported(SignalUnsupportedReason::RequiresAccessibilityPermission)
        }
        Err(_) => SignalSupport::Unsupported(SignalUnsupportedReason::RuntimeDependencyMissing),
    }
}

fn read_focus_source() -> Result<String, Error> {
    run_osascript(
        "tell application \"System Events\" to get name of first process whose frontmost is true",
    )
}

fn read_selected_text() -> Result<String, Error> {
    run_osascript(
        "tell application \"System Events\"
            set frontProcess to first process whose frontmost is true
            try
                set focusedElement to value of attribute \"AXFocusedUIElement\" of frontProcess
                set selectedText to value of attribute \"AXSelectedText\" of focusedElement
                return selectedText
            on error
                return \"\"
            end try
        end tell",
    )
}

fn probe_accessibility() -> Result<String, Error> {
    run_osascript(
        "tell application \"System Events\"
            set frontProcess to first process whose frontmost is true
            set focusedElement to value of attribute \"AXFocusedUIElement\" of frontProcess
            return value of attribute \"AXRole\" of focusedElement
        end tell",
    )
}

fn run_osascript(script: &str) -> Result<String, Error> {
    let output = Command::new("osascript")
        .arg("-e")
        .arg(script)
        .output()
        .map_err(|error| Error::PlatformError(format!("failed to invoke osascript: {error}")))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        return Err(Error::PlatformError(format!("osascript failed: {stderr}")));
    }

    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn classify_focus_target(source: &str) -> FocusTarget {
    let normalized = source.to_ascii_lowercase();

    if contains_any(
        &normalized,
        &[
            "terminal",
            "iterm",
            "wezterm",
            "kitty",
            "alacritty",
            "warp",
            "ghostty",
        ],
    ) {
        return FocusTarget::Terminal;
    }

    if contains_any(
        &normalized,
        &[
            "safari", "chrome", "firefox", "brave", "edge", "opera", "arc",
        ],
    ) {
        return FocusTarget::Browser;
    }

    if normalized.trim().is_empty() || normalized == "unknown" {
        FocusTarget::Unknown
    } else {
        FocusTarget::Application
    }
}

fn contains_any(haystack: &str, needles: &[&str]) -> bool {
    needles.iter().any(|needle| haystack.contains(needle))
}

fn is_accessibility_permission_error(error: &Error) -> bool {
    let message = error.to_string().to_ascii_lowercase();
    message.contains("not authorized")
        || message.contains("accessibility")
        || message.contains("1743")
}

fn hash_string(input: &str) -> u64 {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    let mut hasher = DefaultHasher::new();
    input.hash(&mut hasher);
    hasher.finish()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn classifies_browser_focus() {
        assert_eq!(classify_focus_target("Google Chrome"), FocusTarget::Browser);
    }

    #[test]
    fn classifies_terminal_focus() {
        assert_eq!(classify_focus_target("iTerm2"), FocusTarget::Terminal);
    }
}