diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 540d5bba..a33968ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,15 @@ jobs: run: swift format lint --strict --recursive . - name: Build - run: xcodebuild -quiet -scheme iMCP -configuration Debug -destination "platform=macOS" build + run: | + xcodebuild -quiet \ + -scheme iMCP \ + -configuration Debug \ + -destination "platform=macOS" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + HELPER_CODE_SIGNING_ALLOWED=NO \ + build # The cache action's exclude pattern has not kept the module cache out of # the archive, and runner images with the same Xcode build can differ in diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index e2eeef00..1fa323d8 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -53,6 +53,7 @@ enum ServiceRegistry { CalendarService.shared, CaptureService.shared, ContactsService.shared, + HomeService.shared, LocationService.shared, MapsService.shared, MessageService.shared, @@ -71,6 +72,7 @@ enum ServiceRegistry { calendarEnabled: Binding, captureEnabled: Binding, contactsEnabled: Binding, + homeEnabled: Binding, locationEnabled: Binding, mapsEnabled: Binding, messagesEnabled: Binding, @@ -102,6 +104,13 @@ enum ServiceRegistry { service: ContactsService.shared, binding: contactsEnabled ), + ServiceConfig( + name: "Home", + iconName: "house.fill", + color: .orange, + service: HomeService.shared, + binding: homeEnabled + ), ServiceConfig( name: "Location", iconName: "location.fill", @@ -177,6 +186,7 @@ final class ServerController: ObservableObject { @AppStorage("calendarEnabled") private var calendarEnabled = false @AppStorage("captureEnabled") private var captureEnabled = false @AppStorage("contactsEnabled") private var contactsEnabled = false + @AppStorage("homeEnabled") private var homeEnabled = false @AppStorage("locationEnabled") private var locationEnabled = false @AppStorage("mapsEnabled") private var mapsEnabled = true // Default enabled @AppStorage("messagesEnabled") private var messagesEnabled = false @@ -199,6 +209,7 @@ final class ServerController: ObservableObject { calendarEnabled: $calendarEnabled, captureEnabled: $captureEnabled, contactsEnabled: $contactsEnabled, + homeEnabled: $homeEnabled, locationEnabled: $locationEnabled, mapsEnabled: $mapsEnabled, messagesEnabled: $messagesEnabled, diff --git a/App/Info.plist b/App/Info.plist index 855567cc..5365a477 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -7,6 +7,7 @@ NSBonjourServices _mcp._tcp + _imcp-helper._tcp SUEnableInstallerLauncherService diff --git a/App/Services/Home.swift b/App/Services/Home.swift new file mode 100644 index 00000000..b66acd92 --- /dev/null +++ b/App/Services/Home.swift @@ -0,0 +1,258 @@ +import AppKit +import Darwin +import MCP +import Network +import OSLog + +/// Serves the Home tools by proxying to the Catalyst helper that holds HomeKit access. +/// +/// HomeKit is not available to native macOS apps, +/// so the helper runs as a separate app in `Contents/Helpers` +/// and implements the same tool definitions from `HomeTools`. +/// This service launches the helper, connects to it over the loopback interface, +/// and forwards each call as an MCP `tools/call` request. +actor HomeService: Service, HomeBackend { + static let shared = HomeService() + + private let log = Logger.service("home") + private let helperURL: URL? + private var client: MCP.Client? + private var connection: NWConnection? + private var connecting: Task? + private var authorized = false + private var launchedHelper: LaunchedHelper? + + private struct LaunchedHelper: Sendable { + let pid: pid_t + let endpoint: NWEndpoint + } + + init(helperURL: URL? = nil) { self.helperURL = helperURL } + + // MARK: - Service + + nonisolated var tools: [Tool] { return HomeTools.tools(backend: self) } + + var isActivated: Bool { authorized && connection?.state == .ready } + + func activate() async throws { + if isActivated { return } + if let connecting { return try await connecting.value } + let task = Task { try await self.connect() } + connecting = task + defer { connecting = nil } + try await task.value + } + + // MARK: - HomeBackend + + func call(_ tool: String, _ input: [String: Value]) async throws -> Value { + try await activate() + guard let active = client else { throw HomeError("iMCP Helper is not connected.") } + do { return try await forward(active, tool, input) } catch let error as HomeError { throw error } catch { + if client === active { + authorized = false + client = nil + connection?.cancel() + connection = nil + await active.disconnect() + } + try await activate() + // A lost response does not prove that a write failed. Never duplicate a write. + guard tools.first(where: { $0.name == tool })?.annotations.readOnlyHint == true + else { + throw HomeError( + "The iMCP Helper connection was lost. The write may have completed. Inspect the home before retrying." + ) + } + guard let client else { throw HomeError("iMCP Helper could not reconnect.") } + return try await forward(client, tool, input) + } + } + + // MARK: - Connection + + private func forward(_ client: MCP.Client, _ tool: String, _ input: [String: Value]) async throws -> Value { + let connection = self.connection + let monitor = Task { + guard let connection else { return } + while !Task.isCancelled { + do { try await Task.sleep(for: .milliseconds(250)) } catch { return } + switch connection.state { + case .failed, .cancelled, .waiting: + await client.disconnect() + return + default: break + } + } + } + defer { monitor.cancel() } + let result = try await client.callTool(name: tool, arguments: input) + let texts = result.content.compactMap { item -> String? in + if case .text(let text, _, _) = item { return text } + return nil + } + if result.isError == true { throw HomeError(texts.joined(separator: "\n")) } + guard let text = texts.first, let data = text.data(using: .utf8) else { + throw HomeError("iMCP Helper returned no JSON text.") + } + do { return try JSONDecoder().decode(Value.self, from: data) } catch { + throw HomeError("iMCP Helper returned invalid JSON: \(error.localizedDescription)") + } + } + + private func connect() async throws { + authorized = false + let old = client + client = nil + connection?.cancel() + connection = nil + await old?.disconnect() + launchedHelper = try await launchHelper(previous: launchedHelper, overrideURL: helperURL) + let parameters = NWParameters.tcp + parameters.acceptLocalOnly = true + parameters.requiredInterfaceType = .loopback + parameters.includePeerToPeer = false + if let ip = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options { ip.version = .v4 } + let endpoint: NWEndpoint + if let launchedHelper { + endpoint = launchedHelper.endpoint + } else { + let browser = NWBrowser(for: .bonjour(type: "_imcp-helper._tcp", domain: "local."), using: .tcp) + do { + endpoint = try await BonjourDiscovery.discoverEndpoint( + using: browser, + timeout: .seconds(15), + preferring: { String(describing: $0.endpoint).contains("iMCP Helper") } + ) + } catch { + throw HomeError( + "iMCP Helper was not found. Quit any manually started iMCP Helper and enable Home again. \(error.localizedDescription)" + ) + } + } + let deadline = Date().addingTimeInterval(15) + repeat { + if let launchedHelper { try await checkHelperIsRunning(launchedHelper) } + do { + try await connect(to: endpoint, parameters: parameters) + return + } catch let error as HomeError { throw error } catch { + guard let launchedHelper else { throw error } + try await checkHelperIsRunning(launchedHelper) + guard Date() < deadline else { + throw HomeError( + "iMCP Helper did not accept a local connection within 15 seconds. Check its status window, then retry." + ) + } + try await Task.sleep(for: .milliseconds(250)) + } + } while true + } + + private func connect(to endpoint: NWEndpoint, parameters: NWParameters) async throws { + let connection = NWConnection(to: endpoint, using: parameters) + let transport = NetworkTransport( + connection: connection, + heartbeatConfig: .init(enabled: false), + reconnectionConfig: .disabled, + bufferConfig: .unlimited + ) + let client = MCP.Client(name: "iMCP", version: Bundle.main.shortVersionString ?? "unknown") + let timeout = Task { + // NetworkTransport waits indefinitely in NWConnection.waiting. + // Retry refused connections while the helper starts. + for _ in 0 ..< 80 { + do { try await Task.sleep(for: .milliseconds(250)) } catch { return } + if case .waiting = connection.state { break } + } + connection.cancel() + await client.disconnect() + } + defer { timeout.cancel() } + do { + let result = try await client.connect(transport: transport) + guard result.serverInfo.name == "iMCP Helper" else { + throw HomeError("The selected endpoint is not iMCP Helper.") + } + _ = try await forward(client, "homes_list", [:]) + self.client = client + self.connection = connection + authorized = true + log.info("Connected to iMCP Helper") + } catch { + connection.cancel() + await client.disconnect() + throw error + } + } + + // MARK: - Helper Launch + + @MainActor + private func checkHelperIsRunning(_ helper: LaunchedHelper) throws { + guard helper.pid > 0, + let application = NSRunningApplication(processIdentifier: helper.pid), + !application.isTerminated + else { + throw HomeError( + "iMCP Helper exited before the connection was ready. Check its crash report in Console, then retry." + ) + } + } + + @MainActor + private func launchHelper(previous: LaunchedHelper?, overrideURL: URL?) async throws -> LaunchedHelper? { + if let running = NSWorkspace.shared.runningApplications.first(where: { + $0.bundleIdentifier == "co.dododo.iMCP.Helper" && !$0.isTerminated + }) { + return previous?.pid == running.processIdentifier ? previous : nil + } + let embedded = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/iMCP Helper.app") + let url = + overrideURL + ?? (FileManager.default.fileExists(atPath: embedded.path) + ? embedded : NSWorkspace.shared.urlForApplication(withBundleIdentifier: "co.dododo.iMCP.Helper")) + guard let url else { return nil } + let port = try availableLoopbackPort() + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + // Xcode's native macOS debugger libraries cannot load in a Catalyst app. + // Empty values override the launch environment; omitting the keys does not. + configuration.environment = [ + "DYLD_INSERT_LIBRARIES": "", + "DYLD_LIBRARY_PATH": "", + "DYLD_FRAMEWORK_PATH": "", + ] + configuration.arguments = ["--parent-pid", String(getpid()), "--port", String(port)] + let application = try await NSWorkspace.shared.openApplication(at: url, configuration: configuration) + return LaunchedHelper( + pid: application.processIdentifier, + endpoint: .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: port)!) + ) + } + + /// Selects an ephemeral loopback port for the child process. + /// The helper reports a bind error if another process takes it before launch. + private nonisolated func availableLoopbackPort() throws -> UInt16 { + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw HomeError("Cannot allocate a socket for iMCP Helper.") } + defer { close(descriptor) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { throw HomeError("Cannot bind a socket for iMCP Helper.") } + var length = socklen_t(MemoryLayout.size) + let nameResult = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(descriptor, $0, &length) } + } + guard nameResult == 0 else { throw HomeError("Cannot select a port for iMCP Helper.") } + return UInt16(bigEndian: address.sin_port) + } +} diff --git a/CLI/MCPService.swift b/CLI/MCPService.swift index f4acbb16..6590b4da 100644 --- a/CLI/MCPService.swift +++ b/CLI/MCPService.swift @@ -1,9 +1,10 @@ +import Foundation import Logging import MCP import Network import ServiceLifecycle -private let serviceType = "_mcp._tcp" +private let serviceType = ProcessInfo.processInfo.environment["IMCP_SERVICE_TYPE"] ?? "_mcp._tcp" private let parameters: NWParameters = { let parameters = NWParameters.tcp diff --git a/CLITests/HomeProxyTests.swift b/CLITests/HomeProxyTests.swift new file mode 100644 index 00000000..db0457ce --- /dev/null +++ b/CLITests/HomeProxyTests.swift @@ -0,0 +1,40 @@ +import AppKit +import XCTest + +final class HomeProxyTests: XCTestCase { + @MainActor + func testLiveLaunchAndRecovery() async throws { + guard let path = ProcessInfo.processInfo.environment["IMCP_HELPER_PATH"] else { + throw XCTSkip("Set IMCP_HELPER_PATH to run the signed helper integration test.") + } + let backend = HomeService(helperURL: URL(fileURLWithPath: path)) + let initial = await backend.isActivated + XCTAssertFalse(initial) + try await backend.activate() + let active = await backend.isActivated + XCTAssertTrue(active) + let homes = try await backend.call("homes_list", [:]) + XCTAssertNotEqual(homes, .null) + do { + _ = try await backend.call("accessories_get", ["accessory": "invalid"]) + XCTFail("The helper must report invalid accessory IDs.") + } catch let error as HomeError { + XCTAssertFalse(error.localizedDescription.isEmpty) + } + let helper = try XCTUnwrap( + NSWorkspace.shared.runningApplications.first { + $0.bundleIdentifier == "co.dododo.iMCP.Helper" + } + ) + XCTAssertTrue(helper.forceTerminate()) + for _ in 0 ..< 50 { + if helper.isTerminated { break } + try await Task.sleep(for: .milliseconds(100)) + } + XCTAssertTrue(helper.isTerminated) + let recovered = try await backend.call("homes_list", [:]) + XCTAssertEqual(recovered, homes) + let reactivated = await backend.isActivated + XCTAssertTrue(reactivated) + } +} diff --git a/CLITests/HomeToolsTests.swift b/CLITests/HomeToolsTests.swift new file mode 100644 index 00000000..04b86690 --- /dev/null +++ b/CLITests/HomeToolsTests.swift @@ -0,0 +1,98 @@ +import XCTest + +final class HomeToolsTests: XCTestCase { + private let backend = HomeToolRecorder() + + func testForwardingPreservesToolNameAndArguments() async throws { + let tools = HomeTools.tools(backend: backend) + for tool in tools { + let arguments: [String: Value] = ["probe": .string(tool.name)] + let value = try await tool.callAsFunction(arguments) + XCTAssertEqual(value, .object(arguments)) + } + let calls = await backend.names + XCTAssertEqual(calls, tools.map(\.name)) + XCTAssertEqual(Set(calls).count, 28) + } + + func testWriteAnnotationsAreExplicit() { + let destructive: Set = [ + "accessories_remove", "rooms_remove", "zones_remove", "scenes_remove", "automations_remove", + ] + let reads: Set = [ + "homes_list", "home_export", "rooms_list", "zones_list", "accessories_list", "accessories_get", + "characteristics_read", "scenes_list", "automations_list", + ] + for tool in HomeTools.tools(backend: backend) { + XCTAssertEqual(tool.annotations.readOnlyHint, reads.contains(tool.name), tool.name) + XCTAssertEqual(tool.annotations.destructiveHint, destructive.contains(tool.name), tool.name) + XCTAssertEqual(tool.annotations.openWorldHint, false, tool.name) + } + } + + func testRejectsUnknownArgumentsAndWrongTypes() throws { + XCTAssertThrowsError(try validate("rooms_create", ["name": "Test", "surprise": true])) + XCTAssertThrowsError(try validate("home_export", ["include_values": "true"])) + XCTAssertThrowsError(try validate("accessories_rename", ["accessory": "id"])) + XCTAssertThrowsError(try validate("characteristics_write", ["characteristic": "id", "value": .null])) + XCTAssertThrowsError( + try validate("characteristics_write", ["characteristic": "id", "value": .double(.infinity)]) + ) + } + + func testTriggerKindsCannotBeMixed() throws { + let input: [String: Value] = [ + "name": "Test", "scenes": ["scene"], + "trigger": ["event": "sunrise", "characteristic": "id", "value": true], + ] + XCTAssertThrowsError(try validate("automations_create", input)) + } + + func testTimerWeekdaysAreBoundedAndUnique() throws { + for days: Value in [[0], [8], [1, 1], [], ["Monday"]] { + XCTAssertThrowsError( + try validate( + "automations_create", + [ + "name": "Test", "scenes": ["scene"], + "trigger": ["fire_at": "2030-01-01T12:00:00Z", "recurrence": days], + ] + ) + ) + } + try validate( + "automations_create", + [ + "name": "Test", "scenes": ["scene"], + "trigger": ["fire_at": "2030-01-01T12:00:00Z", "recurrence": [2, 3, 4, 5, 6]], + ] + ) + } + + func testAllowsAllCharacteristicValueKindsAndTriggerVariants() throws { + for value: Value in [true, 42, 0.5, "AQID"] { + try validate("characteristics_write", ["characteristic": "id", "value": value]) + } + for trigger: Value in [ + ["event": "sunrise", "offset_minutes": -30], + ["event": "sunset"], + ["characteristic": "id", "value": false], + ["fire_at": "2030-01-01T12:00:00Z", "recurrence": "daily"], + ] { + try validate("automations_create", ["name": "Test", "scenes": ["scene"], "trigger": trigger]) + } + } + + private func validate(_ name: String, _ input: [String: Value]) throws { + let tool = try XCTUnwrap(HomeTools.tools(backend: backend).first { $0.name == name }) + try HomeInput.validate(input, schema: tool.inputSchema) + } +} + +private actor HomeToolRecorder: HomeBackend { + var names: [String] = [] + func call(_ tool: String, _ input: [String: Value]) async throws -> Value { + names.append(tool) + return .object(input) + } +} diff --git a/CLITests/ServiceGroupConfigurationTests.swift b/CLITests/ServiceGroupConfigurationTests.swift index a4f70a6c..0fe6a8f0 100644 --- a/CLITests/ServiceGroupConfigurationTests.swift +++ b/CLITests/ServiceGroupConfigurationTests.swift @@ -18,7 +18,7 @@ final class ServiceGroupConfigurationTests: XCTestCase { /// exit cleanly rather than throwing. This is the primary regression the /// production fix addresses. func testServiceReturningNormallyExitsGroupCleanly() async throws { - struct ImmediatelyReturningService: Service { + struct ImmediatelyReturningService: ServiceLifecycle.Service { func run() async throws {} } @@ -43,7 +43,7 @@ final class ServiceGroupConfigurationTests: XCTestCase { /// to throw. If this ever stops throwing — e.g. the library changes its /// default — the production fix may no longer be necessary. func testDefaultCancelGroupThrowsWhenServiceReturns() async { - struct ImmediatelyReturningService: Service { + struct ImmediatelyReturningService: ServiceLifecycle.Service { func run() async throws {} } diff --git a/Helper/App.swift b/Helper/App.swift new file mode 100644 index 00000000..d1a9d7c4 --- /dev/null +++ b/Helper/App.swift @@ -0,0 +1,83 @@ +import Network +import SwiftUI + +@main +struct HelperApp: App { + @StateObject private var runtime = HelperRuntime() + + var body: some Scene { + WindowGroup { + VStack(alignment: .leading, spacing: 16) { + Text("iMCP Helper").font(.title) + Text(runtime.status).textSelection(.enabled) + if runtime.canRetry { + Button("Retry") { Task { await runtime.start() } } + } + } + .padding(24) + .frame(minWidth: 420, minHeight: 160) + .task { await runtime.start() } + } + } +} + +@MainActor +final class HelperRuntime: ObservableObject { + @Published private(set) var canRetry = false + @Published private(set) var status = "Starting HomeKit…" + private let backend = HomeKitBackend() + private var server: HelperServer? + private var parentMonitor: DispatchSourceProcess? + private var started = false + + func start() async { + guard !started else { return } + started = true + canRetry = false + do { + try monitorParent() + let server = try self.server ?? HelperServer(backend: backend, port: requestedPort()) + self.server = server + status = "Starting the local connection…" + try await server.start() + status = "Waiting for HomeKit access…" + try await backend.store.ensureLoaded() + status = "HomeKit is ready. \(backend.store.homes.count) homes available." + } catch { + status = "iMCP Helper failed: \(homeErrorMessage(error))" + started = false + canRetry = true + } + print(status) + fflush(stdout) + } + + private func requestedPort() throws -> NWEndpoint.Port? { + guard let index = CommandLine.arguments.firstIndex(of: "--port") else { return nil } + guard CommandLine.arguments.indices.contains(index + 1), + let number = UInt16(CommandLine.arguments[index + 1]), number > 0, + let port = NWEndpoint.Port(rawValue: number) + else { + throw HomeError("--port requires a TCP port between 1 and 65535.") + } + return port + } + + private func monitorParent() throws { + guard parentMonitor == nil else { return } + guard let index = CommandLine.arguments.firstIndex(of: "--parent-pid") else { return } + guard CommandLine.arguments.indices.contains(index + 1), + let pid = Int32(CommandLine.arguments[index + 1]), pid > 1 + else { + throw HomeError("--parent-pid requires a valid process ID.") + } + #if targetEnvironment(macCatalyst) + let monitor = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: .main) + monitor.setEventHandler { exit(0) } + parentMonitor = monitor + monitor.resume() + // Close the race where the parent exits before the source is installed. + if kill(pid, 0) == -1 && errno == ESRCH { exit(0) } + #endif + } +} diff --git a/Helper/Extensions/HomeKit+Extensions.swift b/Helper/Extensions/HomeKit+Extensions.swift new file mode 100644 index 00000000..31a157d8 --- /dev/null +++ b/Helper/Extensions/HomeKit+Extensions.swift @@ -0,0 +1,99 @@ +import CoreFoundation +import Foundation +import HomeKit + +@MainActor +enum HomeValue { + static func encode(_ value: Any?) -> Value { + guard let value else { return .null } + if let number = value as? NSNumber { + if CFGetTypeID(number) == CFBooleanGetTypeID() { return .bool(number.boolValue) } + return number.doubleValue.isFinite ? .double(number.doubleValue) : .null + } + if let string = value as? String { return .string(string) } + if let data = value as? Data { return .string(data.base64EncodedString()) } + if let values = value as? [Any] { return .array(values.map(encode)) } + return .null + } + + static func decode(_ value: Value, for characteristic: HMCharacteristic, writable: Bool = true) throws -> NSCopying + { + if writable && !characteristic.properties.contains(HMCharacteristicPropertyWritable) { + throw HomeError("The characteristic is read-only.") + } + guard let metadata = characteristic.metadata, let format = metadata.format else { + throw HomeError("The characteristic has no format metadata.") + } + if format == HMCharacteristicMetadataFormatBool { + guard case .bool(let value) = value else { throw HomeError("The characteristic requires a Boolean.") } + return NSNumber(value: value) + } + if format == HMCharacteristicMetadataFormatString { + guard case .string(let value) = value else { throw HomeError("The characteristic requires a string.") } + if let limit = metadata.maxLength, value.utf8.count > limit.intValue { + throw HomeError("The string exceeds maxLength.") + } + return value as NSString + } + if format == HMCharacteristicMetadataFormatData || format == HMCharacteristicMetadataFormatTLV8 { + guard case .string(let value) = value, let data = Data(base64Encoded: value) else { + throw HomeError("The characteristic requires a base64 string.") + } + return data as NSData + } + let number: Double + switch value { + case .int(let value): number = Double(value) + case .double(let value): number = value + default: throw HomeError("The characteristic requires a number.") + } + guard number.isFinite else { throw HomeError("The number must be finite.") } + let ranges: [String: ClosedRange] = [ + HMCharacteristicMetadataFormatUInt8: 0 ... 255, + HMCharacteristicMetadataFormatUInt16: 0 ... 65535, + HMCharacteristicMetadataFormatUInt32: 0 ... 4294967295, + // JSON numeric precision limits exact uint64 representation. + HMCharacteristicMetadataFormatUInt64: 0 ... 9007199254740991, + HMCharacteristicMetadataFormatInt: Double(Int32.min) ... Double(Int32.max), + ] + if let range = ranges[format] { + guard range.contains(number), number.rounded() == number else { + throw HomeError("The number must be an integer within the characteristic format's range.") + } + } else if format != HMCharacteristicMetadataFormatFloat { + throw HomeError("Unsupported characteristic format: \(format)") + } + if let min = metadata.minimumValue, number < min.doubleValue { + throw HomeError("The value is below minimumValue.") + } + if let max = metadata.maximumValue, number > max.doubleValue { + throw HomeError("The value exceeds maximumValue.") + } + if let valid = metadata.validValues, !valid.contains(where: { $0.doubleValue == number }) { + throw HomeError("The value is not in validValues.") + } + if let step = metadata.stepValue?.doubleValue, step.isFinite, step > 0 { + let minimum = metadata.minimumValue?.doubleValue ?? 0 + let base = minimum.isFinite ? minimum : 0 + let count = (number - base) / step + if abs(count - count.rounded()) > 0.000001 { throw HomeError("The value does not match stepValue.") } + } + return NSNumber(value: number) + } +} + +func homeErrorMessage(_ error: Error) -> String { + let error = error as NSError + guard error.domain == HMErrorDomain else { return error.localizedDescription } + switch HMError.Code(rawValue: error.code) { + case .homeAccessNotAuthorized: + return "HomeKit access is not authorized. Enable iMCP Helper in System Settings → Privacy & Security → HomeKit." + case .accessoryNotReachable: return "The accessory is not reachable. Check its power and connection." + case .readOnlyCharacteristic: return "The characteristic is read-only." + case .insufficientPrivileges: return "This operation requires a home owner or administrator." + case .nameContainsProhibitedCharacters: return "The name contains prohibited characters." + case .nameDoesNotEndWithValidCharacters: return "The name must end with a letter or number." + case .cloudDataSyncInProgress: return "HomeKit is syncing iCloud data. Retry when syncing finishes." + default: return error.localizedDescription + } +} diff --git a/Helper/Helper.entitlements b/Helper/Helper.entitlements new file mode 100644 index 00000000..1e4189a7 --- /dev/null +++ b/Helper/Helper.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.homekit + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/Helper/Info.plist b/Helper/Info.plist new file mode 100644 index 00000000..c1f15819 --- /dev/null +++ b/Helper/Info.plist @@ -0,0 +1,12 @@ + + + + + LSMultipleInstancesProhibited + + NSBonjourServices + + _imcp-helper._tcp + + + diff --git a/Helper/Models/HomeModels.swift b/Helper/Models/HomeModels.swift new file mode 100644 index 00000000..0a131550 --- /dev/null +++ b/Helper/Models/HomeModels.swift @@ -0,0 +1,95 @@ +import Foundation + +struct HomeSummary: Codable, Sendable { + let id: String + let name: String + let isPrimary: Bool + let rooms: Int + let zones: Int + let accessories: Int + let scenes: Int + let automations: Int +} + +struct RoomSummary: Codable, Sendable { + let id: String + let name: String + let accessoryCount: Int + let isDefaultRoom: Bool +} + +struct ZoneSummary: Codable, Sendable { + let id: String + let name: String + let rooms: [String] +} + +struct AccessorySummary: Codable, Sendable { + let id: String + let name: String + let home: String? + let room: String? + let category: String + let categoryDescription: String + let manufacturer: String? + let model: String? + let firmware: String? + let isReachable: Bool + let isBridged: Bool + let bridgedBy: String? + let services: [ServiceSummary] +} + +struct ServiceSummary: Codable, Sendable { + let id: String + let name: String + let type: String + let description: String +} + +struct AccessoryDetail: Codable, Sendable { + let accessory: AccessorySummary + let uniqueIdentifiersForBridgedAccessories: [String] + let services: [ServiceDetail] +} + +struct ServiceDetail: Codable, Sendable { + let service: ServiceSummary + let characteristics: [CharacteristicDetail] +} + +struct CharacteristicDetail: Codable, Sendable { + let id: String + let type: String + let description: String + let properties: [String] + let metadata: [String: Value] + var value: Value? + var error: String? +} + +struct SceneSummary: Codable, Sendable { + let id: String + let name: String + let type: String + let actions: [Value] +} + +struct AutomationSummary: Codable, Sendable { + let id: String + let name: String + let kind: String + let isEnabled: Bool + let scenes: [String] + let details: [String: Value] + var lastFireDate: Value = .null + var limitations: String = + "HomeKit may omit Home app or Shortcuts behavior. lastFireDate is no longer supported by Apple." +} + +struct BatchResult: Codable, Sendable { + let id: String + let ok: Bool + var value: Value? + var error: String? +} diff --git a/Helper/Server/HelperAdvertisement.swift b/Helper/Server/HelperAdvertisement.swift new file mode 100644 index 00000000..4929f4cc --- /dev/null +++ b/Helper/Server/HelperAdvertisement.swift @@ -0,0 +1,78 @@ +import Foundation +import Network +import dnssd + +/// Publishes the helper only to processes on this Mac, without multicast discovery. +@MainActor +final class HelperAdvertisement { + private var reference: DNSServiceRef? + private var permissionBrowser: NWBrowser? + private var continuation: CheckedContinuation? + + func start(port: UInt16) async throws { + // Browsing also lets macOS present its local network permission prompt. + let browser = NWBrowser(for: .bonjour(type: "_imcp-helper._tcp", domain: "local."), using: .tcp) + permissionBrowser = browser + browser.start(queue: .main) + let timeout = Task { [weak self] in + do { try await Task.sleep(for: .seconds(30)) } catch { return } + self?.finish(DNSServiceErrorType(kDNSServiceErr_Timeout)) + } + defer { timeout.cancel() } + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + let result = DNSServiceRegister( + &reference, + 0, + kDNSServiceInterfaceIndexLocalOnly, + "iMCP Helper", + "_imcp-helper._tcp", + "local.", + "localhost.", + port.bigEndian, + 0, + nil, + { _, _, error, _, _, _, context in + guard let context else { return } + MainActor.assumeIsolated { + let advertiser = Unmanaged.fromOpaque(context).takeUnretainedValue() + advertiser.finish(error) + } + }, + Unmanaged.passUnretained(self).toOpaque() + ) + if result != kDNSServiceErr_NoError { finish(result); return } + guard let reference else { finish(DNSServiceErrorType(kDNSServiceErr_Unknown)); return } + let queueResult = DNSServiceSetDispatchQueue(reference, .main) + if queueResult != kDNSServiceErr_NoError { finish(queueResult) } + + } + } + + private func finish(_ error: DNSServiceErrorType) { + let continuation = continuation + self.continuation = nil + permissionBrowser?.cancel() + permissionBrowser = nil + if error == kDNSServiceErr_NoError { + continuation?.resume() + } else { + stop() + continuation?.resume( + throwing: HomeError( + "iMCP Helper Bonjour registration failed (\(error)). Allow iMCP Helper in System Settings → Privacy & Security → Local Network, then retry." + ) + ) + } + } + + func stop() { + permissionBrowser?.cancel() + permissionBrowser = nil + let pending = continuation + continuation = nil + pending?.resume(throwing: CancellationError()) + if let reference { DNSServiceRefDeallocate(reference) } + reference = nil + } +} diff --git a/Helper/Server/HelperServer.swift b/Helper/Server/HelperServer.swift new file mode 100644 index 00000000..3e1f5ab7 --- /dev/null +++ b/Helper/Server/HelperServer.swift @@ -0,0 +1,143 @@ +import Foundation +import MCP +import Network +import OSLog +import Ontology + +actor HelperServer { + private let backend: any HomeBackend + private let port: NWEndpoint.Port? + private var listener: NWListener? + private var advertisement: HelperAdvertisement? + private var sessions: [UUID: MCP.Server] = [:] + private var startup: CheckedContinuation? + private let log = Logger.service("helper.server") + + init(backend: any HomeBackend, port: NWEndpoint.Port? = nil) { + self.backend = backend + self.port = port + } + + func start() async throws { + guard listener == nil else { return } + let parameters = NWParameters.tcp + parameters.acceptLocalOnly = true + parameters.requiredInterfaceType = .loopback + parameters.includePeerToPeer = false + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: port ?? .any) + if let ip = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options { ip.version = .v4 } + let listener = try NWListener(using: parameters) + self.listener = listener + listener.newConnectionHandler = { [weak self] connection in + Task { await self?.accept(connection) } + } + listener.stateUpdateHandler = { [weak self] state in + Task { await self?.stateChanged(state) } + } + try await withCheckedThrowingContinuation { continuation in + startup = continuation + listener.start(queue: .main) + Task { [weak self] in + try? await Task.sleep(for: .seconds(10)) + await self?.startupTimedOut() + } + } + // Parent-launched helpers use the private port passed by iMCP. + // Manual launches retain Bonjour discovery for development clients. + if port != nil { return } + guard let port = listener.port else { throw HomeError("iMCP Helper has no TCP port.") } + let advertisement = await HelperAdvertisement() + self.advertisement = advertisement + do { try await advertisement.start(port: port.rawValue) } catch { + listener.cancel() + self.listener = nil + await advertisement.stop() + self.advertisement = nil + throw error + } + } + + private func stateChanged(_ state: NWListener.State) async { + switch state { + case .ready: + startup?.resume() + startup = nil + case .failed(let error): + startup?.resume(throwing: error) + startup = nil + listener?.cancel() + listener = nil + await advertisement?.stop() + advertisement = nil + log.error("Home listener failed: \(error.localizedDescription)") + default: break + } + } + private func startupTimedOut() { + guard let startup else { return } + self.startup = nil + listener?.cancel() + listener = nil + startup.resume(throwing: HomeError("The iMCP Helper listener did not start within 10 seconds.")) + } + + private func accept(_ connection: NWConnection) async { + let id = UUID() + let server = MCP.Server( + name: "iMCP Helper", + version: Bundle.main.shortVersionString ?? "1.0", + capabilities: .init(tools: .init(listChanged: false)) + ) + let tools = HomeKitService(backend: backend).tools + let transport = NetworkTransport( + connection: connection, + heartbeatConfig: .init(enabled: false), + reconnectionConfig: .disabled, + bufferConfig: .unlimited + ) + sessions[id] = server + await server.withMethodHandler(ListTools.self) { _ in + try ListTools.Result( + tools: tools.map { tool in + MCP.Tool( + name: tool.name, + description: tool.description, + inputSchema: try Value(tool.inputSchema), + annotations: tool.annotations + ) + } + ) + } + await server.withMethodHandler(CallTool.self) { params in + do { + guard let tool = tools.first(where: { $0.name == params.name }) else { + throw HomeError("Unknown Home tool: \(params.name)") + } + try HomeInput.validate(params.arguments ?? [:], schema: tool.inputSchema) + let value = try await tool.callAsFunction(params.arguments ?? [:]) + let encoder = JSONEncoder() + encoder.userInfo[Ontology.DateTime.timeZoneOverrideKey] = TimeZone.current + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let text = String(decoding: try encoder.encode(value), as: UTF8.self) + return CallTool.Result(content: [.text(text: text, annotations: nil, _meta: nil)], isError: false) + } catch { + return CallTool.Result( + content: [.text(text: "Error: \(error)", annotations: nil, _meta: nil)], + isError: true + ) + } + } + do { + try await server.start(transport: transport) + await server.waitUntilCompleted() + } catch { log.error("Home connection failed: \(error.localizedDescription)") } + await server.stop() + sessions.removeValue(forKey: id) + connection.cancel() + } +} + +struct HomeKitService: Service { + let backend: any HomeBackend + var tools: [Tool] { return HomeTools.tools(backend: backend) } +} diff --git a/Helper/Services/HomeKitBackend.swift b/Helper/Services/HomeKitBackend.swift new file mode 100644 index 00000000..1fc165d3 --- /dev/null +++ b/Helper/Services/HomeKitBackend.swift @@ -0,0 +1,643 @@ +import Foundation +import HomeKit + +@MainActor +final class HomeKitBackend: HomeBackend { + let store = HomeKitStore() + + func call(_ tool: String, _ input: [String: Value]) async throws -> Value { + do { + try await store.ensureLoaded() + return try await dispatch(tool, HomeArguments(input)) + } catch { + throw HomeError(homeErrorMessage(error)) + } + } + + private func dispatch(_ tool: String, _ args: HomeArguments) async throws -> Value { + switch tool { + case "homes_list": + return .object([ + "authorizationStatus": .int(Int(store.manager?.authorizationStatus.rawValue ?? 0)), + "homes": try Value(store.homes.map(homeSummary)), + ]) + case "home_export": + let home = try store.home(args.string("home")) + let include = try args.bool("include_values") ?? false + var accessories: [AccessoryDetail] = [] + for accessory in home.accessories { + accessories.append(await accessoryDetail(accessory, includeValues: include)) + } + return .object([ + "home": try Value(homeSummary(home)), + "rooms": try Value(store.rooms(home).map { roomSummary($0, home: home) }), + "zones": try Value(home.zones.map(zoneSummary)), + "accessories": try Value(accessories), + "scenes": try Value(allScenes(home).map(sceneSummary)), + "automations": try Value(home.triggers.map(automationSummary)), + ]) + case "rooms_list": + let home = try store.home(args.string("home")) + return try Value(store.rooms(home).map { roomSummary($0, home: home) }) + case "zones_list": return try Value(store.home(args.string("home")).zones.map(zoneSummary)) + case "accessories_list": return try listAccessories(args) + case "accessories_get": + return try await Value( + accessoryDetail( + store.accessory(args.required("accessory")), + includeValues: args.bool("include_values") ?? false + ) + ) + case "characteristics_read": return try Value(await readBatch(args.ids("characteristics", required: true))) + case "scenes_list": return try Value(allScenes(store.home(args.string("home"))).map(sceneSummary)) + case "automations_list": return try Value(store.home(args.string("home")).triggers.map(automationSummary)) + case "accessories_identify": + let accessory = try store.accessory(args.required("accessory")) + guard accessory.supportsIdentify else { throw HomeError("The accessory does not support identification.") } + try await accessory.identify() + return try Value(accessorySummary(accessory)) + case "accessories_rename": + let accessory = try store.accessory(args.required("accessory")) + try await accessory.updateName(args.name()) + return try Value(accessorySummary(accessory)) + case "services_rename": + let service = try store.service(args.required("service")) + try await service.updateName(args.name()) + return try Value(serviceSummary(service)) + case "accessories_assign_room": return try await assignRoom(args) + case "accessories_remove": + let accessory = try store.accessory(args.required("accessory")) + guard let home = store.home(containing: accessory) else { throw HomeError("The accessory has no home.") } + try await home.removeAccessory(accessory) + return removed(accessory.uniqueIdentifier) + case "rooms_create": + let home = try store.home(args.string("home")) + return try await Value(roomSummary(home.addRoom(named: args.name()), home: home)) + case "rooms_rename": + let (home, room) = try store.room(args.required("room")) + try await room.updateName(args.name()) + return try Value(roomSummary(room, home: home)) + case "rooms_remove": + let (home, room) = try store.room(args.required("room")) + try await home.removeRoom(room) + return removed(room.uniqueIdentifier) + case "zones_create": return try await createZone(args) + case "zones_update": return try await updateZone(args) + case "zones_remove": + let (home, zone) = try store.zone(args.required("zone")) + try await home.removeZone(zone) + return removed(zone.uniqueIdentifier) + case "characteristics_write": + let characteristic = try store.characteristic(args.required("characteristic")) + let value = try args.value("value") + try await characteristic.writeValue(HomeValue.decode(value, for: characteristic)) + return .object(["id": .string(characteristic.uniqueIdentifier.uuidString), "value": value]) + case "scenes_create": return try await createScene(args) + case "scenes_update": return try await updateScene(args) + case "scenes_execute": + let (home, scene) = try store.actionSet(args.required("scene")) + try await home.executeActionSet(scene) + return try Value(sceneSummary(scene)) + case "scenes_remove": + let (home, scene) = try store.actionSet(args.required("scene")) + try await home.removeActionSet(scene) + return removed(scene.uniqueIdentifier) + case "automations_update": + let (_, trigger) = try store.trigger(args.required("automation")) + let name = try args.optionalName() + let enabled = try args.bool("enabled") + if let name { try await trigger.updateName(name) } + if let enabled { try await trigger.enable(enabled) } + return try Value(automationSummary(trigger)) + case "automations_remove": + let (home, trigger) = try store.trigger(args.required("automation")) + try await home.removeTrigger(trigger) + return removed(trigger.uniqueIdentifier) + case "automations_create": return try await createAutomation(args) + default: throw HomeError("Unknown Home tool: \(tool)") + } + } + + private func listAccessories(_ args: HomeArguments) throws -> Value { + let home = try store.home(args.string("home")) + let room = try args.string("room") + if let room { try requireSameHome(store.room(room).0, home) } + let category = try args.string("category") + let reachable = try args.bool("reachable") + let bridged = try args.bool("bridged") + let inDefault = try args.bool("in_default_room") + let name = try args.string("name_contains") + let accessories = home.accessories.filter { accessory in + (room == nil || accessory.room?.uniqueIdentifier == UUID(uuidString: room!)) + && (category == nil || accessory.category.categoryType == category + || accessory.category.localizedDescription.localizedCaseInsensitiveCompare(category!) + == .orderedSame) + && (reachable == nil || accessory.isReachable == reachable) + && (bridged == nil || accessory.isBridged == bridged) + && (inDefault == nil + || (accessory.room?.uniqueIdentifier == home.roomForEntireHome().uniqueIdentifier) == inDefault) + && (name == nil || accessory.name.localizedCaseInsensitiveContains(name!)) + } + return try Value(accessories.map(accessorySummary)) + } + + private func assignRoom(_ args: HomeArguments) async throws -> Value { + let (home, room) = try store.room(args.required("room")) + var results: [BatchResult] = [] + for id in try args.ids("accessories", required: true) { + do { + let accessory = try store.accessory(id) + try requireSameHome(store.home(containing: accessory), home) + if accessory.room?.uniqueIdentifier != room.uniqueIdentifier { + try await home.assignAccessory(accessory, to: room) + } + results.append(BatchResult(id: id, ok: true)) + } catch { results.append(BatchResult(id: id, ok: false, error: homeErrorMessage(error))) } + } + return try Value(results) + } + + private func createZone(_ args: HomeArguments) async throws -> Value { + let home = try store.home(args.string("home")) + let rooms = try args.ids("rooms").map { id -> HMRoom in + let (owner, room) = try store.room(id) + try requireSameHome(owner, home) + return room + } + let zone = try await home.addZone(named: args.name()) + do { for room in rooms { try await zone.addRoom(room) } } catch { + let original = error + do { try await home.removeZone(zone) } catch { + throw HomeError( + "Zone setup failed: \(homeErrorMessage(original)). Cleanup failed for zone \(zone.uniqueIdentifier): \(homeErrorMessage(error))" + ) + } + throw original + } + return try Value(zoneSummary(zone)) + } + + private func updateZone(_ args: HomeArguments) async throws -> Value { + let (home, zone) = try store.zone(args.required("zone")) + let name = try args.optionalName() + func rooms(_ key: String) throws -> [HMRoom] { + try args.ids(key).map { id in + let (owner, room) = try store.room(id) + try requireSameHome(owner, home) + return room + } + } + let add = try rooms("add_rooms") + let remove = try rooms("remove_rooms") + if let name { try await zone.updateName(name) } + for room in remove where zone.rooms.contains(where: { $0.uniqueIdentifier == room.uniqueIdentifier }) { + try await zone.removeRoom(room) + } + for room in add where !zone.rooms.contains(where: { $0.uniqueIdentifier == room.uniqueIdentifier }) { + try await zone.addRoom(room) + } + return try Value(zoneSummary(zone)) + } + + private func preparedActions(_ input: [Value], home: HMHome) throws -> [(HMCharacteristic, NSCopying)] { + var seen: Set = [] + return try input.map { value in + guard case .object(let object) = value else { throw HomeError("Each action must be an object.") } + let args = HomeArguments(object) + let characteristic = try store.characteristic(args.required("characteristic")) + try requireSameHome(store.home(containing: characteristic), home) + guard seen.insert(characteristic.uniqueIdentifier).inserted else { + throw HomeError("Actions must use distinct characteristic IDs.") + } + return (characteristic, try HomeValue.decode(args.value("value"), for: characteristic)) + } + } + + private func createScene(_ args: HomeArguments) async throws -> Value { + let home = try store.home(args.string("home")) + let actions = try preparedActions(args.array("actions", required: true), home: home) + let scene = try await home.addActionSet(named: args.name()) + do { + for (characteristic, value) in actions { + try await scene.addAction( + HMCharacteristicWriteAction(characteristic: characteristic, targetValue: value) + ) + } + } catch { + let original = error + do { try await home.removeActionSet(scene) } catch { + throw HomeError( + "Scene setup failed: \(homeErrorMessage(original)). Cleanup failed for scene \(scene.uniqueIdentifier): \(homeErrorMessage(error))" + ) + } + throw original + } + return try Value(sceneSummary(scene)) + } + + private func updateScene(_ args: HomeArguments) async throws -> Value { + let (home, scene) = try store.actionSet(args.required("scene")) + let name = try args.optionalName() + let actions = try preparedActions(args.array("add_actions"), home: home) + let remove = try Set( + args.ids("remove_characteristics").map { id -> UUID in + let characteristic = try store.characteristic(id) + try requireSameHome(store.home(containing: characteristic), home) + return characteristic.uniqueIdentifier + } + ) + if let name { try await scene.updateName(name) } + for action in scene.actions { + if let write = action as? HMCharacteristicWriteAction, + remove.contains(write.characteristic.uniqueIdentifier) + { + try await scene.removeAction(action) + } + } + for (characteristic, value) in actions { + if let existing = scene.actions.compactMap({ $0 as? HMCharacteristicWriteAction }).first(where: { + $0.characteristic.uniqueIdentifier == characteristic.uniqueIdentifier + }) { + try await existing.updateTargetValue(value) + } else { + try await scene.addAction( + HMCharacteristicWriteAction(characteristic: characteristic, targetValue: value) + ) + } + } + return try Value(sceneSummary(scene)) + } + + private func createAutomation(_ args: HomeArguments) async throws -> Value { + let home = try store.home(args.string("home")) + let name = try args.name() + let scenes = try args.ids("scenes", required: true).map { id in + let (owner, scene) = try store.actionSet(id) + try requireSameHome(owner, home) + return scene + } + guard !scenes.isEmpty else { throw HomeError("An automation requires at least one scene.") } + guard case .object(let object) = try args.value("trigger") else { + throw HomeError("trigger must be an object.") + } + let triggerArgs = HomeArguments(object) + let selectors = ["fire_at", "characteristic", "event"].filter { object[$0] != nil } + guard selectors.count == 1 else { + throw HomeError("Specify exactly one trigger kind: fire_at, characteristic, or event.") + } + let trigger: HMTrigger + if let fireAt = try triggerArgs.string("fire_at") { + guard + let date = ISO8601DateFormatter().date(from: fireAt) + ?? ISO8601DateFormatter.fractional.date(from: fireAt), date > Date() + else { + throw HomeError("fire_at must be a future ISO 8601 timestamp with a time zone.") + } + guard date.timeIntervalSince1970.truncatingRemainder(dividingBy: 60) == 0 else { + throw HomeError( + "fire_at must be on a whole-minute boundary (seconds and fractional seconds must be zero)." + ) + } + if let recurrence = object["recurrence"], case .array(let weekdays) = recurrence { + guard !weekdays.isEmpty else { throw HomeError("The weekday recurrence must not be empty.") } + let days = try weekdays.map { value -> DateComponents in + guard case .int(let day) = value, (1 ... 7).contains(day) else { + throw HomeError("Weekdays must be integers from 1 (Sunday) through 7 (Saturday).") + } + return DateComponents(weekday: day) + } + let components = Calendar.current.dateComponents([.hour, .minute, .second], from: date) + // A date condition prevents a weekly event from firing before fire_at. + trigger = HMEventTrigger( + name: name, + events: [HMCalendarEvent(fire: components)], + end: nil, + recurrences: days, + predicate: HMEventTrigger.predicateForEvaluatingTrigger( + occurringAfter: Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: date + ) + ) + ) + } else { + var recurrence: DateComponents? + if let value = object["recurrence"] { + guard value.stringValue == "daily" else { + throw HomeError("recurrence must be daily or an array of weekday numbers.") + } + recurrence = DateComponents(day: 1) + } + trigger = HMTimerTrigger(name: name, fireDate: date, recurrence: recurrence) + } + } else if let id = try triggerArgs.string("characteristic") { + let characteristic = try store.characteristic(id) + try requireSameHome(store.home(containing: characteristic), home) + let value = try HomeValue.decode(triggerArgs.value("value"), for: characteristic, writable: false) + trigger = HMEventTrigger( + name: name, + events: [HMCharacteristicEvent(characteristic: characteristic, triggerValue: value)], + predicate: nil + ) + } else { + let event = try triggerArgs.required("event") + guard event == "sunrise" || event == "sunset" else { throw HomeError("event must be sunrise or sunset.") } + var offset: DateComponents? + if let value = object["offset_minutes"] { + guard case .int(let minutes) = value else { throw HomeError("offset_minutes must be an integer.") } + offset = DateComponents(minute: minutes) + } + trigger = HMEventTrigger( + name: name, + events: [ + HMSignificantTimeEvent(significantEvent: event == "sunrise" ? .sunrise : .sunset, offset: offset) + ], + predicate: nil + ) + } + try await home.addTrigger(trigger) + do { + for scene in scenes { try await trigger.addActionSet(scene) } + try await trigger.enable(true) + } catch { + let original = error + do { try await home.removeTrigger(trigger) } catch { + throw HomeError( + "Automation setup failed: \(homeErrorMessage(original)). Cleanup failed for automation \(trigger.uniqueIdentifier): \(homeErrorMessage(error))" + ) + } + throw original + } + return try Value(automationSummary(trigger)) + } + + private func requireSameHome(_ owner: HMHome?, _ home: HMHome) throws { + guard owner?.uniqueIdentifier == home.uniqueIdentifier else { + throw HomeError("All objects in this operation must belong to the same home.") + } + } + private func removed(_ id: UUID) -> Value { .object(["id": .string(id.uuidString), "removed": .bool(true)]) } +} + +// MARK: - Summaries + +/// Builds the JSON result models for the read-only tools +/// and performs the throttled batch reads that fill in live values. +extension HomeKitBackend { + func homeSummary(_ home: HMHome) -> HomeSummary { + HomeSummary( + id: home.uniqueIdentifier.uuidString, + name: home.name, + isPrimary: home.isPrimary, + rooms: store.rooms(home).count, + zones: home.zones.count, + accessories: home.accessories.count, + scenes: allScenes(home).count, + automations: home.triggers.count + ) + } + func roomSummary(_ room: HMRoom, home: HMHome) -> RoomSummary { + RoomSummary( + id: room.uniqueIdentifier.uuidString, + name: room.name, + accessoryCount: room.accessories.count, + isDefaultRoom: room.uniqueIdentifier == home.roomForEntireHome().uniqueIdentifier + ) + } + func zoneSummary(_ zone: HMZone) -> ZoneSummary { + ZoneSummary( + id: zone.uniqueIdentifier.uuidString, + name: zone.name, + rooms: zone.rooms.map { $0.uniqueIdentifier.uuidString } + ) + } + func serviceSummary(_ service: HMService) -> ServiceSummary { + ServiceSummary( + id: service.uniqueIdentifier.uuidString, + name: service.name, + type: service.serviceType, + description: service.localizedDescription + ) + } + func accessorySummary(_ accessory: HMAccessory) -> AccessorySummary { + let home = store.home(containing: accessory) + let bridge = home?.accessories.first { + $0.uniqueIdentifiersForBridgedAccessories?.contains(accessory.uniqueIdentifier) == true + } + return AccessorySummary( + id: accessory.uniqueIdentifier.uuidString, + name: accessory.name, + home: home?.uniqueIdentifier.uuidString, + room: accessory.room?.uniqueIdentifier.uuidString, + category: accessory.category.categoryType, + categoryDescription: accessory.category.localizedDescription, + manufacturer: accessory.manufacturer, + model: accessory.model, + firmware: accessory.firmwareVersion, + isReachable: accessory.isReachable, + isBridged: accessory.isBridged, + bridgedBy: bridge?.uniqueIdentifier.uuidString, + services: accessory.services.map(serviceSummary) + ) + } + func accessoryDetail(_ accessory: HMAccessory, includeValues: Bool) async -> AccessoryDetail { + var values: [String: BatchResult] = [:] + if includeValues { + let ids = accessory.services.flatMap(\.characteristics).map { $0.uniqueIdentifier.uuidString } + for row in await readBatch(ids) { values[row.id] = row } + } + let services = accessory.services.map { service in + ServiceDetail( + service: serviceSummary(service), + characteristics: service.characteristics.map { characteristic in + let metadata = characteristic.metadata + let row = values[characteristic.uniqueIdentifier.uuidString] + return CharacteristicDetail( + id: characteristic.uniqueIdentifier.uuidString, + type: characteristic.characteristicType, + description: characteristic.localizedDescription, + properties: characteristic.properties, + metadata: [ + "format": HomeValue.encode(metadata?.format), "units": HomeValue.encode(metadata?.units), + "minimumValue": HomeValue.encode(metadata?.minimumValue), + "maximumValue": HomeValue.encode(metadata?.maximumValue), + "stepValue": HomeValue.encode(metadata?.stepValue), + "validValues": HomeValue.encode(metadata?.validValues), + "maxLength": HomeValue.encode(metadata?.maxLength), + "manufacturerDescription": HomeValue.encode(metadata?.manufacturerDescription), + ], + value: row?.value, + error: row?.error + ) + } + ) + } + return AccessoryDetail( + accessory: accessorySummary(accessory), + uniqueIdentifiersForBridgedAccessories: accessory.uniqueIdentifiersForBridgedAccessories?.map(\.uuidString) + ?? [], + services: services + ) + } + func readBatch(_ ids: [String]) async -> [BatchResult] { + var rows: [BatchResult] = [] + // Each chunk preserves request order while permitting four concurrent reads. + for start in stride(from: 0, to: ids.count, by: 4) { + let chunk = Array(ids[start ..< min(start + 4, ids.count)]) + let results = await withTaskGroup(of: (Int, BatchResult).self) { group in + for (index, id) in chunk.enumerated() { + group.addTask { @MainActor in + do { + let value = try await self.store.read(self.store.characteristic(id)) + return (index, BatchResult(id: id, ok: true, value: value)) + } catch { return (index, BatchResult(id: id, ok: false, error: homeErrorMessage(error))) } + } + } + var result: [(Int, BatchResult)] = [] + for await row in group { result.append(row) } + return result.sorted { $0.0 < $1.0 }.map(\.1) + } + rows += results + } + return rows + } + func allScenes(_ home: HMHome) -> [HMActionSet] { + var seen: Set = [] + return (home.actionSets + home.triggers.flatMap(\.actionSets)).filter { + seen.insert($0.uniqueIdentifier).inserted + } + } + func sceneSummary(_ scene: HMActionSet) -> SceneSummary { + SceneSummary( + id: scene.uniqueIdentifier.uuidString, + name: scene.name, + type: scene.actionSetType, + actions: scene.actions.map { action in + var object: [String: Value] = [ + "id": .string(action.uniqueIdentifier.uuidString), + "kind": .string(String(describing: type(of: action))), + ] + if let write = action as? HMCharacteristicWriteAction { + object["characteristic"] = .string(write.characteristic.uniqueIdentifier.uuidString) + object["service"] = HomeValue.encode(write.characteristic.service?.uniqueIdentifier.uuidString) + object["accessory"] = HomeValue.encode( + write.characteristic.service?.accessory?.uniqueIdentifier.uuidString + ) + object["accessoryName"] = HomeValue.encode(write.characteristic.service?.accessory?.name) + object["target"] = HomeValue.encode(write.targetValue) + } else { + object["supported"] = .bool(false) + } + return .object(object) + } + ) + } + func automationSummary(_ trigger: HMTrigger) -> AutomationSummary { + var details: [String: Value] = [:] + if let timer = trigger as? HMTimerTrigger { + details["fire_at"] = .string(timer.fireDate.ISO8601Format()) + details["recurrence"] = timer.recurrence.map(dateComponents) ?? .null + } + if let event = trigger as? HMEventTrigger { + details["events"] = .array(event.events.map(eventSummary)) + details["endEvents"] = .array(event.endEvents.map(eventSummary)) + details["predicate"] = HomeValue.encode(event.predicate?.predicateFormat) + details["recurrences"] = event.recurrences.map { .array($0.map(dateComponents)) } ?? .null + details["executeOnce"] = .bool(event.executeOnce) + } + return AutomationSummary( + id: trigger.uniqueIdentifier.uuidString, + name: trigger.name, + kind: trigger is HMTimerTrigger + ? "timer" : trigger is HMEventTrigger ? "event" : String(describing: type(of: trigger)), + isEnabled: trigger.isEnabled, + scenes: trigger.actionSets.map { $0.uniqueIdentifier.uuidString }, + details: details + ) + } + private func dateComponents(_ components: DateComponents) -> Value { + var result: [String: Value] = [:] + for (name, number) in [ + ("year", components.year), ("month", components.month), ("day", components.day), + ("hour", components.hour), ("minute", components.minute), ("second", components.second), + ("weekday", components.weekday), + ] { + if let number { result[name] = .int(number) } + } + if let zone = components.timeZone { result["timeZone"] = .string(zone.identifier) } + return .object(result) + } + private func eventSummary(_ event: HMEvent) -> Value { + var result: [String: Value] = [ + "id": .string(event.uniqueIdentifier.uuidString), "kind": .string(String(describing: type(of: event))), + ] + if let characteristic = event as? HMCharacteristicEvent { + result["characteristic"] = .string(characteristic.characteristic.uniqueIdentifier.uuidString) + result["value"] = HomeValue.encode(characteristic.triggerValue) + } else if let time = event as? HMSignificantTimeEvent { + result["event"] = .string(time.significantEvent.rawValue) + result["offset"] = time.offset.map(dateComponents) ?? .null + } else if let calendar = event as? HMCalendarEvent { + result["fireDateComponents"] = dateComponents(calendar.fireDateComponents) + } else { + result["supported"] = .bool(false) + result["description"] = .string(String(describing: event)) + } + return .object(result) + } +} + +private struct HomeArguments { + let input: [String: Value] + init(_ input: [String: Value]) { self.input = input } + func value(_ key: String) throws -> Value { + guard let value = input[key], !value.isNull else { throw HomeError("Missing argument: \(key)") } + return value + } + func string(_ key: String) throws -> String? { + guard let value = input[key] else { return nil } + guard case .string(let string) = value else { throw HomeError("\(key) must be a string.") } + return string + } + func required(_ key: String) throws -> String { + guard let value = try string(key), !value.isEmpty else { throw HomeError("\(key) is required.") } + return value + } + func optionalName() throws -> String? { + guard let value = try string("name") else { return nil } + guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw HomeError("name must not be empty.") + } + return value + } + func name() throws -> String { + guard let name = try optionalName() else { throw HomeError("name is required.") }; return name + } + func bool(_ key: String) throws -> Bool? { + guard let value = input[key] else { return nil } + guard case .bool(let bool) = value else { throw HomeError("\(key) must be a Boolean.") } + return bool + } + func array(_ key: String, required: Bool = false) throws -> [Value] { + guard let value = input[key] else { + if required { throw HomeError("\(key) is required.") } + return [] + } + guard case .array(let array) = value else { throw HomeError("\(key) must be an array.") } + return array + } + func ids(_ key: String, required: Bool = false) throws -> [String] { + try array(key, required: required).map { value in + guard case .string(let id) = value else { throw HomeError("\(key) must contain UUID strings.") } + return id + } + } +} + +extension ISO8601DateFormatter { + fileprivate static var fractional: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } +} diff --git a/Helper/Services/HomeKitStore.swift b/Helper/Services/HomeKitStore.swift new file mode 100644 index 00000000..b99487d9 --- /dev/null +++ b/Helper/Services/HomeKitStore.swift @@ -0,0 +1,171 @@ +import Foundation +import HomeKit + +@MainActor +final class HomeKitStore: NSObject, HMHomeManagerDelegate { + private(set) var manager: HMHomeManager? + private var loaded = false + private var waiters: [UUID: CheckedContinuation] = [:] + private var activeReads: Set = [] + private var reads: [UUID: CheckedContinuation] = [:] + + var homes: [HMHome] { manager?.homes ?? [] } + + func ensureLoaded(timeout: Duration = .seconds(10)) async throws { + if manager == nil { + manager = HMHomeManager() + manager?.delegate = self + } + try checkAuthorization() + if !loaded { + let id = UUID() + try await withCheckedThrowingContinuation { continuation in + waiters[id] = continuation + Task { [weak self] in + try? await Task.sleep(for: timeout) + self?.waiters.removeValue(forKey: id)?.resume( + throwing: HomeError( + "HomeKit did not load within 10 seconds. Grant HomeKit access, then retry." + ) + ) + } + } + } + try checkAuthorization() + guard manager?.authorizationStatus.contains(.authorized) == true else { + throw HomeError( + "HomeKit access is not authorized. Enable iMCP Helper in System Settings → Privacy & Security → HomeKit." + ) + } + } + + private func checkAuthorization() throws { + guard let status = manager?.authorizationStatus else { return } + if status.contains(.restricted) || (status.contains(.determined) && !status.contains(.authorized)) { + throw HomeError( + "HomeKit access is not authorized. Enable iMCP Helper in System Settings → Privacy & Security → HomeKit." + ) + } + } + + nonisolated func homeManagerDidUpdateHomes(_ manager: HMHomeManager) { + Task { @MainActor [weak self] in + guard let self else { return } + loaded = true + let pending = waiters + waiters.removeAll() + for continuation in pending.values { continuation.resume() } + } + } + + func home(_ id: String?) throws -> HMHome { + if let id { return try find(homes, id, id: \.uniqueIdentifier, kind: "home") } + guard homes.count == 1, let home = homes.first else { + throw HomeError( + homes.isEmpty ? "No homes are available." : "Specify a home UUID when more than one home exists." + ) + } + return home + } + + func accessory(_ id: String) throws -> HMAccessory { + try find(homes.flatMap(\.accessories), id, id: \.uniqueIdentifier, kind: "accessory") + } + /// The Xcode 26 SDK does not declare `HMAccessory.home`, so resolve it by membership. + func home(containing accessory: HMAccessory) -> HMHome? { + homes.first { home in + home.accessories.contains { $0.uniqueIdentifier == accessory.uniqueIdentifier } + } + } + func home(containing characteristic: HMCharacteristic) -> HMHome? { + characteristic.service?.accessory.flatMap(home(containing:)) + } + func rooms(_ home: HMHome) -> [HMRoom] { + let all = home.roomForEntireHome() + return home.rooms.contains(where: { $0.uniqueIdentifier == all.uniqueIdentifier }) + ? home.rooms : home.rooms + [all] + } + func room(_ id: String) throws -> (HMHome, HMRoom) { + for home in homes { + if let room = rooms(home).first(where: { $0.uniqueIdentifier == UUID(uuidString: id) }) { + return (home, room) + } + } + throw HomeError("Room not found: \(id)") + } + func zone(_ id: String) throws -> (HMHome, HMZone) { + for home in homes { + if let zone = home.zones.first(where: { $0.uniqueIdentifier == UUID(uuidString: id) }) { + return (home, zone) + } + } + throw HomeError("Zone not found: \(id)") + } + func service(_ id: String) throws -> HMService { + try find(homes.flatMap(\.accessories).flatMap(\.services), id, id: \.uniqueIdentifier, kind: "service") + } + func characteristic(_ id: String) throws -> HMCharacteristic { + try find( + homes.flatMap(\.accessories).flatMap(\.services).flatMap(\.characteristics), + id, + id: \.uniqueIdentifier, + kind: "characteristic" + ) + } + func actionSet(_ id: String) throws -> (HMHome, HMActionSet) { + for home in homes { + // Home app automations can own action sets absent from home.actionSets. + let scenes = home.actionSets + home.triggers.flatMap(\.actionSets) + if let scene = scenes.first(where: { $0.uniqueIdentifier == UUID(uuidString: id) }) { return (home, scene) } + } + throw HomeError("Scene not found: \(id)") + } + func trigger(_ id: String) throws -> (HMHome, HMTrigger) { + for home in homes { + if let trigger = home.triggers.first(where: { $0.uniqueIdentifier == UUID(uuidString: id) }) { + return (home, trigger) + } + } + throw HomeError("Automation not found: \(id)") + } + private func find(_ values: [T], _ id: String, id key: KeyPath, kind: String) throws -> T { + guard let uuid = UUID(uuidString: id), let value = values.first(where: { $0[keyPath: key] == uuid }) else { + throw HomeError("\(kind.capitalized) not found: \(id)") + } + return value + } + + /// Limits outstanding HomeKit reads, including reads whose callers have timed out. + func read(_ characteristic: HMCharacteristic) async throws -> Value { + guard characteristic.properties.contains(HMCharacteristicPropertyReadable) else { + throw HomeError("The characteristic is not readable.") + } + guard activeReads.count < 4 else { + throw HomeError("Four HomeKit reads are still pending. Retry after the devices respond.") + } + let id = UUID() + activeReads.insert(id) + return try await withCheckedThrowingContinuation { continuation in + reads[id] = continuation + let timeout = Task { [weak self] in + do { try await Task.sleep(for: .seconds(5)) } catch { return } + self?.reads.removeValue(forKey: id)?.resume( + throwing: HomeError("The characteristic read timed out after 5 seconds.") + ) + } + characteristic.readValue { [weak self] error in + Task { @MainActor in + guard let self else { return } + timeout.cancel() + self.activeReads.remove(id) + let continuation = self.reads.removeValue(forKey: id) + if let error { + continuation?.resume(throwing: error) + } else { + continuation?.resume(returning: HomeValue.encode(characteristic.value)) + } + } + } + } + } +} diff --git a/README.md b/README.md index 51c8a449..58f68eb3 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,78 @@ a Swift package for working with structured data. It includes convenience initializers for types from Apple frameworks, such as those returned by iMCP tools. +### iMCP Helper + +Apple doesn't make the HomeKit framework available to native macOS apps, +only to Mac Catalyst apps. +So the Home service runs in a separate Catalyst app, +[`iMCP Helper`](/Helper/), +bundled at `iMCP.app/Contents/Helpers/iMCP Helper.app`. +When you enable Home, +iMCP launches the helper and the helper asks for HomeKit access. +The helper exits when iMCP exits, +and its status window reports startup and permission errors. +The Mac's iCloud account must have access to the home, +and writes can require the owner or administrator role. + +Both apps compile the same tool definitions from +[`Shared/Home/HomeTools.swift`](/Shared/Home/HomeTools.swift). +The helper implements them with HomeKit; +the app forwards each call over an MCP connection on the loopback interface, +so the usual service and per-tool switches apply. +Object IDs are HomeKit UUID strings. +Inventory tools return live characteristic values only when `include_values` is true, +with a five-second timeout and at most four outstanding HomeKit reads. +After a lost connection, read-only calls retry once. +Writes are not repeated, because HomeKit may have applied them already; +the error asks the caller to inspect the home before retrying. + +iMCP passes an ephemeral loopback port to the helper it launches. +A helper started some other way (for example, from Xcode) +advertises "\_imcp-helper.\_tcp" on the local-only DNS-SD interface instead. +If iMCP can't find such a helper, quit it and let iMCP launch its own. +To point `imcp-server` (and so the MCP Inspector) at a running helper directly, +set `IMCP_SERVICE_TYPE=_imcp-helper._tcp`. +`uv run Scripts/check-home.py ` runs a read-only +integration check against it. + +#### Building the helper + +Building the `iMCP` scheme runs +[`Scripts/build-helper.sh`](/Scripts/build-helper.sh), +which builds the `iMCP Helper` scheme for Mac Catalyst in its own +derived-data directory and copies the product into the app. +A direct target dependency selects the iOS variant of the helper, +so the nested build is required. + +The HomeKit entitlement requires a development certificate and a +provisioning profile with the HomeKit capability. +So the helper uses automatic signing with team `TTY35UM57S` +in both Debug and Release, even though the native Debug app is unsigned. +To build with another team, change it in the helper target's +Signing & Capabilities tab. +The first Mac Catalyst build for a new bundle ID can fail with +"Entitlement com.apple.developer.homekit not found and could not be included in profile", +because automatic provisioning registers the capability only for the iOS destination. +Build the helper once for iOS to register it, +then build for Mac Catalyst: + +```console +xcodebuild -scheme "iMCP Helper" -destination "generic/platform=iOS" \ + -allowProvisioningUpdates build +xcodebuild -scheme "iMCP Helper" -destination "platform=macOS,variant=Mac Catalyst" \ + -allowProvisioningUpdates build +``` + +CI builds both apps unsigned, +which checks compilation and packaging but can't access HomeKit: + +```console +xcodebuild -scheme iMCP -configuration Debug -destination "platform=macOS" \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \ + HELPER_CODE_SIGNING_ALLOWED=NO build +``` + ## Debugging ### Using the MCP Inspector diff --git a/Scripts/build-helper.sh b/Scripts/build-helper.sh new file mode 100644 index 00000000..f8e69600 --- /dev/null +++ b/Scripts/build-helper.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Xcode cannot select a Catalyst variant for a native macOS target dependency. +set -euo pipefail + +: "${SRCROOT:?Run this script from the iMCP Xcode build phase.}" +: "${TARGET_BUILD_DIR:?Missing Xcode product directory.}" +: "${WRAPPER_NAME:?Missing Xcode app wrapper name.}" +: "${CONFIGURATION:?Missing Xcode configuration.}" +: "${PROJECT_TEMP_DIR:?Missing Xcode intermediates directory.}" + +helper_derived_data="$PROJECT_TEMP_DIR/HelperDerivedData" +helper_signing="${HELPER_CODE_SIGNING_ALLOWED:-YES}" + +# Keep the nested build's environment, products, and build database separate. +# Inherited Xcode settings otherwise rename package products after the native app. +env -i PATH="$PATH" HOME="$HOME" USER="$USER" LOGNAME="$LOGNAME" \ + TMPDIR="${TMPDIR:-/tmp}" DEVELOPER_DIR="${DEVELOPER_DIR:-$(xcode-select -p)}" \ + xcodebuild -quiet -project "$SRCROOT/iMCP.xcodeproj" \ + -scheme "iMCP Helper" -configuration "$CONFIGURATION" \ + -destination 'platform=macOS,variant=Mac Catalyst' \ + -derivedDataPath "$helper_derived_data" \ + CODE_SIGNING_ALLOWED="$helper_signing" CODE_SIGNING_REQUIRED="$helper_signing" \ + build + +helper_product="$helper_derived_data/Build/Products/$CONFIGURATION-maccatalyst/iMCP Helper.app" +helper_destination="$TARGET_BUILD_DIR/$WRAPPER_NAME/Contents/Helpers/iMCP Helper.app" +if [[ ! -d "$helper_product/Contents/MacOS" ]]; then + echo 'error: The iMCP Helper build did not produce a Catalyst app.' >&2 + exit 1 +fi +mkdir -p "$(dirname "$helper_destination")" +# ditto preserves the helper's development signature when the native Debug app is unsigned. +# Remove stale files if the nested app's contents changed between builds. +rm -rf "$helper_destination" +ditto "$helper_product" "$helper_destination" diff --git a/Scripts/check-home.py b/Scripts/check-home.py new file mode 100644 index 00000000..31763ff6 --- /dev/null +++ b/Scripts/check-home.py @@ -0,0 +1,164 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Check the running iMCP Helper through imcp-server without changing the home.""" + +import argparse +import json +import os +import select +import subprocess +import time +from pathlib import Path + + +class HomeClient: + def __init__(self, server: Path, service_type: str = "_imcp-helper._tcp"): + self.process = subprocess.Popen( + [str(server.resolve())], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env={**os.environ, "IMCP_SERVICE_TYPE": service_type}, + ) + self.sequence = 0 + self.buffer = b"" + try: + self.request( + "initialize", + { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "iMCP Helper checks", "version": "1.0"}, + }, + ) + self.send({"jsonrpc": "2.0", "method": "notifications/initialized"}) + except BaseException: + self.close() + raise + + def send(self, message): + self.process.stdin.write(json.dumps(message).encode() + b"\n") + self.process.stdin.flush() + + def request(self, method, params): + self.sequence += 1 + self.send( + {"jsonrpc": "2.0", "id": self.sequence, "method": method, "params": params} + ) + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + if b"\n" not in self.buffer: + ready, _, _ = select.select( + [self.process.stdout], [], [], max(0, deadline - time.monotonic()) + ) + if not ready: + raise TimeoutError(f"Timed out waiting for {method}") + chunk = os.read(self.process.stdout.fileno(), 65536) + if not chunk: + raise RuntimeError("imcp-server closed its output") + self.buffer += chunk + continue + line, self.buffer = self.buffer.split(b"\n", 1) + response = json.loads(line) + if response.get("id") != self.sequence: + continue + if "error" in response: + raise RuntimeError(response["error"]) + return response["result"] + raise TimeoutError(f"Timed out waiting for {method}") + + def call(self, name, arguments=None, *, error=False): + result = self.request( + "tools/call", {"name": name, "arguments": arguments or {}} + ) + assert bool(result.get("isError")) == error, result + text = result["content"][0]["text"] + return text if error else json.loads(text) + + def close(self): + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + + +def check(client): + tools = client.request("tools/list", {})["tools"] + assert len(tools) == 28 + assert len({tool["name"] for tool in tools}) == 28 + destructive = { + "accessories_remove", + "rooms_remove", + "zones_remove", + "scenes_remove", + "automations_remove", + } + for tool in tools: + annotations = tool["annotations"] + assert annotations["openWorldHint"] is False + assert annotations["destructiveHint"] == (tool["name"] in destructive) + assert tool["inputSchema"]["additionalProperties"] is False + homes = client.call("homes_list")["homes"] + assert homes, "The helper is authorized but no homes are available" + for home in homes: + args = {"home": home["id"]} + rooms = client.call("rooms_list", args) + assert sum(room["isDefaultRoom"] for room in rooms) == 1 + client.call("zones_list", args) + accessories = client.call("accessories_list", args) + assert len(accessories) == home["accessories"] + default = next(room["id"] for room in rooms if room["isDefaultRoom"]) + unassigned = client.call("accessories_list", {**args, "in_default_room": True}) + assert all(accessory["room"] == default for accessory in unassigned) + graph = client.call("home_export", args) + assert len(graph["accessories"]) == len(accessories) + client.call("scenes_list", args) + client.call("automations_list", args) + if accessories: + detail = client.call("accessories_get", {"accessory": accessories[0]["id"]}) + assert detail["accessory"]["id"] == accessories[0]["id"] + for service in detail["services"]: + for characteristic in service["characteristics"]: + assert "value" not in characteristic + print( + f"Checked {len(rooms)} rooms, {len(accessories)} accessories, " + f"and {len(unassigned)} accessories in the Default Room." + ) + result = client.call("characteristics_read", {"characteristics": ["invalid-id"]}) + assert len(result) == 1 and result[0]["ok"] is False and result[0]["error"] + client.call("accessories_get", {"accessory": "invalid-id"}, error=True) + client.call("home_export", {"include_values": "yes"}, error=True) + client.call( + "rooms_create", {"name": "Do not create", "unexpected": True}, error=True + ) + client.call("accessories_rename", {"accessory": "invalid-id"}, error=True) + client.call( + "automations_create", + {"name": "Do not create", "scenes": [], "trigger": {"event": "noon"}}, + error=True, + ) + print("Tool schemas, annotations, inventory, and invalid-input checks passed.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "server", type=Path, help="Path to the built imcp-server executable" + ) + parser.add_argument("--service-type", default="_imcp-helper._tcp") + args = parser.parse_args() + client = None + try: + client = HomeClient(args.server, args.service_type) + check(client) + finally: + if client is not None: + client.close() + + +if __name__ == "__main__": + main() diff --git a/App/Extensions/Bundle+Extensions.swift b/Shared/Extensions/Bundle+Extensions.swift similarity index 100% rename from App/Extensions/Bundle+Extensions.swift rename to Shared/Extensions/Bundle+Extensions.swift diff --git a/App/Extensions/Logger+Extensions.swift b/Shared/Extensions/Logger+Extensions.swift similarity index 100% rename from App/Extensions/Logger+Extensions.swift rename to Shared/Extensions/Logger+Extensions.swift diff --git a/Shared/Home/HomeBackend.swift b/Shared/Home/HomeBackend.swift new file mode 100644 index 00000000..3f825319 --- /dev/null +++ b/Shared/Home/HomeBackend.swift @@ -0,0 +1,3 @@ +protocol HomeBackend: Sendable { + func call(_ tool: String, _ input: [String: Value]) async throws -> Value +} diff --git a/Shared/Home/HomeError.swift b/Shared/Home/HomeError.swift new file mode 100644 index 00000000..e5ff0901 --- /dev/null +++ b/Shared/Home/HomeError.swift @@ -0,0 +1,8 @@ +import Foundation + +struct HomeError: LocalizedError, CustomStringConvertible { + let message: String + init(_ message: String) { self.message = message } + var errorDescription: String? { message } + var description: String { message } +} diff --git a/Shared/Home/HomeInput.swift b/Shared/Home/HomeInput.swift new file mode 100644 index 00000000..320011b9 --- /dev/null +++ b/Shared/Home/HomeInput.swift @@ -0,0 +1,74 @@ +import JSONSchema + +/// Validates the schema features used by Home tools before any HomeKit mutation. +enum HomeInput { + static func validate(_ input: [String: Value], schema: JSONSchema) throws { + try validate(.object(input), schema: Value(schema), path: "arguments") + } + + private static func validate(_ value: Value, schema: Value, path: String) throws { + guard case .object(let schema) = schema else { throw HomeError("Invalid Home tool schema.") } + if case .array(let choices) = schema["anyOf"] { + for choice in choices { + if (try? validate(value, schema: choice, path: path)) != nil { return } + } + throw HomeError("\(path) does not match any permitted input format.") + } + if case .array(let choices) = schema["enum"], !choices.contains(value) { + throw HomeError("\(path) is not one of the permitted values.") + } + switch schema["type"]?.stringValue { + case "object": + guard case .object(let object) = value else { throw HomeError("\(path) must be an object.") } + let properties = schema["properties"]?.objectValue ?? [:] + if case .array(let required) = schema["required"] { + for key in required.compactMap(\.stringValue) where object[key] == nil { + throw HomeError("Missing argument: \(path).\(key)") + } + } + for (key, member) in object { + if let property = properties[key] { + try validate(member, schema: property, path: "\(path).\(key)") + } else if schema["additionalProperties"] == .bool(false) { + throw HomeError("Unknown argument: \(path).\(key)") + } + } + case "array": + guard case .array(let array) = value else { throw HomeError("\(path) must be an array.") } + if let min = schema["minItems"]?.intValue, array.count < min { + throw HomeError("\(path) has too few items.") + } + if schema["uniqueItems"] == .bool(true), Set(array).count != array.count { + throw HomeError("\(path) must contain distinct items.") + } + if let item = schema["items"] { + for (index, member) in array.enumerated() { + try validate(member, schema: item, path: "\(path)[\(index)]") + } + } + case "string": + guard case .string = value else { throw HomeError("\(path) must be a string.") } + case "boolean": + guard case .bool = value else { throw HomeError("\(path) must be a Boolean.") } + case "number", "integer": + let number: Double + switch value { + case .int(let integer): number = Double(integer) + case .double(let double) where schema["type"]?.stringValue == "number": number = double + default: throw HomeError("\(path) must be a \(schema["type"]?.stringValue ?? "number").") + } + guard number.isFinite else { throw HomeError("\(path) must be finite.") } + if let min = numeric(schema["minimum"]), number < min { throw HomeError("\(path) is below its minimum.") } + if let max = numeric(schema["maximum"]), number > max { throw HomeError("\(path) exceeds its maximum.") } + default: throw HomeError("Unsupported Home tool schema type.") + } + } + + private static func numeric(_ value: Value?) -> Double? { + switch value { + case .int(let value): return Double(value) + case .double(let value): return value + default: return nil + } + } +} diff --git a/Shared/Home/HomeTools.swift b/Shared/Home/HomeTools.swift new file mode 100644 index 00000000..c7a1f803 --- /dev/null +++ b/Shared/Home/HomeTools.swift @@ -0,0 +1,251 @@ +import JSONSchema + +/// Tool definitions shared by the native proxy and the Catalyst helper. +enum HomeTools { + static let identifier = JSONSchema.string(description: "HomeKit uniqueIdentifier UUID") + static let identifiers = JSONSchema.array(items: identifier) + static let value = JSONSchema.anyOf([.boolean(), .number(), .string()]) + static let actions = JSONSchema.array( + items: .object( + properties: ["characteristic": identifier, "value": value], + required: ["characteristic", "value"], + additionalProperties: false + ) + ) + static let trigger = JSONSchema.anyOf([ + .object( + properties: [ + "fire_at": .string( + description: "Future ISO 8601 timestamp on a whole-minute boundary, with a time zone.", + format: .dateTime + ), + "recurrence": .anyOf([ + .string(enum: ["daily"]), + .array(items: .integer(minimum: 1, maximum: 7), minItems: 1, uniqueItems: true), + ]), + ], + required: ["fire_at"], + additionalProperties: false + ), + .object( + properties: ["characteristic": identifier, "value": value], + required: ["characteristic", "value"], + additionalProperties: false + ), + .object( + properties: [ + "event": .string(enum: ["sunrise", "sunset"]), + "offset_minutes": .integer(), + ], + required: ["event"], + additionalProperties: false + ), + ]) + + static func tools(backend: any HomeBackend) -> [Tool] { + let home: [String: JSONSchema] = [ + "home": .string(description: "Home UUID. Required when more than one home exists.") + ] + let includeValues = JSONSchema.boolean( + description: "Read live values. Defaults to false; unreachable devices return per-item errors.", + default: false + ) + func tool( + _ name: String, + _ description: String, + _ properties: [String: JSONSchema] = [:], + required: [String] = [], + read: Bool = false, + idempotent: Bool = false, + destructive: Bool = false + ) -> Tool { + Tool( + name: name, + description: description, + inputSchema: .object( + properties: .init(uniqueKeysWithValues: properties.sorted { $0.key < $1.key }), + required: required, + additionalProperties: false + ), + annotations: .init( + readOnlyHint: read, + destructiveHint: destructive, + idempotentHint: read || idempotent, + openWorldHint: false + ) + ) { input in + try await backend.call(name, input) + } + } + func withHome(_ properties: [String: JSONSchema]) -> [String: JSONSchema] { + home.merging(properties) { _, value in value } + } + return [ + tool("homes_list", "List homes, counts, primary home, and authorization status.", read: true), + tool( + "home_export", + "Export rooms, zones, accessories, services, characteristic metadata, scenes, and exposed automations.", + withHome(["include_values": includeValues]), + read: true + ), + tool("rooms_list", "List rooms and identify the Default Room.", home, read: true), + tool("zones_list", "List zones and their room IDs.", home, read: true), + tool( + "accessories_list", + "List accessories with room, bridge, reachability, and service information.", + withHome([ + "room": identifier, "category": .string(), "reachable": .boolean(), + "bridged": .boolean(), "in_default_room": .boolean(), "name_contains": .string(), + ]), + read: true + ), + tool( + "accessories_get", + "Inspect an accessory and its characteristics.", + ["accessory": identifier, "include_values": includeValues], + required: ["accessory"], + read: true + ), + tool( + "characteristics_read", + "Read live characteristic values with one result per UUID.", + ["characteristics": identifiers], + required: ["characteristics"], + read: true + ), + tool("scenes_list", "List scenes with resolved characteristic actions.", home, read: true), + tool( + "automations_list", + "List automation data exposed by HomeKit. Shortcuts and some Home app conditions may be incomplete. lastFireDate is unavailable.", + home, + read: true + ), + tool( + "accessories_identify", + "Ask an accessory to identify itself with a light or sound.", + ["accessory": identifier], + required: ["accessory"], + idempotent: true + ), + tool( + "accessories_rename", + "Rename an accessory.", + ["accessory": identifier, "name": .string()], + required: ["accessory", "name"], + idempotent: true + ), + tool( + "services_rename", + "Rename a service within an accessory.", + ["service": identifier, "name": .string()], + required: ["service", "name"], + idempotent: true + ), + tool( + "accessories_assign_room", + "Move accessories to a room. Return one result per accessory.", + ["room": identifier, "accessories": identifiers], + required: ["room", "accessories"], + idempotent: true + ), + tool( + "accessories_remove", + "Remove an accessory from its home. Pairing is required to add it again.", + ["accessory": identifier], + required: ["accessory"], + destructive: true + ), + tool("rooms_create", "Create a room.", withHome(["name": .string()]), required: ["name"]), + tool( + "rooms_rename", + "Rename a room.", + ["room": identifier, "name": .string()], + required: ["room", "name"], + idempotent: true + ), + tool( + "rooms_remove", + "Remove a room. Its accessories return to the Default Room.", + ["room": identifier], + required: ["room"], + destructive: true + ), + tool( + "zones_create", + "Create a zone with optional rooms.", + withHome(["name": .string(), "rooms": identifiers]), + required: ["name"] + ), + tool( + "zones_update", + "Rename a zone or change its rooms.", + ["zone": identifier, "name": .string(), "add_rooms": identifiers, "remove_rooms": identifiers], + required: ["zone"], + idempotent: true + ), + tool( + "zones_remove", + "Remove a zone without removing its rooms.", + ["zone": identifier], + required: ["zone"], + destructive: true + ), + tool( + "characteristics_write", + "Write a characteristic value after checking its format and limits. Data values use base64 strings.", + ["characteristic": identifier, "value": value], + required: ["characteristic", "value"], + idempotent: true + ), + tool( + "scenes_create", + "Create a scene with characteristic write actions.", + withHome(["name": .string(), "actions": actions]), + required: ["name", "actions"] + ), + tool( + "scenes_update", + "Rename a scene, set characteristic actions, or remove actions.", + [ + "scene": identifier, "name": .string(), "add_actions": actions, + "remove_characteristics": identifiers, + ], + required: ["scene"], + idempotent: true + ), + tool( + "scenes_execute", + "Execute a scene and change its devices.", + ["scene": identifier], + required: ["scene"] + ), + tool( + "scenes_remove", + "Remove a scene from the home.", + ["scene": identifier], + required: ["scene"], + destructive: true + ), + tool( + "automations_update", + "Rename an automation or change whether it is enabled.", + ["automation": identifier, "name": .string(), "enabled": .boolean()], + required: ["automation"], + idempotent: true + ), + tool( + "automations_remove", + "Remove an automation from the home.", + ["automation": identifier], + required: ["automation"], + destructive: true + ), + tool( + "automations_create", + "Create an enabled automation for a timer, characteristic value, sunrise, or sunset. Timer recurrence is daily or weekday numbers (1 Sunday through 7 Saturday).", + withHome(["name": .string(), "scenes": identifiers, "trigger": trigger]), + required: ["name", "scenes", "trigger"] + ), + ] + } +} diff --git a/App/Models/Service.swift b/Shared/Models/Service.swift similarity index 100% rename from App/Models/Service.swift rename to Shared/Models/Service.swift diff --git a/App/Models/Tool.swift b/Shared/Models/Tool.swift similarity index 100% rename from App/Models/Tool.swift rename to Shared/Models/Tool.swift diff --git a/App/Models/Value.swift b/Shared/Models/Value.swift similarity index 100% rename from App/Models/Value.swift rename to Shared/Models/Value.swift diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index afcf264d..b25baf2d 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -6,6 +6,12 @@ objectVersion = 77; objects = { +/* Begin PBXTargetDependency section */ +/* End PBXTargetDependency section */ + +/* Begin PBXContainerItemProxy section */ +/* End PBXContainerItemProxy section */ + /* Begin PBXBuildFile section */ 76F80A6E3EDBFB02048A6ED8 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0E66768348853754D32208C /* Cocoa.framework */; }; 826C0834A5B647F8A918E54D /* ServiceLifecycle in Frameworks */ = {isa = PBXBuildFile; productRef = DCE061F8A68D0CD546D919E2 /* ServiceLifecycle */; }; @@ -30,6 +36,26 @@ F8D8C48E2DCE0E6800369E5C /* JSONSchema in Frameworks */ = {isa = PBXBuildFile; productRef = F8D8C48D2DCE0E6800369E5C /* JSONSchema */; }; F8F1D2F82D6F9E0C00F6323D /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = F8F44E9C2D5903F70075D79C /* MCP */; }; F9E9902DC72D90F6C423EFE2 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 48ABEE9E6903427DD79096A1 /* Logging */; }; + A20000000000000000000020 = {isa = PBXBuildFile; productRef = A20000000000000000000010; }; + A20000000000000000000021 = {isa = PBXBuildFile; productRef = A20000000000000000000011; }; + A20000000000000000000022 = {isa = PBXBuildFile; productRef = A20000000000000000000012; }; + A20000000000000000000023 = {isa = PBXBuildFile; productRef = A20000000000000000000013; }; + A20000000000000000000030 = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF000000000001; }; + A30000000000000000000020 = {isa = PBXBuildFile; fileRef = A30000000000000000000010; }; + A30000000000000000000021 = {isa = PBXBuildFile; fileRef = A30000000000000000000011; }; + A30000000000000000000022 = {isa = PBXBuildFile; fileRef = A30000000000000000000012; }; + A30000000000000000000023 = {isa = PBXBuildFile; fileRef = A30000000000000000000013; }; + A30000000000000000000024 = {isa = PBXBuildFile; fileRef = A30000000000000000000014; }; + A30000000000000000000025 = {isa = PBXBuildFile; fileRef = A30000000000000000000015; }; + A30000000000000000000026 = {isa = PBXBuildFile; fileRef = A30000000000000000000016; }; + A30000000000000000000030 = {isa = PBXBuildFile; productRef = A20000000000000000000010; }; + A30000000000000000000031 = {isa = PBXBuildFile; productRef = A20000000000000000000011; }; + A30000000000000000000032 = {isa = PBXBuildFile; productRef = A20000000000000000000012; }; + A40000000000000000000020 = {isa = PBXBuildFile; fileRef = A40000000000000000000010; }; + A40000000000000000000021 = {isa = PBXBuildFile; fileRef = A40000000000000000000011; }; + A40000000000000000000022 = {isa = PBXBuildFile; fileRef = A40000000000000000000012; }; + A40000000000000000000023 = {isa = PBXBuildFile; fileRef = A40000000000000000000013; }; + A40000000000000000000024 = {isa = PBXBuildFile; fileRef = A40000000000000000000014; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -65,6 +91,19 @@ E0E66768348853754D32208C /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; F8F44E6D2D59038D0075D79C /* iMCP.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iMCP.app; sourceTree = BUILT_PRODUCTS_DIR; }; F8F44EB62D5908D00075D79C /* imcp-server */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "imcp-server"; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000001 /* iMCP Helper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; path = "iMCP Helper.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + A30000000000000000000010 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeToolsTests.swift"; sourceTree = ""; }; + A30000000000000000000011 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../Shared/Home/HomeBackend.swift"; sourceTree = ""; }; + A30000000000000000000012 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../Shared/Home/HomeError.swift"; sourceTree = ""; }; + A30000000000000000000013 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../Shared/Home/HomeInput.swift"; sourceTree = ""; }; + A30000000000000000000014 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../Shared/Home/HomeTools.swift"; sourceTree = ""; }; + A30000000000000000000015 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../Shared/Models/Tool.swift"; sourceTree = ""; }; + A30000000000000000000016 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../Shared/Models/Value.swift"; sourceTree = ""; }; + A40000000000000000000010 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeProxyTests.swift"; sourceTree = ""; }; + A40000000000000000000011 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../App/Services/Home.swift"; sourceTree = ""; }; + A40000000000000000000012 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../Shared/Extensions/Logger+Extensions.swift"; sourceTree = ""; }; + A40000000000000000000013 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../Shared/Extensions/Bundle+Extensions.swift"; sourceTree = ""; }; + A40000000000000000000014 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "../Shared/Models/Service.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -75,6 +114,7 @@ ); target = F8F44E6C2D59038D0075D79C /* iMCP */; }; + A1000000000000000000000B = {isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = (Info.plist, Helper.entitlements, ); target = A10000000000000000000003; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -91,6 +131,8 @@ path = CLI; sourceTree = ""; }; + A10000000000000000000002 /* Helper */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A1000000000000000000000B, ); path = Helper; sourceTree = ""; }; + A20000000000000000000001 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; path = Shared; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -98,6 +140,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A30000000000000000000030, + A30000000000000000000031, + A30000000000000000000032, 76F80A6E3EDBFB02048A6ED8 /* Cocoa.framework in Frameworks */, 826C0834A5B647F8A918E54D /* ServiceLifecycle in Frameworks */, 8DFCE9E4CBCBEB93A32E8560 /* ServiceLifecycleTestKit in Frameworks */, @@ -132,6 +177,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000006 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (A20000000000000000000020, A20000000000000000000021, A20000000000000000000022, A20000000000000000000023, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -146,6 +192,18 @@ 9784E568F0621136136A0EAC /* CLITests */ = { isa = PBXGroup; children = ( + A40000000000000000000010, + A40000000000000000000011, + A40000000000000000000012, + A40000000000000000000013, + A40000000000000000000014, + A30000000000000000000010, + A30000000000000000000011, + A30000000000000000000012, + A30000000000000000000013, + A30000000000000000000014, + A30000000000000000000015, + A30000000000000000000016, 75E148EAC5AC8B3263C5AA13 /* ServiceGroupConfigurationTests.swift */, B0D1DEADBEEF000000000003 /* BonjourDiscoveryTests.swift */, B0D1DEADBEEF000000000001 /* BonjourDiscovery.swift */, @@ -167,6 +225,8 @@ isa = PBXGroup; children = ( F8F44E6F2D59038D0075D79C /* App */, + A10000000000000000000002 /* Helper */, + A20000000000000000000001 /* Shared */, F8F44EB72D5908D00075D79C /* CLI */, F8F44E6E2D59038D0075D79C /* Products */, 13058B0C8A894B6DB0A7D2A8 /* Frameworks */, @@ -178,6 +238,7 @@ isa = PBXGroup; children = ( F8F44E6D2D59038D0075D79C /* iMCP.app */, + A10000000000000000000001 /* iMCP Helper.app */, F8F44EB62D5908D00075D79C /* imcp-server */, 6A5CFBBEC66BC8CDA9D65496 /* imcp-serverTests.xctest */, ); @@ -201,6 +262,9 @@ ); name = "imcp-serverTests"; packageProductDependencies = ( + A20000000000000000000010, + A20000000000000000000011, + A20000000000000000000012, DCE061F8A68D0CD546D919E2 /* ServiceLifecycle */, 1CD6B9E157EEC273F178543D /* ServiceLifecycleTestKit */, 48ABEE9E6903427DD79096A1 /* Logging */, @@ -217,12 +281,14 @@ F8F44E6A2D59038D0075D79C /* Frameworks */, F8F44E6B2D59038D0075D79C /* Resources */, F84900952D63C74F0044AA81 /* Copy Executables */, + A20000000000000000000035 /* Build and Embed Home Helper */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( + A20000000000000000000001 /* Shared */, F8F44E6F2D59038D0075D79C /* App */, ); name = iMCP; @@ -267,6 +333,19 @@ productReference = F8F44EB62D5908D00075D79C /* imcp-server */; productType = "com.apple.product-type.tool"; }; + A10000000000000000000003 /* iMCP Helper */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000004; + buildPhases = (A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, ); + buildRules = (); + dependencies = (); + fileSystemSynchronizedGroups = (A10000000000000000000002, A20000000000000000000001, ); + name = "iMCP Helper"; + packageProductDependencies = (A20000000000000000000010, A20000000000000000000011, A20000000000000000000012, A20000000000000000000013, ); + productName = "iMCP Helper"; + productReference = A10000000000000000000001; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -277,6 +356,12 @@ LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 2620; TargetAttributes = { + A10000000000000000000003 = { + CreatedOnToolsVersion = 27.0; + DevelopmentTeam = TTY35UM57S; + ProvisioningStyle = Automatic; + SystemCapabilities = {com.apple.HomeKit = {enabled = 1; properties = {com.apple.developer.homekit = 1; }; }; com.apple.Sandbox = {enabled = 1; }; }; + }; F8F44E6C2D59038D0075D79C = { CreatedOnToolsVersion = 16.2; }; @@ -308,6 +393,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( + A10000000000000000000003 /* iMCP Helper */, F8F44E6C2D59038D0075D79C /* iMCP */, F8F44EB52D5908D00075D79C /* imcp-server */, 8F6A71ADC0CC6BD168A9167D /* imcp-serverTests */, @@ -330,13 +416,41 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000007 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + A20000000000000000000035 /* Build and Embed Home Helper */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = (); + inputPaths = (); + outputPaths = (); + name = "Build and Embed Home Helper"; + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/bash; + shellScript = "bash \"$SRCROOT/Scripts/build-helper.sh\""; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ B53F67B71CE8E2EB7B5CB823 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A40000000000000000000020, + A40000000000000000000021, + A40000000000000000000022, + A40000000000000000000023, + A40000000000000000000024, + A30000000000000000000020, + A30000000000000000000021, + A30000000000000000000022, + A30000000000000000000023, + A30000000000000000000024, + A30000000000000000000025, + A30000000000000000000026, 930214FCD680D7D74158F215 /* ServiceGroupConfigurationTests.swift in Sources */, B0D1DEADBEEF000000000004 /* BonjourDiscoveryTests.swift in Sources */, B0D1DEADBEEF000000000002 /* BonjourDiscovery.swift in Sources */, @@ -349,6 +463,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A20000000000000000000030, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -359,6 +474,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000005 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -519,6 +635,8 @@ F8F44E932D59038E0075D79C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + HELPER_CODE_SIGNING_ALLOWED = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; AUTOMATION_APPLE_EVENTS = YES; @@ -586,6 +704,8 @@ F8F44E942D59038E0075D79C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + HELPER_CODE_SIGNING_ALLOWED = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; AUTOMATION_APPLE_EVENTS = YES; @@ -686,6 +806,76 @@ }; name = Release; }; + A10000000000000000000008 = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Helper/Helper.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + DEVELOPMENT_TEAM = TTY35UM57S; + DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO; + ENABLE_APP_SANDBOX = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Helper/Info.plist; + INFOPLIST_KEY_NSHomeKitUsageDescription = "iMCP uses HomeKit to inspect and organize your homes, accessories, scenes, and automations."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "iMCP connects to iMCP Helper on this Mac."; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../Frameworks"; + MARKETING_VERSION = 1.0; + CURRENT_PROJECT_VERSION = 1; + PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP.Helper; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_VERSION = 5.0; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A10000000000000000000009 = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Helper/Helper.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CODE_SIGNING_ALLOWED = YES; + CODE_SIGNING_REQUIRED = YES; + DEVELOPMENT_TEAM = TTY35UM57S; + DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO; + ENABLE_APP_SANDBOX = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Helper/Info.plist; + INFOPLIST_KEY_NSHomeKitUsageDescription = "iMCP uses HomeKit to inspect and organize your homes, accessories, scenes, and automations."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "iMCP connects to iMCP Helper on this Mac."; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../Frameworks"; + MARKETING_VERSION = 1.0; + CURRENT_PROJECT_VERSION = 1; + PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP.Helper; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_VERSION = 5.0; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -725,6 +915,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A10000000000000000000004 = {isa = XCConfigurationList; buildConfigurations = (A10000000000000000000008, A10000000000000000000009, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -863,6 +1054,10 @@ package = 26120478122FBFCB2B073DE5 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + A20000000000000000000010 = {isa = XCSwiftPackageProductDependency; package = F8D7C3182DCBD32100A4775F; productName = MCP; }; + A20000000000000000000011 = {isa = XCSwiftPackageProductDependency; package = F8D8C48C2DCE0E6800369E5C; productName = JSONSchema; }; + A20000000000000000000012 = {isa = XCSwiftPackageProductDependency; package = F873F48B2D712BCF0035CD0A; productName = Ontology; }; + A20000000000000000000013 = {isa = XCSwiftPackageProductDependency; package = F88358352D64A085000317CD; productName = Logging; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F8F44E652D59038D0075D79C /* Project object */; diff --git a/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme b/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme new file mode 100644 index 00000000..ffa048cf --- /dev/null +++ b/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +