import SwiftUI import AppKit import Foundation // MARK: - Data Model struct AppConfig: Codable, Identifiable { let id: String var enabled: Bool var mode: String var isReadWrite: Bool { get { mode == "read-write" } set { mode = newValue ? "read-write" : "read" } } } struct ConfigFile: Codable { var apps: [String: AppEntry] struct AppEntry: Codable { var enabled: Bool var mode: String } } // MARK: - App Metadata struct AppInfo { let key: String let name: String let icon: String let description: String } let appList: [AppInfo] = [ AppInfo(key: "reminders", name: "Reminders", icon: "checklist", description: "Create, complete, search, and manage reminders"), AppInfo(key: "calendar", name: "Calendar", icon: "calendar", description: "View, create, and manage calendar events"), AppInfo(key: "mail", name: "Mail", icon: "envelope", description: "Read, search, send, and organize email"), AppInfo(key: "contacts", name: "Contacts", icon: "person.crop.circle", description: "Search, view, create, and update contacts"), AppInfo(key: "findmy", name: "Find My", icon: "location.circle", description: "Locate Apple devices and AirTags"), AppInfo(key: "maps", name: "Maps", icon: "map", description: "Search locations, get directions, drop pins"), ] // MARK: - Path Helpers let serverInstallDir: URL = { let home = FileManager.default.homeDirectoryForCurrentUser return home .appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs/Claude Working Folder/apple-mcp") }() let configPath: URL = serverInstallDir.appendingPathComponent("config.json") let venvPython: URL = serverInstallDir.appendingPathComponent(".venv/bin/python") let claudeJsonPath: URL = { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent(".claude.json") }() let claudeDesktopConfigPath: URL = { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent("Library/Application Support/Claude/claude_desktop_config.json") }() // MARK: - Config Manager class ConfigManager: ObservableObject { @Published var configs: [String: AppConfig] = [:] @Published var isInstalled = false @Published var isInstalling = false @Published var installLog = "" @Published var installError: String? = nil @Published var pythonVersion: String = "Not installed" init() { checkInstallation() loadConfig() } func checkInstallation() { let fm = FileManager.default isInstalled = fm.fileExists(atPath: venvPython.path) && fm.fileExists(atPath: configPath.path) if isInstalled { let task = Process() let pipe = Pipe() task.executableURL = venvPython task.arguments = ["--version"] task.standardOutput = pipe task.standardError = pipe try? task.run() task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() pythonVersion = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown" } } func loadConfig() { guard FileManager.default.fileExists(atPath: configPath.path) else { return } guard let data = try? Data(contentsOf: configPath), let configFile = try? JSONDecoder().decode(ConfigFile.self, from: data) else { return } var result: [String: AppConfig] = [:] for (key, entry) in configFile.apps { result[key] = AppConfig(id: key, enabled: entry.enabled, mode: entry.mode) } configs = result } func saveConfig() { var entries: [String: ConfigFile.AppEntry] = [:] for (key, config) in configs { entries[key] = ConfigFile.AppEntry(enabled: config.enabled, mode: config.mode) } let configFile = ConfigFile(apps: entries) guard let data = try? JSONEncoder().encode(configFile) else { return } // Pretty-print the JSON guard let json = try? JSONSerialization.jsonObject(with: data), let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) else { return } try? pretty.write(to: configPath) } func toggleEnabled(key: String) { configs[key]?.enabled.toggle() saveConfig() } func setMode(key: String, readWrite: Bool) { configs[key]?.mode = readWrite ? "read-write" : "read" saveConfig() } func install() { isInstalling = true installLog = "" installError = nil DispatchQueue.global(qos: .userInitiated).async { [self] in do { try performInstall() DispatchQueue.main.async { self.isInstalling = false self.checkInstallation() self.loadConfig() } } catch { DispatchQueue.main.async { self.installError = error.localizedDescription self.isInstalling = false } } } } private func log(_ msg: String) { DispatchQueue.main.async { self.installLog += msg + "\n" } } private func performInstall() throws { let fm = FileManager.default let home = fm.homeDirectoryForCurrentUser // Step 1: Copy server files from bundle Resources if available, or verify they exist log("Checking server files...") let serverMainFile = serverInstallDir.appendingPathComponent("apple_mcp.py") if !fm.fileExists(atPath: serverMainFile.path) { // Try to copy from app bundle if let bundleServerDir = Bundle.main.resourceURL?.appendingPathComponent("server") { if fm.fileExists(atPath: bundleServerDir.path) { log("Copying server files from app bundle...") try? fm.createDirectory(at: serverInstallDir, withIntermediateDirectories: true) let items = try fm.contentsOfDirectory(at: bundleServerDir, includingPropertiesForKeys: nil) for item in items { let dest = serverInstallDir.appendingPathComponent(item.lastPathComponent) if fm.fileExists(atPath: dest.path) { try fm.removeItem(at: dest) } try fm.copyItem(at: item, to: dest) } log("Server files copied.") } else { throw NSError(domain: "", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Server files not found. Place the apple-mcp project at:\n\(serverInstallDir.path)" ]) } } else { throw NSError(domain: "", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Server files not found at \(serverInstallDir.path) and not bundled in app." ]) } } else { log("Server files found at \(serverInstallDir.path)") } // Step 2: Install uv if needed let uvPath = home.appendingPathComponent(".local/bin/uv") if !fm.fileExists(atPath: uvPath.path) { log("Installing uv...") try runShell("curl -LsSf https://astral.sh/uv/install.sh | sh") log("uv installed.") } else { log("uv already installed.") } // Step 3: Create venv with Python 3.13 let venvDir = serverInstallDir.appendingPathComponent(".venv") if !fm.fileExists(atPath: venvDir.path) { log("Creating Python 3.13 virtual environment...") try runShell("export PATH=\"$HOME/.local/bin:$PATH\" && cd \"\(serverInstallDir.path)\" && uv venv --python 3.13 .venv") log("Virtual environment created.") } else { log("Virtual environment already exists.") } // Step 4: Install dependencies log("Installing dependencies...") try runShell("export PATH=\"$HOME/.local/bin:$PATH\" && cd \"\(serverInstallDir.path)\" && uv pip install -r requirements.txt") log("Dependencies installed.") // Step 5: Create default config.json if missing if !fm.fileExists(atPath: configPath.path) { log("Creating default config.json...") let defaultConfig = """ { "apps": { "reminders": { "enabled": true, "mode": "read-write" }, "calendar": { "enabled": true, "mode": "read-write" }, "mail": { "enabled": true, "mode": "read" }, "contacts": { "enabled": true, "mode": "read" }, "findmy": { "enabled": true, "mode": "read" }, "maps": { "enabled": true, "mode": "read" } } } """ try defaultConfig.write(to: configPath, atomically: true, encoding: .utf8) log("Default config created.") } // Step 6: Compile EventKit reminders helper let helperDir = serverInstallDir.appendingPathComponent("helpers") let helperSwift = helperDir.appendingPathComponent("reminders_helper.swift") let helperBin = helperDir.appendingPathComponent("reminders_helper") if fm.fileExists(atPath: helperSwift.path) && !fm.fileExists(atPath: helperBin.path) { log("Compiling EventKit reminders helper...") try runShell("swiftc \"\(helperSwift.path)\" -o \"\(helperBin.path)\" -framework EventKit -target arm64-apple-macosx14.0 -Osize") log("Reminders helper compiled.") } else if fm.fileExists(atPath: helperBin.path) { log("Reminders helper already compiled.") } // Step 7: Register in Claude Code (~/.claude.json) log("Registering MCP server in Claude Code...") try registerMCPServer(at: claudeJsonPath, label: "~/.claude.json") // Step 8: Register in Claude Desktop / CoWork log("Registering MCP server in Claude Desktop / CoWork...") try registerMCPServer(at: claudeDesktopConfigPath, label: "claude_desktop_config.json") log("") log("Setup complete! Restart Claude Code and/or the Claude Desktop App to activate.") } private func registerMCPServer(at configURL: URL, label: String) throws { let fm = FileManager.default let serverEntry: [String: Any] = [ "command": venvPython.path, "args": [serverInstallDir.appendingPathComponent("apple_mcp.py").path] ] if fm.fileExists(atPath: configURL.path) { let data = try Data(contentsOf: configURL) if var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { var mcpServers = json["mcpServers"] as? [String: Any] ?? [:] mcpServers["apple-apps"] = serverEntry json["mcpServers"] = mcpServers let updated = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) try updated.write(to: configURL) log(" Registered in \(label)") } } else { // Create parent directory if needed try? fm.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) let json: [String: Any] = ["mcpServers": ["apple-apps": serverEntry]] let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) try data.write(to: configURL) log(" Created \(label) with MCP server registration.") } } @discardableResult private func runShell(_ command: String) throws -> String { let task = Process() let pipe = Pipe() task.executableURL = URL(fileURLWithPath: "/bin/zsh") task.arguments = ["-c", command] task.standardOutput = pipe task.standardError = pipe task.environment = ProcessInfo.processInfo.environment try task.run() task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8) ?? "" if task.terminationStatus != 0 { throw NSError(domain: "", code: Int(task.terminationStatus), userInfo: [ NSLocalizedDescriptionKey: "Command failed: \(command)\n\(output)" ]) } return output } } // MARK: - Views struct AppRowView: View { let info: AppInfo @ObservedObject var manager: ConfigManager var config: AppConfig? { manager.configs[info.key] } var body: some View { HStack(spacing: 12) { Image(systemName: info.icon) .font(.title2) .foregroundColor(config?.enabled == true ? .accentColor : .secondary) .frame(width: 28) VStack(alignment: .leading, spacing: 2) { Text(info.name) .font(.headline) Text(info.description) .font(.caption) .foregroundColor(.secondary) } Spacer() if let config = config { Picker("", selection: Binding( get: { config.mode }, set: { manager.setMode(key: info.key, readWrite: $0 == "read-write") } )) { Text("Read").tag("read") Text("Read & Write").tag("read-write") } .pickerStyle(.segmented) .frame(width: 160) .disabled(!config.enabled) .opacity(config.enabled ? 1.0 : 0.4) Toggle("", isOn: Binding( get: { config.enabled }, set: { _ in manager.toggleEnabled(key: info.key) } )) .toggleStyle(.switch) .labelsHidden() } } .padding(.vertical, 6) } } struct SetupStatusView: View { @ObservedObject var manager: ConfigManager var body: some View { GroupBox { VStack(alignment: .leading, spacing: 10) { HStack { Image(systemName: manager.isInstalled ? "checkmark.circle.fill" : "xmark.circle.fill") .foregroundColor(manager.isInstalled ? .green : .red) .font(.title2) VStack(alignment: .leading) { Text(manager.isInstalled ? "Server Installed" : "Server Not Installed") .font(.headline) if manager.isInstalled { Text(manager.pythonVersion) .font(.caption) .foregroundColor(.secondary) } } Spacer() Button(manager.isInstalled ? "Reinstall" : "Install") { manager.install() } .disabled(manager.isInstalling) .buttonStyle(.borderedProminent) .tint(manager.isInstalled ? .secondary : .accentColor) } if manager.isInstalling { ProgressView() .progressViewStyle(.linear) } if !manager.installLog.isEmpty { ScrollView { Text(manager.installLog) .font(.system(.caption, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) } .frame(maxHeight: 150) .background(Color(nsColor: .textBackgroundColor)) .cornerRadius(6) } if let error = manager.installError { Text(error) .font(.caption) .foregroundColor(.red) } } .padding(4) } label: { Label("Setup", systemImage: "gear") } } } struct ContentView: View { @StateObject private var manager = ConfigManager() var body: some View { VStack(spacing: 0) { // Header HStack { Image(systemName: "apple.logo") .font(.largeTitle) .foregroundColor(.accentColor) VStack(alignment: .leading) { Text("Apple Apps MCP") .font(.title2.bold()) Text("Configure Claude's access to Apple apps") .font(.subheadline) .foregroundColor(.secondary) } Spacer() } .padding() Divider() ScrollView { VStack(spacing: 16) { // Setup section SetupStatusView(manager: manager) // App permissions section GroupBox { VStack(spacing: 0) { ForEach(Array(appList.enumerated()), id: \.element.key) { index, info in AppRowView(info: info, manager: manager) if index < appList.count - 1 { Divider() } } } .padding(4) } label: { Label("App Permissions", systemImage: "lock.shield") } // Footer note HStack { Image(systemName: "info.circle") .foregroundColor(.secondary) Text("Changes are saved automatically. Restart Claude Code for permission changes to take effect.") .font(.caption) .foregroundColor(.secondary) } .padding(.horizontal) } .padding() } } .frame(width: 580, height: 620) } } // MARK: - App Entry Point @main struct AppleMCPConfigApp: App { var body: some Scene { WindowGroup { ContentView() } .windowResizability(.contentSize) } }