native-devtools-mcp 0.10.1

MCP server for computer use & browser automation — screenshot, OCR, click, type, find_text, Chrome/Electron CDP, template matching. macOS, Windows & Android.
use std::io::{self, Write};
use std::path::PathBuf;

use super::{BOLD, DIM, GREEN, RED, RESET, YELLOW};

const VERSION: &str = env!("CARGO_PKG_VERSION");

pub fn run() {
    println!();
    println!("{BOLD}native-devtools-mcp v{VERSION} — Setup{RESET}");
    println!("{DIM}Guided setup for permissions and MCP client configuration{RESET}");
    println!();

    #[cfg(target_os = "macos")]
    run_macos();

    #[cfg(target_os = "windows")]
    run_windows();

    configure_mcp_clients();

    println!("{BOLD}Setup complete!{RESET}");
    println!();
}

// ── macOS permission checks ──────────────────────────────────────────

#[cfg(target_os = "macos")]
fn run_macos() {
    println!("{BOLD}Step 1: Permissions{RESET}");
    println!();

    check_macos_permission(
        "Accessibility",
        "This permission lets the AI click, type, scroll, and drag on your behalf.\n    \
         Grant it to the app that runs this server (e.g., Terminal, VS Code, Claude Desktop).",
        "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
        || check_accessibility(true),
        || check_accessibility(false),
    );

    check_macos_permission(
        "Screen Recording",
        "This permission lets the AI take screenshots to see what's on screen.\n    \
         Grant it to the app that runs this server (e.g., Terminal, VS Code, Claude Desktop).",
        "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
        check_screen_recording,
        check_screen_recording,
    );
}

#[cfg(target_os = "macos")]
fn check_macos_permission(
    name: &str,
    explanation: &str,
    prefs_url: &str,
    initial_check: impl FnOnce() -> bool,
    recheck: impl FnOnce() -> bool,
) {
    if initial_check() {
        println!("  {GREEN}{RESET} {name}: granted");
        println!();
        return;
    }

    println!("  {RED}{RESET} {name}: not granted");
    println!();
    println!("    {explanation}");
    println!();

    let _ = std::process::Command::new("open").arg(prefs_url).status();
    println!("    → System Settings opened to {name}.");
    wait_for_enter("    Press Enter after granting permission...");
    println!();

    if recheck() {
        println!("  {GREEN}{RESET} {name}: granted");
    } else {
        println!(
            "  {YELLOW}!{RESET} {name}: still not granted — you may need to restart your terminal."
        );
    }
    println!();
}

#[cfg(target_os = "macos")]
fn check_accessibility(prompt: bool) -> bool {
    use core_foundation::base::TCFType;
    use core_foundation::boolean::CFBoolean;
    use core_foundation::dictionary::CFDictionary;
    use core_foundation::string::CFString;

    #[link(name = "ApplicationServices", kind = "framework")]
    extern "C" {
        fn AXIsProcessTrustedWithOptions(options: core_foundation::base::CFTypeRef) -> bool;
    }

    let key = CFString::new("AXTrustedCheckOptionPrompt");
    let value = if prompt {
        CFBoolean::true_value()
    } else {
        CFBoolean::false_value()
    };
    let options = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), value.as_CFType())]);

    unsafe { AXIsProcessTrustedWithOptions(options.as_concrete_TypeRef() as _) }
}

#[cfg(target_os = "macos")]
fn check_screen_recording() -> bool {
    let temp_dir = match tempfile::tempdir() {
        Ok(d) => d,
        Err(_) => return false,
    };
    let path = temp_dir.path().join("test.png");

    let output = std::process::Command::new("/usr/sbin/screencapture")
        .args(["-x", "-C", "-t", "png"])
        .arg(&path)
        .output();

    match output {
        Ok(o) if o.status.success() => {
            // If Screen Recording is denied, the file will exist but be tiny/empty
            std::fs::metadata(&path)
                .map(|m| m.len() > 1024)
                .unwrap_or(false)
        }
        _ => false,
    }
}

// ── Windows permission checks ────────────────────────────────────────

#[cfg(target_os = "windows")]
fn run_windows() {
    println!("{BOLD}Step 1: Permissions{RESET}");
    println!();
    println!("  {GREEN}{RESET} No special permissions required on Windows.");
    println!("    (Input injection may fail when targeting elevated/admin windows)");
    println!();
}

// ── Shared utilities ─────────────────────────────────────────────────

fn wait_for_enter(prompt: &str) {
    print!("{prompt}");
    let _ = io::stdout().flush();
    let mut buf = String::new();
    let _ = io::stdin().read_line(&mut buf);
}

// ── MCP client configuration ────────────────────────────────────────

fn configure_mcp_clients() {
    println!("{BOLD}Step 2: MCP Client Configuration{RESET}");
    println!();

    let detected = detect_clients();

    if detected.is_empty() {
        println!("  No MCP clients detected.");
        println!();
        print_manual_config();
        return;
    }

    for client in &detected {
        println!("  Found: {BOLD}{}{RESET}", client.name);
        println!("  Config: {DIM}{}{RESET}", client.config_path.display());

        if client.already_configured {
            println!("  {GREEN}{RESET} Already configured with native-devtools");
            println!();
            continue;
        }

        println!();
        println!("  Add this to your MCP configuration:");
        println!();
        for line in client.config_snippet.lines() {
            println!("    {DIM}{line}{RESET}");
        }
        println!();

        print!("  Write config automatically? [y/N] ");
        let _ = io::stdout().flush();
        let mut answer = String::new();
        let _ = io::stdin().read_line(&mut answer);

        if answer.trim().eq_ignore_ascii_case("y") {
            match write_client_config(client) {
                Ok(()) => println!("  {GREEN}{RESET} Config written successfully."),
                Err(e) => println!("  {RED}{RESET} Failed to write config: {e}"),
            }
        } else {
            println!("  Skipped. You can add the config manually later.");
        }
        println!();
    }
}

struct ClientInfo {
    name: &'static str,
    config_path: PathBuf,
    config_snippet: &'static str,
    server_config: serde_json::Value,
    already_configured: bool,
}

/// npx-based server config used by most MCP clients.
fn npx_server_config() -> serde_json::Value {
    serde_json::json!({
        "command": "npx",
        "args": ["-y", "native-devtools-mcp"]
    })
}

const NPX_SNIPPET: &str = r#""native-devtools": {
  "command": "npx",
  "args": ["-y", "native-devtools-mcp"]
}"#;

#[cfg(target_os = "macos")]
const CLAUDE_DESKTOP_SNIPPET: &str = r#""native-devtools": {
  "command": "/Applications/NativeDevtools.app/Contents/MacOS/native-devtools-mcp"
}"#;

#[cfg(target_os = "windows")]
const CLAUDE_DESKTOP_SNIPPET: &str = NPX_SNIPPET;

#[cfg(not(any(target_os = "macos", target_os = "windows")))]
const CLAUDE_DESKTOP_SNIPPET: &str = NPX_SNIPPET;

fn detect_clients() -> Vec<ClientInfo> {
    let mut clients = Vec::new();
    let home = match home_dir() {
        Some(h) => h,
        None => return clients,
    };

    // Claude Desktop
    #[cfg(target_os = "macos")]
    let claude_desktop_path =
        home.join("Library/Application Support/Claude/claude_desktop_config.json");
    #[cfg(target_os = "windows")]
    let claude_desktop_path = home.join("AppData/Roaming/Claude/claude_desktop_config.json");
    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
    let claude_desktop_path = home.join(".config/Claude/claude_desktop_config.json");

    if claude_desktop_path.exists() {
        #[cfg(target_os = "macos")]
        let server_config = serde_json::json!({
            "command": "/Applications/NativeDevtools.app/Contents/MacOS/native-devtools-mcp"
        });
        #[cfg(not(target_os = "macos"))]
        let server_config = npx_server_config();

        clients.push(ClientInfo {
            name: "Claude Desktop",
            config_path: claude_desktop_path,
            config_snippet: CLAUDE_DESKTOP_SNIPPET,
            server_config,
            already_configured: false, // set below
        });
    }

    // Claude Code
    let claude_code_path = home.join(".claude.json");
    if claude_code_path.exists() {
        clients.push(ClientInfo {
            name: "Claude Code",
            config_path: claude_code_path,
            config_snippet: NPX_SNIPPET,
            server_config: npx_server_config(),
            already_configured: false,
        });
    }

    // Cursor
    let cursor_path = home.join(".cursor/mcp.json");
    if cursor_path.exists() {
        clients.push(ClientInfo {
            name: "Cursor",
            config_path: cursor_path,
            config_snippet: NPX_SNIPPET,
            server_config: npx_server_config(),
            already_configured: false,
        });
    }

    // Check which are already configured
    for client in &mut clients {
        client.already_configured = config_has_native_devtools(&client.config_path);
    }

    clients
}

fn home_dir() -> Option<PathBuf> {
    #[cfg(target_os = "macos")]
    {
        std::env::var("HOME").ok().map(PathBuf::from)
    }
    #[cfg(target_os = "windows")]
    {
        std::env::var("USERPROFILE").ok().map(PathBuf::from)
    }
    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
    {
        std::env::var("HOME").ok().map(PathBuf::from)
    }
}

fn config_has_native_devtools(path: &std::path::Path) -> bool {
    std::fs::read_to_string(path)
        .map(|content| content.contains("native-devtools"))
        .unwrap_or(false)
}

fn write_client_config(client: &ClientInfo) -> Result<(), String> {
    let content =
        std::fs::read_to_string(&client.config_path).map_err(|e| format!("read error: {e}"))?;

    let mut json: serde_json::Value =
        serde_json::from_str(&content).map_err(|e| format!("JSON parse error: {e}"))?;

    // Create backup
    let backup_path = client.config_path.with_extension("json.backup");
    std::fs::copy(&client.config_path, &backup_path).map_err(|e| format!("backup failed: {e}"))?;
    println!("  {DIM}Backed up to: {}{RESET}", backup_path.display());

    // Add or merge mcpServers
    let mcp_servers = json
        .as_object_mut()
        .ok_or("config is not a JSON object")?
        .entry("mcpServers")
        .or_insert_with(|| serde_json::json!({}));

    mcp_servers
        .as_object_mut()
        .ok_or("mcpServers is not a JSON object")?
        .insert("native-devtools".to_string(), client.server_config.clone());

    // Write back
    let formatted =
        serde_json::to_string_pretty(&json).map_err(|e| format!("JSON serialize error: {e}"))?;
    std::fs::write(&client.config_path, formatted).map_err(|e| format!("write error: {e}"))?;

    Ok(())
}

fn print_manual_config() {
    println!("  To configure manually, add this to your MCP client config:");
    println!();

    #[cfg(target_os = "macos")]
    {
        println!("  For Claude Desktop (macOS, recommended):");
        println!("    {DIM}\"native-devtools\": {{");
        println!("      \"command\": \"/Applications/NativeDevtools.app/Contents/MacOS/native-devtools-mcp\"");
        println!("    }}{RESET}");
        println!();
    }

    println!("  For Claude Code / Cursor / other MCP clients:");
    println!("    {DIM}\"native-devtools\": {{");
    println!("      \"command\": \"npx\",");
    println!("      \"args\": [\"-y\", \"native-devtools-mcp\"]");
    println!("    }}{RESET}");
    println!();
}