nils-macos-agent 0.3.0

CLI crate for nils-macos-agent in the nils-cli workspace.
Documentation
use std::time::Instant;

use crate::backend::applescript::{self, ActivationTarget};
use crate::backend::process::ProcessRunner;
use crate::cli::{OutputFormat, WindowActivateArgs};
use crate::commands::{emit_json_success, reject_tsv_for_list_only};
use crate::error::CliError;
use crate::model::WindowActivateResult;
use crate::retry::run_with_retry;
use crate::run::{
    ActionPolicy, action_policy_result, build_action_meta_with_attempts, next_action_id,
};
use crate::targets::{self, TargetSelector};
use crate::test_mode;
use crate::wait;

pub fn run(
    format: OutputFormat,
    args: &WindowActivateArgs,
    policy: ActionPolicy,
    runner: &dyn ProcessRunner,
) -> Result<(), CliError> {
    let (target, selected_app, selected_window_id) = resolve_target(args)?;
    let action_id = next_action_id("window.activate");
    let started = Instant::now();
    let mut attempts_used = 0u8;
    let retry = policy.retry_policy();

    if !policy.dry_run {
        attempts_used = match run_with_retry(retry, || {
            applescript::activate(runner, &target, policy.timeout_ms)
        }) {
            Ok((_, attempts)) => attempts,
            Err(err) => {
                if !args.reopen_on_fail {
                    return Err(add_reopen_hint(err));
                }
                applescript::reopen(runner, &target, policy.timeout_ms).map_err(add_reopen_hint)?;
                let (_, attempts) = run_with_retry(retry, || {
                    applescript::activate(runner, &target, policy.timeout_ms)
                })
                .map_err(add_reopen_hint)?;
                attempts
            }
        };

        if let Some(wait_ms) = args.wait_ms
            && let Err(wait_err) = wait_for_active_confirmation(runner, &target, wait_ms)
        {
            if !args.reopen_on_fail {
                return Err(add_reopen_hint(wait_err));
            }
            applescript::reopen(runner, &target, policy.timeout_ms).map_err(add_reopen_hint)?;
            let (_, wait_recover_attempts) = run_with_retry(retry, || {
                applescript::activate(runner, &target, policy.timeout_ms)
            })
            .map_err(add_reopen_hint)?;
            attempts_used = attempts_used.saturating_add(wait_recover_attempts);
            wait_for_active_confirmation(runner, &target, wait_ms).map_err(add_reopen_hint)?;
        }
    }

    let result = WindowActivateResult {
        selected_app,
        selected_window_id,
        wait_ms: args.wait_ms,
        policy: action_policy_result(policy),
        meta: build_action_meta_with_attempts(action_id, started, policy, attempts_used),
    };

    match format {
        OutputFormat::Json => {
            emit_json_success("window.activate", result)?;
        }
        OutputFormat::Text => {
            println!(
                "window.activate\taction_id={}\tapp={}\twindow_id={}\telapsed_ms={}",
                result.meta.action_id,
                result.selected_app,
                result
                    .selected_window_id
                    .map(|id| id.to_string())
                    .unwrap_or_else(|| "-".to_string()),
                result.meta.elapsed_ms,
            );
        }
        OutputFormat::Tsv => {
            return reject_tsv_for_list_only();
        }
    }

    Ok(())
}

fn add_reopen_hint(err: CliError) -> CliError {
    err.with_hint(
        "Try --reopen-on-fail to quit/relaunch the target app before retrying activation.",
    )
}

fn resolve_target(
    args: &WindowActivateArgs,
) -> Result<(ActivationTarget, String, Option<u32>), CliError> {
    if let Some(bundle_id) = args.bundle_id.as_ref() {
        return Ok((
            ActivationTarget::BundleId(bundle_id.clone()),
            bundle_id.clone(),
            None,
        ));
    }

    if let Some(app) = args.app.as_ref() {
        return Ok((ActivationTarget::App(app.clone()), app.clone(), None));
    }

    let selector = TargetSelector {
        window_id: args.window_id,
        active_window: args.active_window,
        app: args.app.clone(),
        window_name: args.window_name.clone(),
    };

    let window = targets::resolve_window(&selector).map_err(|err| {
        CliError::runtime(format!(
            "window activate failed for selector `{}`: {}; try --window-id <id> or --app <name> --window-title-contains <title>",
            selector_label(args),
            err
        ))
    })?;

    Ok((
        ActivationTarget::App(window.owner_name.clone()),
        window.owner_name,
        Some(window.id),
    ))
}

fn selector_label(args: &WindowActivateArgs) -> String {
    if let Some(window_id) = args.window_id {
        return format!("--window-id {window_id}");
    }
    if args.active_window {
        return "--active-window".to_string();
    }
    if let Some(app) = args.app.as_deref() {
        if let Some(window_name) = args.window_name.as_deref() {
            return format!("--app {app} --window-title-contains {window_name}");
        }
        return format!("--app {app}");
    }
    if let Some(bundle_id) = args.bundle_id.as_deref() {
        return format!("--bundle-id {bundle_id}");
    }
    "<unknown-selector>".to_string()
}

fn wait_for_active_confirmation(
    runner: &dyn ProcessRunner,
    target: &ActivationTarget,
    wait_ms: u64,
) -> Result<(), CliError> {
    if wait_ms == 0 {
        return Ok(());
    }

    if test_mode::enabled() {
        wait::sleep_ms(wait_ms.min(10));
        return Ok(());
    }

    wait::wait_until("window activation", wait_ms, 50, || match target {
        ActivationTarget::App(app) => applescript::frontmost_app_name(runner, wait_ms)
            .map(|frontmost| frontmost.eq_ignore_ascii_case(app)),
        ActivationTarget::BundleId(bundle_id) => targets::app_active_by_bundle_id(bundle_id),
    })
    .map(|_| ())
}

#[cfg(test)]
mod tests {
    use nils_test_support::{EnvGuard, GlobalStateLock};
    use pretty_assertions::assert_eq;

    use super::{ActivationTarget, resolve_target, selector_label, wait_for_active_confirmation};
    use crate::backend::process::{ProcessFailure, ProcessOutput, ProcessRequest, ProcessRunner};
    use crate::cli::WindowActivateArgs;

    #[derive(Debug)]
    struct PanicRunner;

    impl ProcessRunner for PanicRunner {
        fn run(&self, _request: &ProcessRequest) -> Result<ProcessOutput, ProcessFailure> {
            panic!("runner should not be called in this test")
        }
    }

    #[test]
    fn selector_label_prefers_window_id() {
        let args = WindowActivateArgs {
            window_id: Some(42),
            active_window: false,
            app: Some("Terminal".to_string()),
            window_name: None,
            bundle_id: None,
            wait_ms: None,
            reopen_on_fail: false,
        };
        assert_eq!(selector_label(&args), "--window-id 42");
    }

    #[test]
    fn selector_label_formats_other_selectors() {
        let active = WindowActivateArgs {
            window_id: None,
            active_window: true,
            app: None,
            window_name: None,
            bundle_id: None,
            wait_ms: None,
            reopen_on_fail: false,
        };
        assert_eq!(selector_label(&active), "--active-window");

        let app_window = WindowActivateArgs {
            window_id: None,
            active_window: false,
            app: Some("Terminal".to_string()),
            window_name: Some("Inbox".to_string()),
            bundle_id: None,
            wait_ms: None,
            reopen_on_fail: false,
        };
        assert_eq!(
            selector_label(&app_window),
            "--app Terminal --window-title-contains Inbox"
        );

        let bundle = WindowActivateArgs {
            window_id: None,
            active_window: false,
            app: None,
            window_name: None,
            bundle_id: Some("com.apple.Terminal".to_string()),
            wait_ms: None,
            reopen_on_fail: false,
        };
        assert_eq!(selector_label(&bundle), "--bundle-id com.apple.Terminal");
    }

    #[test]
    fn resolve_target_accepts_bundle_id_and_app_without_lookup() {
        let bundle_args = WindowActivateArgs {
            window_id: None,
            active_window: false,
            app: None,
            window_name: None,
            bundle_id: Some("com.apple.Terminal".to_string()),
            wait_ms: None,
            reopen_on_fail: false,
        };
        let (target, selected_app, selected_window_id) =
            resolve_target(&bundle_args).expect("bundle selector should resolve");
        assert_eq!(
            target,
            ActivationTarget::BundleId("com.apple.Terminal".to_string())
        );
        assert_eq!(selected_app, "com.apple.Terminal");
        assert_eq!(selected_window_id, None);

        let app_args = WindowActivateArgs {
            window_id: None,
            active_window: false,
            app: Some("Terminal".to_string()),
            window_name: None,
            bundle_id: None,
            wait_ms: None,
            reopen_on_fail: false,
        };
        let (target, selected_app, selected_window_id) =
            resolve_target(&app_args).expect("app selector should resolve");
        assert_eq!(target, ActivationTarget::App("Terminal".to_string()));
        assert_eq!(selected_app, "Terminal");
        assert_eq!(selected_window_id, None);
    }

    #[test]
    fn resolve_target_uses_target_lookup_for_active_window() {
        let lock = GlobalStateLock::new();
        let _mode = EnvGuard::set(&lock, "CODEX_MACOS_AGENT_TEST_MODE", "1");

        let args = WindowActivateArgs {
            window_id: None,
            active_window: true,
            app: None,
            window_name: None,
            bundle_id: None,
            wait_ms: None,
            reopen_on_fail: false,
        };
        let (target, selected_app, selected_window_id) =
            resolve_target(&args).expect("active-window selector should resolve");
        assert_eq!(target, ActivationTarget::App("Terminal".to_string()));
        assert_eq!(selected_app, "Terminal");
        assert_eq!(selected_window_id, Some(100));
    }

    #[test]
    fn wait_for_active_confirmation_short_circuits_on_zero_or_test_mode() {
        wait_for_active_confirmation(
            &PanicRunner,
            &ActivationTarget::App("Terminal".to_string()),
            0,
        )
        .expect("zero wait should be a no-op");

        let lock = GlobalStateLock::new();
        let _mode = EnvGuard::set(&lock, "CODEX_MACOS_AGENT_TEST_MODE", "1");
        wait_for_active_confirmation(
            &PanicRunner,
            &ActivationTarget::App("Terminal".to_string()),
            25,
        )
        .expect("test mode should skip runner polling");
    }
}