From 7fecfed83ae50aae6b0ad8b5eead1c5b56ba6c3e Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Wed, 16 Sep 2026 04:49:38 -0700 Subject: [PATCH 1/9] Allow the CLI to select a Bonjour service type --- CLI/MCPService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 4d11f32acc2f831461092535a50c649e05ef2449 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Wed, 16 Sep 2026 04:49:45 -0700 Subject: [PATCH 2/9] Add HomeKit tools through an embedded Catalyst helper --- .github/workflows/ci.yml | 2 +- App/Controllers/ServerController.swift | 11 + App/Info.plist | 1 + App/Services/Home.swift | 7 + App/Services/HomeProxyBackend.swift | 213 +++++++++ CLITests/HomeProxyTests.swift | 40 ++ CLITests/HomeToolsTests.swift | 98 ++++ Home/App.swift | 89 ++++ Home/Extensions/HomeKit+Extensions.swift | 99 ++++ Home/Home.entitlements | 14 + Home/Info.plist | 12 + Home/Models/HomeModels.swift | 95 ++++ Home/Server/HelperServer.swift | 143 ++++++ Home/Server/HomeAdvertisement.swift | 78 ++++ Home/Services/HomeKitBackend.swift | 435 ++++++++++++++++++ Home/Services/HomeKitInventory.swift | 205 +++++++++ Home/Services/HomeKitSpike.swift | 246 ++++++++++ Home/Services/HomeKitStore.swift | 162 +++++++ Scripts/build-home-helper.sh | 35 ++ .../Extensions/Bundle+Extensions.swift | 0 .../Extensions/Logger+Extensions.swift | 0 Shared/Home/HomeBackend.swift | 3 + Shared/Home/HomeError.swift | 8 + Shared/Home/HomeInput.swift | 74 +++ Shared/Home/HomeTools.swift | 251 ++++++++++ {App => Shared}/Models/Service.swift | 0 {App => Shared}/Models/Tool.swift | 0 {App => Shared}/Models/Value.swift | 0 iMCP.xcodeproj/project.pbxproj | 191 ++++++++ .../xcshareddata/xcschemes/iMCP Home.xcscheme | 78 ++++ 30 files changed, 2589 insertions(+), 1 deletion(-) create mode 100644 App/Services/Home.swift create mode 100644 App/Services/HomeProxyBackend.swift create mode 100644 CLITests/HomeProxyTests.swift create mode 100644 CLITests/HomeToolsTests.swift create mode 100644 Home/App.swift create mode 100644 Home/Extensions/HomeKit+Extensions.swift create mode 100644 Home/Home.entitlements create mode 100644 Home/Info.plist create mode 100644 Home/Models/HomeModels.swift create mode 100644 Home/Server/HelperServer.swift create mode 100644 Home/Server/HomeAdvertisement.swift create mode 100644 Home/Services/HomeKitBackend.swift create mode 100644 Home/Services/HomeKitInventory.swift create mode 100644 Home/Services/HomeKitSpike.swift create mode 100644 Home/Services/HomeKitStore.swift create mode 100644 Scripts/build-home-helper.sh rename {App => Shared}/Extensions/Bundle+Extensions.swift (100%) rename {App => Shared}/Extensions/Logger+Extensions.swift (100%) create mode 100644 Shared/Home/HomeBackend.swift create mode 100644 Shared/Home/HomeError.swift create mode 100644 Shared/Home/HomeInput.swift create mode 100644 Shared/Home/HomeTools.swift rename {App => Shared}/Models/Service.swift (100%) rename {App => Shared}/Models/Tool.swift (100%) rename {App => Shared}/Models/Value.swift (100%) create mode 100644 iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Home.xcscheme diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 540d5bba..b04d9a98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ 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 HOME_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 2385e523..e293f121 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..7b267b51 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -7,6 +7,7 @@ NSBonjourServices _mcp._tcp + _imcp-home._tcp SUEnableInstallerLauncherService diff --git a/App/Services/Home.swift b/App/Services/Home.swift new file mode 100644 index 00000000..09719e6b --- /dev/null +++ b/App/Services/Home.swift @@ -0,0 +1,7 @@ +final class HomeService: Service { + static let shared = HomeService() + private let backend = HomeProxyBackend() + var tools: [Tool] { return HomeTools.tools(backend: backend) } + var isActivated: Bool { get async { await backend.isActivated } } + func activate() async throws { try await backend.activate() } +} diff --git a/App/Services/HomeProxyBackend.swift b/App/Services/HomeProxyBackend.swift new file mode 100644 index 00000000..7d31af17 --- /dev/null +++ b/App/Services/HomeProxyBackend.swift @@ -0,0 +1,213 @@ +import AppKit +import Darwin +import MCP +import Network +import OSLog + +actor HomeProxyBackend: HomeBackend { + private let log = Logger.service("home") + private var client: MCP.Client? + private var connection: NWConnection? + private var connecting: Task? + private var authorized = false + private var launchedHelper: LaunchedHelper? + private let helperURL: URL? + + init(helperURL: URL? = nil) { self.helperURL = helperURL } + + private struct LaunchedHelper: Sendable { + let pid: pid_t + let endpoint: NWEndpoint + } + + 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 + } + + func call(_ tool: String, _ input: [String: Value]) async throws -> Value { + try await activate() + guard let active = client else { throw HomeError("The Home 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 HomeTools.tools(backend: self).first(where: { $0.name == tool })?.annotations.readOnlyHint == true + else { + throw HomeError( + "The Home helper connection was lost. The write may have completed. Inspect the home before retrying." + ) + } + guard let client else { throw HomeError("The Home helper could not reconnect.") } + return try await forward(client, tool, input) + } + } + + 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("The Home helper returned no JSON text.") + } + do { return try JSONDecoder().decode(Value.self, from: data) } catch { + throw HomeError("The Home 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-home._tcp", domain: "local."), using: .tcp) + do { + endpoint = try await BonjourDiscovery.discoverEndpoint( + using: browser, + timeout: .seconds(15), + preferring: { String(describing: $0.endpoint).contains("iMCP Home") } + ) + } catch { + throw HomeError( + "The Home helper was not found. Quit any manually started iMCP Home helper and enable Home again. \(error.localizedDescription)" + ) + } + } + let deadline = Date().addingTimeInterval(15) + repeat { + do { + try await connect(to: endpoint, parameters: parameters) + return + } catch let error as HomeError { throw error } catch { + guard launchedHelper != nil, Date() < deadline else { throw error } + 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 Home" else { + throw HomeError("The selected endpoint is not an iMCP Home helper.") + } + _ = try await forward(client, "homes_list", [:]) + self.client = client + self.connection = connection + authorized = true + log.info("Connected to the Home helper") + } catch { + connection.cancel() + await client.disconnect() + throw error + } + } + + @MainActor + private func launchHelper(previous: LaunchedHelper?, overrideURL: URL?) async throws -> LaunchedHelper? { + if let running = NSWorkspace.shared.runningApplications.first(where: { + $0.bundleIdentifier == "co.dododo.iMCP.Home" && !$0.isTerminated + }) { + return previous?.pid == running.processIdentifier ? previous : nil + } + let embedded = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/iMCP Home.app") + let url = + overrideURL + ?? (FileManager.default.fileExists(atPath: embedded.path) + ? embedded : NSWorkspace.shared.urlForApplication(withBundleIdentifier: "co.dododo.iMCP.Home")) + guard let url else { return nil } + let port = try availableLoopbackPort() + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + 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 Home helper socket.") } + 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 Home helper socket.") } + 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 Home helper port.") } + return UInt16(bigEndian: address.sin_port) + } +} diff --git a/CLITests/HomeProxyTests.swift b/CLITests/HomeProxyTests.swift new file mode 100644 index 00000000..56dfa6e3 --- /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_HOME_HELPER_PATH"] else { + throw XCTSkip("Set IMCP_HOME_HELPER_PATH to run the signed helper integration test.") + } + let backend = HomeProxyBackend(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.Home" + } + ) + 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/Home/App.swift b/Home/App.swift new file mode 100644 index 00000000..9af9fdfe --- /dev/null +++ b/Home/App.swift @@ -0,0 +1,89 @@ +import Network +import SwiftUI + +@main +struct HomeApp: App { + @StateObject private var runtime = HomeRuntime() + + var body: some Scene { + WindowGroup { + VStack(alignment: .leading, spacing: 16) { + Text("iMCP Home").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 HomeRuntime: 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() + if CommandLine.arguments.contains("--spike") || CommandLine.arguments.contains("--rename-accessory") { + let spike = HomeKitSpike() + await spike.run() + status = spike.status + return + } + 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 = "Home 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/Home/Extensions/HomeKit+Extensions.swift b/Home/Extensions/HomeKit+Extensions.swift new file mode 100644 index 00000000..fcc2305d --- /dev/null +++ b/Home/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 Home 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/Home/Home.entitlements b/Home/Home.entitlements new file mode 100644 index 00000000..1e4189a7 --- /dev/null +++ b/Home/Home.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/Home/Info.plist b/Home/Info.plist new file mode 100644 index 00000000..3a9993d4 --- /dev/null +++ b/Home/Info.plist @@ -0,0 +1,12 @@ + + + + + LSMultipleInstancesProhibited + + NSBonjourServices + + _imcp-home._tcp + + + diff --git a/Home/Models/HomeModels.swift b/Home/Models/HomeModels.swift new file mode 100644 index 00000000..0a131550 --- /dev/null +++ b/Home/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/Home/Server/HelperServer.swift b/Home/Server/HelperServer.swift new file mode 100644 index 00000000..3ef81c1d --- /dev/null +++ b/Home/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: HomeAdvertisement? + private var sessions: [UUID: MCP.Server] = [:] + private var startup: CheckedContinuation? + private let log = Logger.service("home.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("The Home helper has no TCP port.") } + let advertisement = await HomeAdvertisement() + 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 Home helper listener did not start within 10 seconds.")) + } + + private func accept(_ connection: NWConnection) async { + let id = UUID() + let server = MCP.Server( + name: "iMCP Home", + 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/Home/Server/HomeAdvertisement.swift b/Home/Server/HomeAdvertisement.swift new file mode 100644 index 00000000..586ca8de --- /dev/null +++ b/Home/Server/HomeAdvertisement.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 HomeAdvertisement { + 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-home._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 Home", + "_imcp-home._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( + "Home helper Bonjour registration failed (\(error)). Allow iMCP Home 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/Home/Services/HomeKitBackend.swift b/Home/Services/HomeKitBackend.swift new file mode 100644 index 00000000..f8b09051 --- /dev/null +++ b/Home/Services/HomeKitBackend.swift @@ -0,0 +1,435 @@ +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 = accessory.home 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(accessory.home, 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(characteristic.service?.accessory?.home, 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(characteristic.service?.accessory?.home, 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(characteristic.service?.accessory?.home, 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)]) } +} + +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/Home/Services/HomeKitInventory.swift b/Home/Services/HomeKitInventory.swift new file mode 100644 index 00000000..248976ee --- /dev/null +++ b/Home/Services/HomeKitInventory.swift @@ -0,0 +1,205 @@ +import Foundation +import HomeKit + +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 bridge = accessory.home?.accessories.first { + $0.uniqueIdentifiersForBridgedAccessories?.contains(accessory.uniqueIdentifier) == true + } + return AccessorySummary( + id: accessory.uniqueIdentifier.uuidString, + name: accessory.name, + home: accessory.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) + } +} diff --git a/Home/Services/HomeKitSpike.swift b/Home/Services/HomeKitSpike.swift new file mode 100644 index 00000000..bbebe762 --- /dev/null +++ b/Home/Services/HomeKitSpike.swift @@ -0,0 +1,246 @@ +import Foundation +import HomeKit + +/// Exercises HomeKit access before the helper is connected to iMCP. +@MainActor +final class HomeKitSpike: NSObject, ObservableObject, HMHomeManagerDelegate { + @Published private(set) var status = "Waiting for HomeKit access…" + private var manager: HMHomeManager? + private var loaded = false + private var started = false + private var waiter: CheckedContinuation? + private var loadTimeout: Task? + + func run() async { + guard !started else { return } + started = true + let start = Date() + do { + let manager = HMHomeManager() + self.manager = manager + manager.delegate = self + try await waitForHomes() + guard manager.authorizationStatus.contains(.authorized) else { + throw SpikeError.message( + "HomeKit access is denied. Check System Settings → Privacy & Security → HomeKit." + ) + } + let folder = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let destination = folder.appendingPathComponent("homekit-spike.json") + let report: [String: Any] = [ + "loadedInSeconds": Date().timeIntervalSince(start), + "authorizationStatus": manager.authorizationStatus.rawValue, + "homes": manager.homes.map(home), + ] + let data = try JSONSerialization.data( + withJSONObject: jsonValue(report), + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + try data.write(to: destination, options: .atomic) + print(String(decoding: data, as: UTF8.self)) + status = "Loaded \(manager.homes.count) homes. Export: \(destination.path)" + + // A specific UUID keeps repeated diagnostic launches from changing arbitrary devices. + if let index = CommandLine.arguments.firstIndex(of: "--rename-accessory") { + guard CommandLine.arguments.indices.contains(index + 1), + let id = UUID(uuidString: CommandLine.arguments[index + 1]), + let accessory = manager.homes.flatMap(\.accessories).first(where: { + $0.uniqueIdentifier == id + }) + else { throw SpikeError.message("Specify an existing accessory UUID after --rename-accessory.") } + let original = accessory.name + let recovery = folder.appendingPathComponent("rename-recovery.json") + guard !FileManager.default.fileExists(atPath: recovery.path) else { + throw SpikeError.message( + "A previous rename test needs review. Restore the name saved in \(recovery.path), then remove that file before another test." + ) + } + let recoveryData = try JSONSerialization.data( + withJSONObject: [ + "accessory": id.uuidString, "originalName": original, + ], + options: [.sortedKeys] + ) + // Preserve the original name even if the process exits during the write test. + try recoveryData.write(to: recovery, options: .atomic) + try await accessory.updateName(original + " Test") + do { + try await accessory.updateName(original) + try FileManager.default.removeItem(at: recovery) + status += "\nRename and restore succeeded for \(id.uuidString)." + } catch { + throw SpikeError.message( + "Restore failed for \(id.uuidString). Restore its name to '\(original)' in Home. \(error.localizedDescription)" + ) + } + } + } catch { + status = "HomeKit check failed: \(error.localizedDescription)" + } + print(status) + fflush(stdout) + } + + private func waitForHomes() async throws { + if loaded { return } + try await withCheckedThrowingContinuation { continuation in + waiter = continuation + loadTimeout = Task { [weak self] in + do { try await Task.sleep(for: .seconds(10)) } catch { return } + self?.finishLoading( + .failure( + SpikeError.message( + "HomeKit did not load within 10 seconds. Grant access, then restart the helper." + ) + ) + ) + } + } + } + + private func finishLoading(_ result: Result) { + loadTimeout?.cancel() + loadTimeout = nil + let continuation = waiter + waiter = nil + continuation?.resume(with: result) + } + + nonisolated func homeManagerDidUpdateHomes(_ manager: HMHomeManager) { + Task { @MainActor [weak self] in + self?.loaded = true + self?.finishLoading(.success(())) + } + } + + private func home(_ home: HMHome) -> [String: Any] { + let defaultRoom = home.roomForEntireHome() + var rooms = home.rooms + if !rooms.contains(where: { $0.uniqueIdentifier == defaultRoom.uniqueIdentifier }) { + rooms.append(defaultRoom) + } + return [ + "id": home.uniqueIdentifier.uuidString, + "name": home.name, + "isPrimary": home.isPrimary, + "rooms": rooms.map { room in + [ + "id": room.uniqueIdentifier.uuidString, "name": room.name, + "isDefaultRoom": room.uniqueIdentifier == defaultRoom.uniqueIdentifier, + "accessories": room.accessories.map { $0.uniqueIdentifier.uuidString }, + ] as [String: Any] + }, + "zones": home.zones.map { zone in + [ + "id": zone.uniqueIdentifier.uuidString, "name": zone.name, + "rooms": zone.rooms.map { $0.uniqueIdentifier.uuidString }, + ] as [String: Any] + }, + "accessories": home.accessories.map(accessory), + "scenes": home.actionSets.map { scene in + [ + "id": scene.uniqueIdentifier.uuidString, "name": scene.name, + "type": scene.actionSetType, + "actions": scene.actions.map { action -> [String: Any] in + var result: [String: Any] = [ + "id": action.uniqueIdentifier.uuidString, + "kind": String(describing: type(of: action)), + ] + if let write = action as? HMCharacteristicWriteAction { + result["characteristic"] = write.characteristic.uniqueIdentifier.uuidString + result["target"] = jsonValue(write.targetValue) + } + return result + }, + ] as [String: Any] + }, + "automations": home.triggers.map { trigger in + var result: [String: Any] = [ + "id": trigger.uniqueIdentifier.uuidString, "name": trigger.name, + "kind": String(describing: type(of: trigger)), "isEnabled": trigger.isEnabled, + "scenes": trigger.actionSets.map { $0.uniqueIdentifier.uuidString }, + // Apple deprecated lastFireDate in Mac Catalyst 17 without a replacement. + "lastFireDate": NSNull(), + ] + if let event = trigger as? HMEventTrigger { + result["events"] = event.events.map { String(describing: $0) } + result["endEvents"] = event.endEvents.map { String(describing: $0) } + result["predicate"] = event.predicate?.predicateFormat as Any? ?? NSNull() + result["recurrences"] = event.recurrences?.map { String(describing: $0) } + } + if let timer = trigger as? HMTimerTrigger { + result["fireDate"] = timer.fireDate.ISO8601Format() + result["recurrence"] = timer.recurrence.map { String(describing: $0) } + } + return result + }, + ] + } + + private func accessory(_ accessory: HMAccessory) -> [String: Any] { + [ + "id": accessory.uniqueIdentifier.uuidString, "name": accessory.name, + "room": accessory.room?.uniqueIdentifier.uuidString as Any? ?? NSNull(), + "category": accessory.category.categoryType, + "categoryDescription": accessory.category.localizedDescription, + "manufacturer": accessory.manufacturer as Any? ?? NSNull(), + "model": accessory.model as Any? ?? NSNull(), + "firmware": accessory.firmwareVersion as Any? ?? NSNull(), + "isReachable": accessory.isReachable, "isBridged": accessory.isBridged, + "uniqueIdentifiersForBridgedAccessories": accessory.uniqueIdentifiersForBridgedAccessories?.map( + \.uuidString + ) ?? [], + "services": accessory.services.map { service in + [ + "id": service.uniqueIdentifier.uuidString, "name": service.name, + "type": service.serviceType, "description": service.localizedDescription, + "characteristics": service.characteristics.map { characteristic in + let metadata = characteristic.metadata + return [ + "id": characteristic.uniqueIdentifier.uuidString, + "type": characteristic.characteristicType, + "description": characteristic.localizedDescription, + "properties": characteristic.properties, + "metadata": [ + "format": metadata?.format as Any? ?? NSNull(), + "units": metadata?.units as Any? ?? NSNull(), + "minimumValue": metadata?.minimumValue as Any? ?? NSNull(), + "maximumValue": metadata?.maximumValue as Any? ?? NSNull(), + "stepValue": metadata?.stepValue as Any? ?? NSNull(), + "validValues": metadata?.validValues as Any? ?? NSNull(), + "maxLength": metadata?.maxLength as Any? ?? NSNull(), + "manufacturerDescription": metadata?.manufacturerDescription as Any? ?? NSNull(), + ], + ] as [String: Any] + }, + ] as [String: Any] + }, + ] + } + + private func jsonValue(_ value: Any) -> Any { + if let data = value as? Data { return data.base64EncodedString() } + if let number = value as? NSNumber { + return number.doubleValue.isFinite ? number : NSNull() + } + if let object = value as? [String: Any] { return object.mapValues(jsonValue) } + if let array = value as? [Any] { return array.map(jsonValue) } + if value is NSNull || value is String { return value } + return String(describing: value) + } +} + +private enum SpikeError: LocalizedError { + case message(String) + + var errorDescription: String? { + switch self { + case .message(let message): return message + } + } +} diff --git a/Home/Services/HomeKitStore.swift b/Home/Services/HomeKitStore.swift new file mode 100644 index 00000000..a64bd2a6 --- /dev/null +++ b/Home/Services/HomeKitStore.swift @@ -0,0 +1,162 @@ +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 Home 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 Home 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") + } + 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/Scripts/build-home-helper.sh b/Scripts/build-home-helper.sh new file mode 100644 index 00000000..732e2dc7 --- /dev/null +++ b/Scripts/build-home-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/HomeHelperDerivedData" +helper_signing="${HOME_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 Home" -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 Home.app" +helper_destination="$TARGET_BUILD_DIR/$WRAPPER_NAME/Contents/Helpers/iMCP Home.app" +if [[ ! -d "$helper_product/Contents/MacOS" ]]; then + echo 'error: The Home 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/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 dbea19e2..d9d7d591 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,25 @@ 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; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -65,6 +90,18 @@ 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 Home.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; path = "iMCP Home.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/HomeProxyBackend.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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -75,6 +112,7 @@ ); target = F8F44E6C2D59038D0075D79C /* iMCP */; }; + A1000000000000000000000B = {isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = (Info.plist, Home.entitlements, ); target = A10000000000000000000003; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -91,6 +129,8 @@ path = CLI; sourceTree = ""; }; + A10000000000000000000002 /* Home */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A1000000000000000000000B, ); path = Home; sourceTree = ""; }; + A20000000000000000000001 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; path = Shared; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -98,6 +138,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A30000000000000000000030, + A30000000000000000000031, + A30000000000000000000032, 76F80A6E3EDBFB02048A6ED8 /* Cocoa.framework in Frameworks */, 826C0834A5B647F8A918E54D /* ServiceLifecycle in Frameworks */, 8DFCE9E4CBCBEB93A32E8560 /* ServiceLifecycleTestKit in Frameworks */, @@ -132,6 +175,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000006 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (A20000000000000000000020, A20000000000000000000021, A20000000000000000000022, A20000000000000000000023, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -146,6 +190,17 @@ 9784E568F0621136136A0EAC /* CLITests */ = { isa = PBXGroup; children = ( + A40000000000000000000010, + A40000000000000000000011, + A40000000000000000000012, + A40000000000000000000013, + A30000000000000000000010, + A30000000000000000000011, + A30000000000000000000012, + A30000000000000000000013, + A30000000000000000000014, + A30000000000000000000015, + A30000000000000000000016, 75E148EAC5AC8B3263C5AA13 /* ServiceGroupConfigurationTests.swift */, B0D1DEADBEEF000000000003 /* BonjourDiscoveryTests.swift */, B0D1DEADBEEF000000000001 /* BonjourDiscovery.swift */, @@ -167,6 +222,8 @@ isa = PBXGroup; children = ( F8F44E6F2D59038D0075D79C /* App */, + A10000000000000000000002 /* Home */, + A20000000000000000000001 /* Shared */, F8F44EB72D5908D00075D79C /* CLI */, F8F44E6E2D59038D0075D79C /* Products */, 13058B0C8A894B6DB0A7D2A8 /* Frameworks */, @@ -178,6 +235,7 @@ isa = PBXGroup; children = ( F8F44E6D2D59038D0075D79C /* iMCP.app */, + A10000000000000000000001 /* iMCP Home.app */, F8F44EB62D5908D00075D79C /* imcp-server */, 6A5CFBBEC66BC8CDA9D65496 /* imcp-serverTests.xctest */, ); @@ -201,6 +259,9 @@ ); name = "imcp-serverTests"; packageProductDependencies = ( + A20000000000000000000010, + A20000000000000000000011, + A20000000000000000000012, DCE061F8A68D0CD546D919E2 /* ServiceLifecycle */, 1CD6B9E157EEC273F178543D /* ServiceLifecycleTestKit */, 48ABEE9E6903427DD79096A1 /* Logging */, @@ -217,12 +278,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 +330,19 @@ productReference = F8F44EB62D5908D00075D79C /* imcp-server */; productType = "com.apple.product-type.tool"; }; + A10000000000000000000003 /* iMCP Home */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000000000000000000004; + buildPhases = (A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, ); + buildRules = (); + dependencies = (); + fileSystemSynchronizedGroups = (A10000000000000000000002, A20000000000000000000001, ); + name = "iMCP Home"; + packageProductDependencies = (A20000000000000000000010, A20000000000000000000011, A20000000000000000000012, A20000000000000000000013, ); + productName = "iMCP Home"; + productReference = A10000000000000000000001; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -277,6 +353,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 +390,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( + A10000000000000000000003 /* iMCP Home */, F8F44E6C2D59038D0075D79C /* iMCP */, F8F44EB52D5908D00075D79C /* imcp-server */, 8F6A71ADC0CC6BD168A9167D /* imcp-serverTests */, @@ -330,13 +413,40 @@ ); 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-home-helper.sh\""; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ B53F67B71CE8E2EB7B5CB823 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A40000000000000000000020, + A40000000000000000000021, + A40000000000000000000022, + A40000000000000000000023, + A30000000000000000000020, + A30000000000000000000021, + A30000000000000000000022, + A30000000000000000000023, + A30000000000000000000024, + A30000000000000000000025, + A30000000000000000000026, 930214FCD680D7D74158F215 /* ServiceGroupConfigurationTests.swift in Sources */, B0D1DEADBEEF000000000004 /* BonjourDiscoveryTests.swift in Sources */, B0D1DEADBEEF000000000002 /* BonjourDiscovery.swift in Sources */, @@ -349,6 +459,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A20000000000000000000030, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -359,6 +470,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A10000000000000000000005 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -519,6 +631,8 @@ F8F44E932D59038E0075D79C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + HOME_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 +700,8 @@ F8F44E942D59038E0075D79C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + HOME_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 +802,76 @@ }; name = Release; }; + A10000000000000000000008 = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Home/Home.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 = Home/Info.plist; + INFOPLIST_KEY_NSHomeKitUsageDescription = "iMCP uses HomeKit to inspect and organize your homes, accessories, scenes, and automations."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "iMCP connects to its Home 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.Home; + 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 = Home/Home.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 = Home/Info.plist; + INFOPLIST_KEY_NSHomeKitUsageDescription = "iMCP uses HomeKit to inspect and organize your homes, accessories, scenes, and automations."; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "iMCP connects to its Home 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.Home; + 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 +911,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A10000000000000000000004 = {isa = XCConfigurationList; buildConfigurations = (A10000000000000000000008, A10000000000000000000009, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ @@ -863,6 +1050,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 Home.xcscheme b/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Home.xcscheme new file mode 100644 index 00000000..3c070eec --- /dev/null +++ b/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Home.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From efd749779c65532691d527f1018d0acaad9cb31d Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Wed, 16 Sep 2026 04:49:50 -0700 Subject: [PATCH 3/9] Document HomeKit setup and add integration checks --- Docs/Home.md | 157 ++++++++++++++++++++++++++++++++++++++++ Scripts/check-home.py | 164 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 Docs/Home.md create mode 100644 Scripts/check-home.py diff --git a/Docs/Home.md b/Docs/Home.md new file mode 100644 index 00000000..d43c6e7a --- /dev/null +++ b/Docs/Home.md @@ -0,0 +1,157 @@ +# HomeKit tools + +The Home service uses a signed Mac Catalyst helper inside +`iMCP.app/Contents/Helpers/iMCP Home.app`. +The native app starts the helper when you enable Home. +The helper requests HomeKit access and exits when its parent app exits. +Its status window reports startup and permission errors. + +## Build + +Build the `iMCP` scheme for macOS. +The build phase runs the `iMCP Home` scheme for Mac Catalyst in a separate +build directory, then copies the helper into the app. +A direct target dependency selects the iOS variant under Xcode 27, +so the separate build is required. +The nested build uses a clean environment to avoid inheriting native app +product names and SDK settings. + +The helper uses automatic development signing with team `TTY35UM57S` +in both configurations. +The native Debug app can remain unsigned while its helper is signed. +The helper requires a development certificate and a HomeKit provisioning profile. +On another developer account, change the helper's team in Xcode. +If automatic provisioning initially cannot include HomeKit, +enable HomeKit in Signing & Capabilities for the iOS destination first. +An iOS build with `-allowProvisioningUpdates` also registered the capability +for this project's bundle ID during local setup. + +```sh +xcodebuild -scheme "iMCP Home" -configuration Debug \ + -destination 'platform=macOS,variant=Mac Catalyst' \ + -allowProvisioningUpdates build +``` + +CI builds both apps without signing: + +```sh +xcodebuild -quiet -scheme iMCP -configuration Debug \ + -destination 'platform=macOS' \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \ + HOME_HELPER_CODE_SIGNING_ALLOWED=NO build +``` + +Unsigned builds check compilation and packaging. +They cannot test HomeKit access. +Release distribution and notarization of the nested app remain separate work. +The release script has not changed. + +## Access and discovery + +Enable Home in iMCP, then allow HomeKit access when macOS asks. +The Mac's iCloud account must have access to the home. +Writes can require the home owner or administrator role. +If access is denied, check System Settings → Privacy & Security → HomeKit. +Bonjour can also require local network permission. + +The helper binds its TCP listener to `127.0.0.1`. +For automatic launch, the native app selects an ephemeral loopback port +and passes it to the helper with `--port`. +This connection does not depend on Bonjour. +A manually started helper publishes `_imcp-home._tcp` through the DNS-SD +local-only interface, with `localhost` as its host. +That service is visible only on this Mac. +`acceptLocalOnly` alone restricts connections to the local network link; +it does not restrict them to the host. +The explicit loopback binding provides that restriction. +See [Apple's acceptLocalOnly documentation](https://developer.apple.com/documentation/network/nwparameters/acceptlocalonly). + +The native service retries connections while the launched helper starts. +For a manually started helper, it uses Bonjour with a 15-second timeout. +Bonjour registration stalled during local testing on macOS 27. +If manual discovery fails, quit the helper and let iMCP launch it. +It checks `homes_list` before activation succeeds. +Settings can list all 28 tools while the helper is stopped because both targets +use the same definitions in `Shared/Home/HomeTools.swift`. +The normal service and per-tool switches apply to calls through iMCP. +Other local processes can connect directly to the helper while it is running. + +## Behavior + +Home IDs and object IDs are HomeKit UUID strings. +Omitting `home` selects the home only when exactly one home is available. +Batch reads and room assignment return a result for each requested ID. +An unknown ID does not abort other items in the batch. + +Inventory calls omit live values unless `include_values` is true. +Reads have a five-second timeout, with no more than four outstanding HomeKit +read operations across requests. +A timed-out HomeKit operation still holds its slot until its callback arrives, +so unreachable devices cannot cause an unlimited number of pending reads. +Non-finite metadata values are represented as JSON `null`. + +Write values are checked against the characteristic format, range, +step, and permitted values where available. +Unsigned 64-bit values are limited to the exact integer range of JSON numbers +used by this implementation, from 0 through 9007199254740991. +Data and TLV8 values use base64 strings. +All objects in a room, zone, scene, or automation operation must belong +to the same home. + +Failed scene, zone, and automation creation attempts remove the partially +created object where possible. +If cleanup fails, the error includes its ID. +Multi-step updates can make partial changes before HomeKit reports an error. +Inspect the object before retrying a failed update. + +After a connection failure, the proxy reconnects once. +Read-only calls retry once. +Writes are not repeated automatically because HomeKit may have applied a write +before the connection failed. +The error instructs the caller to inspect the home before retrying. + +HomeKit exposes only part of some Home app automations and Shortcuts actions. +Exports include trigger-owned action sets as well as ordinary scenes. +Unknown event and action types are marked unsupported. +Apple deprecated `lastFireDate` in Mac Catalyst 17 without a replacement; +the field is always `null`. + +Timer fire dates must fall on a whole-minute boundary. +Pairing accessories and renaming homes are outside this tool set. + +## Checks + +With a manually started signed helper and working Bonjour discovery, +run the read-only integration check: + +```sh +uv run Scripts/check-home.py /path/to/imcp-server +``` + +The check uses `IMCP_SERVICE_TYPE=_imcp-home._tcp` to connect directly to the helper. +It checks the tool count, annotations, inventory, Default Room filter, +and error handling without changing HomeKit data. +The same environment variable lets MCP Inspector use the helper directly. +The default CLI service type remains `_mcp._tcp`. + +The feasibility mode is available with the `--spike` launch argument. +It exports `homekit-spike.json` to the helper's Application Support directory +and writes JSON to standard output. +Adding `--rename-accessory ` performs a rename-and-restore check. +It saves the original name in `rename-recovery.json` before the first write, +and removes the recovery file after restoration succeeds. +An existing recovery file prevents another rename test. +Review that file and restore the original name before removing it. + +The initial local feasibility check loaded one home with 112 accessories, +including 95 bridged accessories, in about 0.08 seconds. +Both timer and event triggers appeared in the export. +A reachable sconce completed the rename-and-restore check. +These observations confirm API access on the development Mac; +they do not establish support for every accessory or Home app automation. + +The optional proxy integration test starts a signed helper, reads the home, +checks remote errors, terminates the helper, and checks automatic recovery. +Set `TEST_RUNNER_IMCP_HOME_HELPER_PATH` to the signed helper app path when running +the `imcp-serverTests` scheme. +Quit any manually started helper before this test. diff --git a/Scripts/check-home.py b/Scripts/check-home.py new file mode 100644 index 00000000..20d5afb2 --- /dev/null +++ b/Scripts/check-home.py @@ -0,0 +1,164 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Check the running Home 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-home._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 Home 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-home._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() From 1309a51f098950728888ec7f674529084db4186b Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Wed, 16 Sep 2026 05:20:19 -0700 Subject: [PATCH 4/9] Fold the Home proxy into HomeService and move helper docs into the README --- .github/workflows/ci.yml | 10 +- App/Services/Home.swift | 235 ++++++++++++++++- App/Services/HomeProxyBackend.swift | 213 --------------- CLITests/HomeProxyTests.swift | 2 +- CLITests/ServiceGroupConfigurationTests.swift | 4 +- Docs/Home.md | 157 ----------- Home/App.swift | 6 - Home/Services/HomeKitBackend.swift | 207 +++++++++++++++ Home/Services/HomeKitInventory.swift | 205 --------------- Home/Services/HomeKitSpike.swift | 246 ------------------ README.md | 63 +++++ iMCP.xcodeproj/project.pbxproj | 6 +- 12 files changed, 517 insertions(+), 837 deletions(-) delete mode 100644 App/Services/HomeProxyBackend.swift delete mode 100644 Docs/Home.md delete mode 100644 Home/Services/HomeKitInventory.swift delete mode 100644 Home/Services/HomeKitSpike.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b04d9a98..33722c4b 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" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO HOME_HELPER_CODE_SIGNING_ALLOWED=NO build + run: | + xcodebuild -quiet \ + -scheme iMCP \ + -configuration Debug \ + -destination "platform=macOS" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + HOME_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/Services/Home.swift b/App/Services/Home.swift index 09719e6b..1975260e 100644 --- a/App/Services/Home.swift +++ b/App/Services/Home.swift @@ -1,7 +1,232 @@ -final class HomeService: Service { +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 backend = HomeProxyBackend() - var tools: [Tool] { return HomeTools.tools(backend: backend) } - var isActivated: Bool { get async { await backend.isActivated } } - func activate() async throws { try await backend.activate() } + + 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("The Home 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 Home helper connection was lost. The write may have completed. Inspect the home before retrying." + ) + } + guard let client else { throw HomeError("The Home 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("The Home helper returned no JSON text.") + } + do { return try JSONDecoder().decode(Value.self, from: data) } catch { + throw HomeError("The Home 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-home._tcp", domain: "local."), using: .tcp) + do { + endpoint = try await BonjourDiscovery.discoverEndpoint( + using: browser, + timeout: .seconds(15), + preferring: { String(describing: $0.endpoint).contains("iMCP Home") } + ) + } catch { + throw HomeError( + "The Home helper was not found. Quit any manually started iMCP Home helper and enable Home again. \(error.localizedDescription)" + ) + } + } + let deadline = Date().addingTimeInterval(15) + repeat { + do { + try await connect(to: endpoint, parameters: parameters) + return + } catch let error as HomeError { throw error } catch { + guard launchedHelper != nil, Date() < deadline else { throw error } + 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 Home" else { + throw HomeError("The selected endpoint is not an iMCP Home helper.") + } + _ = try await forward(client, "homes_list", [:]) + self.client = client + self.connection = connection + authorized = true + log.info("Connected to the Home helper") + } catch { + connection.cancel() + await client.disconnect() + throw error + } + } + + // MARK: - Helper Launch + + @MainActor + private func launchHelper(previous: LaunchedHelper?, overrideURL: URL?) async throws -> LaunchedHelper? { + if let running = NSWorkspace.shared.runningApplications.first(where: { + $0.bundleIdentifier == "co.dododo.iMCP.Home" && !$0.isTerminated + }) { + return previous?.pid == running.processIdentifier ? previous : nil + } + let embedded = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/iMCP Home.app") + let url = + overrideURL + ?? (FileManager.default.fileExists(atPath: embedded.path) + ? embedded : NSWorkspace.shared.urlForApplication(withBundleIdentifier: "co.dododo.iMCP.Home")) + guard let url else { return nil } + let port = try availableLoopbackPort() + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + 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 Home helper socket.") } + 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 Home helper socket.") } + 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 Home helper port.") } + return UInt16(bigEndian: address.sin_port) + } } diff --git a/App/Services/HomeProxyBackend.swift b/App/Services/HomeProxyBackend.swift deleted file mode 100644 index 7d31af17..00000000 --- a/App/Services/HomeProxyBackend.swift +++ /dev/null @@ -1,213 +0,0 @@ -import AppKit -import Darwin -import MCP -import Network -import OSLog - -actor HomeProxyBackend: HomeBackend { - private let log = Logger.service("home") - private var client: MCP.Client? - private var connection: NWConnection? - private var connecting: Task? - private var authorized = false - private var launchedHelper: LaunchedHelper? - private let helperURL: URL? - - init(helperURL: URL? = nil) { self.helperURL = helperURL } - - private struct LaunchedHelper: Sendable { - let pid: pid_t - let endpoint: NWEndpoint - } - - 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 - } - - func call(_ tool: String, _ input: [String: Value]) async throws -> Value { - try await activate() - guard let active = client else { throw HomeError("The Home 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 HomeTools.tools(backend: self).first(where: { $0.name == tool })?.annotations.readOnlyHint == true - else { - throw HomeError( - "The Home helper connection was lost. The write may have completed. Inspect the home before retrying." - ) - } - guard let client else { throw HomeError("The Home helper could not reconnect.") } - return try await forward(client, tool, input) - } - } - - 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("The Home helper returned no JSON text.") - } - do { return try JSONDecoder().decode(Value.self, from: data) } catch { - throw HomeError("The Home 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-home._tcp", domain: "local."), using: .tcp) - do { - endpoint = try await BonjourDiscovery.discoverEndpoint( - using: browser, - timeout: .seconds(15), - preferring: { String(describing: $0.endpoint).contains("iMCP Home") } - ) - } catch { - throw HomeError( - "The Home helper was not found. Quit any manually started iMCP Home helper and enable Home again. \(error.localizedDescription)" - ) - } - } - let deadline = Date().addingTimeInterval(15) - repeat { - do { - try await connect(to: endpoint, parameters: parameters) - return - } catch let error as HomeError { throw error } catch { - guard launchedHelper != nil, Date() < deadline else { throw error } - 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 Home" else { - throw HomeError("The selected endpoint is not an iMCP Home helper.") - } - _ = try await forward(client, "homes_list", [:]) - self.client = client - self.connection = connection - authorized = true - log.info("Connected to the Home helper") - } catch { - connection.cancel() - await client.disconnect() - throw error - } - } - - @MainActor - private func launchHelper(previous: LaunchedHelper?, overrideURL: URL?) async throws -> LaunchedHelper? { - if let running = NSWorkspace.shared.runningApplications.first(where: { - $0.bundleIdentifier == "co.dododo.iMCP.Home" && !$0.isTerminated - }) { - return previous?.pid == running.processIdentifier ? previous : nil - } - let embedded = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/iMCP Home.app") - let url = - overrideURL - ?? (FileManager.default.fileExists(atPath: embedded.path) - ? embedded : NSWorkspace.shared.urlForApplication(withBundleIdentifier: "co.dododo.iMCP.Home")) - guard let url else { return nil } - let port = try availableLoopbackPort() - let configuration = NSWorkspace.OpenConfiguration() - configuration.activates = false - 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 Home helper socket.") } - 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 Home helper socket.") } - 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 Home helper port.") } - return UInt16(bigEndian: address.sin_port) - } -} diff --git a/CLITests/HomeProxyTests.swift b/CLITests/HomeProxyTests.swift index 56dfa6e3..c4c7564d 100644 --- a/CLITests/HomeProxyTests.swift +++ b/CLITests/HomeProxyTests.swift @@ -7,7 +7,7 @@ final class HomeProxyTests: XCTestCase { guard let path = ProcessInfo.processInfo.environment["IMCP_HOME_HELPER_PATH"] else { throw XCTSkip("Set IMCP_HOME_HELPER_PATH to run the signed helper integration test.") } - let backend = HomeProxyBackend(helperURL: URL(fileURLWithPath: path)) + let backend = HomeService(helperURL: URL(fileURLWithPath: path)) let initial = await backend.isActivated XCTAssertFalse(initial) try await backend.activate() 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/Docs/Home.md b/Docs/Home.md deleted file mode 100644 index d43c6e7a..00000000 --- a/Docs/Home.md +++ /dev/null @@ -1,157 +0,0 @@ -# HomeKit tools - -The Home service uses a signed Mac Catalyst helper inside -`iMCP.app/Contents/Helpers/iMCP Home.app`. -The native app starts the helper when you enable Home. -The helper requests HomeKit access and exits when its parent app exits. -Its status window reports startup and permission errors. - -## Build - -Build the `iMCP` scheme for macOS. -The build phase runs the `iMCP Home` scheme for Mac Catalyst in a separate -build directory, then copies the helper into the app. -A direct target dependency selects the iOS variant under Xcode 27, -so the separate build is required. -The nested build uses a clean environment to avoid inheriting native app -product names and SDK settings. - -The helper uses automatic development signing with team `TTY35UM57S` -in both configurations. -The native Debug app can remain unsigned while its helper is signed. -The helper requires a development certificate and a HomeKit provisioning profile. -On another developer account, change the helper's team in Xcode. -If automatic provisioning initially cannot include HomeKit, -enable HomeKit in Signing & Capabilities for the iOS destination first. -An iOS build with `-allowProvisioningUpdates` also registered the capability -for this project's bundle ID during local setup. - -```sh -xcodebuild -scheme "iMCP Home" -configuration Debug \ - -destination 'platform=macOS,variant=Mac Catalyst' \ - -allowProvisioningUpdates build -``` - -CI builds both apps without signing: - -```sh -xcodebuild -quiet -scheme iMCP -configuration Debug \ - -destination 'platform=macOS' \ - CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \ - HOME_HELPER_CODE_SIGNING_ALLOWED=NO build -``` - -Unsigned builds check compilation and packaging. -They cannot test HomeKit access. -Release distribution and notarization of the nested app remain separate work. -The release script has not changed. - -## Access and discovery - -Enable Home in iMCP, then allow HomeKit access when macOS asks. -The Mac's iCloud account must have access to the home. -Writes can require the home owner or administrator role. -If access is denied, check System Settings → Privacy & Security → HomeKit. -Bonjour can also require local network permission. - -The helper binds its TCP listener to `127.0.0.1`. -For automatic launch, the native app selects an ephemeral loopback port -and passes it to the helper with `--port`. -This connection does not depend on Bonjour. -A manually started helper publishes `_imcp-home._tcp` through the DNS-SD -local-only interface, with `localhost` as its host. -That service is visible only on this Mac. -`acceptLocalOnly` alone restricts connections to the local network link; -it does not restrict them to the host. -The explicit loopback binding provides that restriction. -See [Apple's acceptLocalOnly documentation](https://developer.apple.com/documentation/network/nwparameters/acceptlocalonly). - -The native service retries connections while the launched helper starts. -For a manually started helper, it uses Bonjour with a 15-second timeout. -Bonjour registration stalled during local testing on macOS 27. -If manual discovery fails, quit the helper and let iMCP launch it. -It checks `homes_list` before activation succeeds. -Settings can list all 28 tools while the helper is stopped because both targets -use the same definitions in `Shared/Home/HomeTools.swift`. -The normal service and per-tool switches apply to calls through iMCP. -Other local processes can connect directly to the helper while it is running. - -## Behavior - -Home IDs and object IDs are HomeKit UUID strings. -Omitting `home` selects the home only when exactly one home is available. -Batch reads and room assignment return a result for each requested ID. -An unknown ID does not abort other items in the batch. - -Inventory calls omit live values unless `include_values` is true. -Reads have a five-second timeout, with no more than four outstanding HomeKit -read operations across requests. -A timed-out HomeKit operation still holds its slot until its callback arrives, -so unreachable devices cannot cause an unlimited number of pending reads. -Non-finite metadata values are represented as JSON `null`. - -Write values are checked against the characteristic format, range, -step, and permitted values where available. -Unsigned 64-bit values are limited to the exact integer range of JSON numbers -used by this implementation, from 0 through 9007199254740991. -Data and TLV8 values use base64 strings. -All objects in a room, zone, scene, or automation operation must belong -to the same home. - -Failed scene, zone, and automation creation attempts remove the partially -created object where possible. -If cleanup fails, the error includes its ID. -Multi-step updates can make partial changes before HomeKit reports an error. -Inspect the object before retrying a failed update. - -After a connection failure, the proxy reconnects once. -Read-only calls retry once. -Writes are not repeated automatically because HomeKit may have applied a write -before the connection failed. -The error instructs the caller to inspect the home before retrying. - -HomeKit exposes only part of some Home app automations and Shortcuts actions. -Exports include trigger-owned action sets as well as ordinary scenes. -Unknown event and action types are marked unsupported. -Apple deprecated `lastFireDate` in Mac Catalyst 17 without a replacement; -the field is always `null`. - -Timer fire dates must fall on a whole-minute boundary. -Pairing accessories and renaming homes are outside this tool set. - -## Checks - -With a manually started signed helper and working Bonjour discovery, -run the read-only integration check: - -```sh -uv run Scripts/check-home.py /path/to/imcp-server -``` - -The check uses `IMCP_SERVICE_TYPE=_imcp-home._tcp` to connect directly to the helper. -It checks the tool count, annotations, inventory, Default Room filter, -and error handling without changing HomeKit data. -The same environment variable lets MCP Inspector use the helper directly. -The default CLI service type remains `_mcp._tcp`. - -The feasibility mode is available with the `--spike` launch argument. -It exports `homekit-spike.json` to the helper's Application Support directory -and writes JSON to standard output. -Adding `--rename-accessory ` performs a rename-and-restore check. -It saves the original name in `rename-recovery.json` before the first write, -and removes the recovery file after restoration succeeds. -An existing recovery file prevents another rename test. -Review that file and restore the original name before removing it. - -The initial local feasibility check loaded one home with 112 accessories, -including 95 bridged accessories, in about 0.08 seconds. -Both timer and event triggers appeared in the export. -A reachable sconce completed the rename-and-restore check. -These observations confirm API access on the development Mac; -they do not establish support for every accessory or Home app automation. - -The optional proxy integration test starts a signed helper, reads the home, -checks remote errors, terminates the helper, and checks automatic recovery. -Set `TEST_RUNNER_IMCP_HOME_HELPER_PATH` to the signed helper app path when running -the `imcp-serverTests` scheme. -Quit any manually started helper before this test. diff --git a/Home/App.swift b/Home/App.swift index 9af9fdfe..bafd4127 100644 --- a/Home/App.swift +++ b/Home/App.swift @@ -36,12 +36,6 @@ final class HomeRuntime: ObservableObject { canRetry = false do { try monitorParent() - if CommandLine.arguments.contains("--spike") || CommandLine.arguments.contains("--rename-accessory") { - let spike = HomeKitSpike() - await spike.run() - status = spike.status - return - } let server = try self.server ?? HelperServer(backend: backend, port: requestedPort()) self.server = server status = "Starting the local connection…" diff --git a/Home/Services/HomeKitBackend.swift b/Home/Services/HomeKitBackend.swift index f8b09051..69f75d17 100644 --- a/Home/Services/HomeKitBackend.swift +++ b/Home/Services/HomeKitBackend.swift @@ -379,6 +379,213 @@ final class HomeKitBackend: HomeBackend { 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 bridge = accessory.home?.accessories.first { + $0.uniqueIdentifiersForBridgedAccessories?.contains(accessory.uniqueIdentifier) == true + } + return AccessorySummary( + id: accessory.uniqueIdentifier.uuidString, + name: accessory.name, + home: accessory.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 } diff --git a/Home/Services/HomeKitInventory.swift b/Home/Services/HomeKitInventory.swift deleted file mode 100644 index 248976ee..00000000 --- a/Home/Services/HomeKitInventory.swift +++ /dev/null @@ -1,205 +0,0 @@ -import Foundation -import HomeKit - -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 bridge = accessory.home?.accessories.first { - $0.uniqueIdentifiersForBridgedAccessories?.contains(accessory.uniqueIdentifier) == true - } - return AccessorySummary( - id: accessory.uniqueIdentifier.uuidString, - name: accessory.name, - home: accessory.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) - } -} diff --git a/Home/Services/HomeKitSpike.swift b/Home/Services/HomeKitSpike.swift deleted file mode 100644 index bbebe762..00000000 --- a/Home/Services/HomeKitSpike.swift +++ /dev/null @@ -1,246 +0,0 @@ -import Foundation -import HomeKit - -/// Exercises HomeKit access before the helper is connected to iMCP. -@MainActor -final class HomeKitSpike: NSObject, ObservableObject, HMHomeManagerDelegate { - @Published private(set) var status = "Waiting for HomeKit access…" - private var manager: HMHomeManager? - private var loaded = false - private var started = false - private var waiter: CheckedContinuation? - private var loadTimeout: Task? - - func run() async { - guard !started else { return } - started = true - let start = Date() - do { - let manager = HMHomeManager() - self.manager = manager - manager.delegate = self - try await waitForHomes() - guard manager.authorizationStatus.contains(.authorized) else { - throw SpikeError.message( - "HomeKit access is denied. Check System Settings → Privacy & Security → HomeKit." - ) - } - let folder = try FileManager.default.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - ) - let destination = folder.appendingPathComponent("homekit-spike.json") - let report: [String: Any] = [ - "loadedInSeconds": Date().timeIntervalSince(start), - "authorizationStatus": manager.authorizationStatus.rawValue, - "homes": manager.homes.map(home), - ] - let data = try JSONSerialization.data( - withJSONObject: jsonValue(report), - options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - ) - try data.write(to: destination, options: .atomic) - print(String(decoding: data, as: UTF8.self)) - status = "Loaded \(manager.homes.count) homes. Export: \(destination.path)" - - // A specific UUID keeps repeated diagnostic launches from changing arbitrary devices. - if let index = CommandLine.arguments.firstIndex(of: "--rename-accessory") { - guard CommandLine.arguments.indices.contains(index + 1), - let id = UUID(uuidString: CommandLine.arguments[index + 1]), - let accessory = manager.homes.flatMap(\.accessories).first(where: { - $0.uniqueIdentifier == id - }) - else { throw SpikeError.message("Specify an existing accessory UUID after --rename-accessory.") } - let original = accessory.name - let recovery = folder.appendingPathComponent("rename-recovery.json") - guard !FileManager.default.fileExists(atPath: recovery.path) else { - throw SpikeError.message( - "A previous rename test needs review. Restore the name saved in \(recovery.path), then remove that file before another test." - ) - } - let recoveryData = try JSONSerialization.data( - withJSONObject: [ - "accessory": id.uuidString, "originalName": original, - ], - options: [.sortedKeys] - ) - // Preserve the original name even if the process exits during the write test. - try recoveryData.write(to: recovery, options: .atomic) - try await accessory.updateName(original + " Test") - do { - try await accessory.updateName(original) - try FileManager.default.removeItem(at: recovery) - status += "\nRename and restore succeeded for \(id.uuidString)." - } catch { - throw SpikeError.message( - "Restore failed for \(id.uuidString). Restore its name to '\(original)' in Home. \(error.localizedDescription)" - ) - } - } - } catch { - status = "HomeKit check failed: \(error.localizedDescription)" - } - print(status) - fflush(stdout) - } - - private func waitForHomes() async throws { - if loaded { return } - try await withCheckedThrowingContinuation { continuation in - waiter = continuation - loadTimeout = Task { [weak self] in - do { try await Task.sleep(for: .seconds(10)) } catch { return } - self?.finishLoading( - .failure( - SpikeError.message( - "HomeKit did not load within 10 seconds. Grant access, then restart the helper." - ) - ) - ) - } - } - } - - private func finishLoading(_ result: Result) { - loadTimeout?.cancel() - loadTimeout = nil - let continuation = waiter - waiter = nil - continuation?.resume(with: result) - } - - nonisolated func homeManagerDidUpdateHomes(_ manager: HMHomeManager) { - Task { @MainActor [weak self] in - self?.loaded = true - self?.finishLoading(.success(())) - } - } - - private func home(_ home: HMHome) -> [String: Any] { - let defaultRoom = home.roomForEntireHome() - var rooms = home.rooms - if !rooms.contains(where: { $0.uniqueIdentifier == defaultRoom.uniqueIdentifier }) { - rooms.append(defaultRoom) - } - return [ - "id": home.uniqueIdentifier.uuidString, - "name": home.name, - "isPrimary": home.isPrimary, - "rooms": rooms.map { room in - [ - "id": room.uniqueIdentifier.uuidString, "name": room.name, - "isDefaultRoom": room.uniqueIdentifier == defaultRoom.uniqueIdentifier, - "accessories": room.accessories.map { $0.uniqueIdentifier.uuidString }, - ] as [String: Any] - }, - "zones": home.zones.map { zone in - [ - "id": zone.uniqueIdentifier.uuidString, "name": zone.name, - "rooms": zone.rooms.map { $0.uniqueIdentifier.uuidString }, - ] as [String: Any] - }, - "accessories": home.accessories.map(accessory), - "scenes": home.actionSets.map { scene in - [ - "id": scene.uniqueIdentifier.uuidString, "name": scene.name, - "type": scene.actionSetType, - "actions": scene.actions.map { action -> [String: Any] in - var result: [String: Any] = [ - "id": action.uniqueIdentifier.uuidString, - "kind": String(describing: type(of: action)), - ] - if let write = action as? HMCharacteristicWriteAction { - result["characteristic"] = write.characteristic.uniqueIdentifier.uuidString - result["target"] = jsonValue(write.targetValue) - } - return result - }, - ] as [String: Any] - }, - "automations": home.triggers.map { trigger in - var result: [String: Any] = [ - "id": trigger.uniqueIdentifier.uuidString, "name": trigger.name, - "kind": String(describing: type(of: trigger)), "isEnabled": trigger.isEnabled, - "scenes": trigger.actionSets.map { $0.uniqueIdentifier.uuidString }, - // Apple deprecated lastFireDate in Mac Catalyst 17 without a replacement. - "lastFireDate": NSNull(), - ] - if let event = trigger as? HMEventTrigger { - result["events"] = event.events.map { String(describing: $0) } - result["endEvents"] = event.endEvents.map { String(describing: $0) } - result["predicate"] = event.predicate?.predicateFormat as Any? ?? NSNull() - result["recurrences"] = event.recurrences?.map { String(describing: $0) } - } - if let timer = trigger as? HMTimerTrigger { - result["fireDate"] = timer.fireDate.ISO8601Format() - result["recurrence"] = timer.recurrence.map { String(describing: $0) } - } - return result - }, - ] - } - - private func accessory(_ accessory: HMAccessory) -> [String: Any] { - [ - "id": accessory.uniqueIdentifier.uuidString, "name": accessory.name, - "room": accessory.room?.uniqueIdentifier.uuidString as Any? ?? NSNull(), - "category": accessory.category.categoryType, - "categoryDescription": accessory.category.localizedDescription, - "manufacturer": accessory.manufacturer as Any? ?? NSNull(), - "model": accessory.model as Any? ?? NSNull(), - "firmware": accessory.firmwareVersion as Any? ?? NSNull(), - "isReachable": accessory.isReachable, "isBridged": accessory.isBridged, - "uniqueIdentifiersForBridgedAccessories": accessory.uniqueIdentifiersForBridgedAccessories?.map( - \.uuidString - ) ?? [], - "services": accessory.services.map { service in - [ - "id": service.uniqueIdentifier.uuidString, "name": service.name, - "type": service.serviceType, "description": service.localizedDescription, - "characteristics": service.characteristics.map { characteristic in - let metadata = characteristic.metadata - return [ - "id": characteristic.uniqueIdentifier.uuidString, - "type": characteristic.characteristicType, - "description": characteristic.localizedDescription, - "properties": characteristic.properties, - "metadata": [ - "format": metadata?.format as Any? ?? NSNull(), - "units": metadata?.units as Any? ?? NSNull(), - "minimumValue": metadata?.minimumValue as Any? ?? NSNull(), - "maximumValue": metadata?.maximumValue as Any? ?? NSNull(), - "stepValue": metadata?.stepValue as Any? ?? NSNull(), - "validValues": metadata?.validValues as Any? ?? NSNull(), - "maxLength": metadata?.maxLength as Any? ?? NSNull(), - "manufacturerDescription": metadata?.manufacturerDescription as Any? ?? NSNull(), - ], - ] as [String: Any] - }, - ] as [String: Any] - }, - ] - } - - private func jsonValue(_ value: Any) -> Any { - if let data = value as? Data { return data.base64EncodedString() } - if let number = value as? NSNumber { - return number.doubleValue.isFinite ? number : NSNull() - } - if let object = value as? [String: Any] { return object.mapValues(jsonValue) } - if let array = value as? [Any] { return array.map(jsonValue) } - if value is NSNull || value is String { return value } - return String(describing: value) - } -} - -private enum SpikeError: LocalizedError { - case message(String) - - var errorDescription: String? { - switch self { - case .message(let message): return message - } - } -} diff --git a/README.md b/README.md index 51c8a449..0915a2f3 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,69 @@ a Swift package for working with structured data. It includes convenience initializers for types from Apple frameworks, such as those returned by iMCP tools. +### HomeKit 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 Home`](/Home/), +bundled at `iMCP.app/Contents/Helpers/iMCP Home.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-home.\_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-home._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-home-helper.sh`](/Scripts/build-home-helper.sh), +which builds the `iMCP Home` 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. +If automatic provisioning doesn't include HomeKit at first, +enable the HomeKit capability for the iOS destination, +or build the helper once with `-allowProvisioningUpdates`. + +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 \ + HOME_HELPER_CODE_SIGNING_ALLOWED=NO build +``` + ## Debugging ### Using the MCP Inspector diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index d9d7d591..138ba7ce 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 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 */ @@ -99,9 +100,10 @@ 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/HomeProxyBackend.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 */ @@ -194,6 +196,7 @@ A40000000000000000000011, A40000000000000000000012, A40000000000000000000013, + A40000000000000000000014, A30000000000000000000010, A30000000000000000000011, A30000000000000000000012, @@ -440,6 +443,7 @@ A40000000000000000000021, A40000000000000000000022, A40000000000000000000023, + A40000000000000000000024, A30000000000000000000020, A30000000000000000000021, A30000000000000000000022, From eb05fd9af7a1f5b5118c6d69886dd74fb3826268 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Wed, 16 Sep 2026 05:28:39 -0700 Subject: [PATCH 5/9] Rename the Home helper to iMCP Helper --- .github/workflows/ci.yml | 2 +- App/Info.plist | 2 +- App/Services/Home.swift | 34 ++++++++-------- CLITests/HomeProxyTests.swift | 6 +-- {Home => Helper}/App.swift | 10 ++--- .../Extensions/HomeKit+Extensions.swift | 2 +- .../Helper.entitlements | 0 {Home => Helper}/Info.plist | 2 +- {Home => Helper}/Models/HomeModels.swift | 0 .../Server/HelperAdvertisement.swift | 12 +++--- {Home => Helper}/Server/HelperServer.swift | 12 +++--- .../Services/HomeKitBackend.swift | 0 {Home => Helper}/Services/HomeKitStore.swift | 4 +- README.md | 31 +++++++++----- .../{build-home-helper.sh => build-helper.sh} | 12 +++--- Scripts/check-home.py | 8 ++-- iMCP.xcodeproj/project.pbxproj | 40 +++++++++---------- ...MCP Home.xcscheme => iMCP Helper.xcscheme} | 12 +++--- 18 files changed, 99 insertions(+), 90 deletions(-) rename {Home => Helper}/App.swift (92%) rename {Home => Helper}/Extensions/HomeKit+Extensions.swift (97%) rename Home/Home.entitlements => Helper/Helper.entitlements (100%) rename {Home => Helper}/Info.plist (87%) rename {Home => Helper}/Models/HomeModels.swift (100%) rename Home/Server/HomeAdvertisement.swift => Helper/Server/HelperAdvertisement.swift (83%) rename {Home => Helper}/Server/HelperServer.swift (93%) rename {Home => Helper}/Services/HomeKitBackend.swift (100%) rename {Home => Helper}/Services/HomeKitStore.swift (98%) rename Scripts/{build-home-helper.sh => build-helper.sh} (83%) rename iMCP.xcodeproj/xcshareddata/xcschemes/{iMCP Home.xcscheme => iMCP Helper.xcscheme} (90%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33722c4b..a33968ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: -destination "platform=macOS" \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ - HOME_HELPER_CODE_SIGNING_ALLOWED=NO \ + HELPER_CODE_SIGNING_ALLOWED=NO \ build # The cache action's exclude pattern has not kept the module cache out of diff --git a/App/Info.plist b/App/Info.plist index 7b267b51..5365a477 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -7,7 +7,7 @@ NSBonjourServices _mcp._tcp - _imcp-home._tcp + _imcp-helper._tcp SUEnableInstallerLauncherService diff --git a/App/Services/Home.swift b/App/Services/Home.swift index 1975260e..ed1da4d5 100644 --- a/App/Services/Home.swift +++ b/App/Services/Home.swift @@ -48,7 +48,7 @@ actor HomeService: Service, HomeBackend { func call(_ tool: String, _ input: [String: Value]) async throws -> Value { try await activate() - guard let active = client else { throw HomeError("The Home helper is not connected.") } + 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 @@ -62,10 +62,10 @@ actor HomeService: Service, HomeBackend { guard tools.first(where: { $0.name == tool })?.annotations.readOnlyHint == true else { throw HomeError( - "The Home helper connection was lost. The write may have completed. Inspect the home before retrying." + "The iMCP Helper connection was lost. The write may have completed. Inspect the home before retrying." ) } - guard let client else { throw HomeError("The Home helper could not reconnect.") } + guard let client else { throw HomeError("iMCP Helper could not reconnect.") } return try await forward(client, tool, input) } } @@ -94,10 +94,10 @@ actor HomeService: Service, HomeBackend { } if result.isError == true { throw HomeError(texts.joined(separator: "\n")) } guard let text = texts.first, let data = text.data(using: .utf8) else { - throw HomeError("The Home helper returned no JSON text.") + throw HomeError("iMCP Helper returned no JSON text.") } do { return try JSONDecoder().decode(Value.self, from: data) } catch { - throw HomeError("The Home helper returned invalid JSON: \(error.localizedDescription)") + throw HomeError("iMCP Helper returned invalid JSON: \(error.localizedDescription)") } } @@ -118,16 +118,16 @@ actor HomeService: Service, HomeBackend { if let launchedHelper { endpoint = launchedHelper.endpoint } else { - let browser = NWBrowser(for: .bonjour(type: "_imcp-home._tcp", domain: "local."), using: .tcp) + 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 Home") } + preferring: { String(describing: $0.endpoint).contains("iMCP Helper") } ) } catch { throw HomeError( - "The Home helper was not found. Quit any manually started iMCP Home helper and enable Home again. \(error.localizedDescription)" + "iMCP Helper was not found. Quit any manually started iMCP Helper and enable Home again. \(error.localizedDescription)" ) } } @@ -165,14 +165,14 @@ actor HomeService: Service, HomeBackend { defer { timeout.cancel() } do { let result = try await client.connect(transport: transport) - guard result.serverInfo.name == "iMCP Home" else { - throw HomeError("The selected endpoint is not an iMCP Home helper.") + 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 the Home helper") + log.info("Connected to iMCP Helper") } catch { connection.cancel() await client.disconnect() @@ -185,15 +185,15 @@ actor HomeService: Service, HomeBackend { @MainActor private func launchHelper(previous: LaunchedHelper?, overrideURL: URL?) async throws -> LaunchedHelper? { if let running = NSWorkspace.shared.runningApplications.first(where: { - $0.bundleIdentifier == "co.dododo.iMCP.Home" && !$0.isTerminated + $0.bundleIdentifier == "co.dododo.iMCP.Helper" && !$0.isTerminated }) { return previous?.pid == running.processIdentifier ? previous : nil } - let embedded = Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/iMCP Home.app") + 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.Home")) + ? embedded : NSWorkspace.shared.urlForApplication(withBundleIdentifier: "co.dododo.iMCP.Helper")) guard let url else { return nil } let port = try availableLoopbackPort() let configuration = NSWorkspace.OpenConfiguration() @@ -210,7 +210,7 @@ actor HomeService: Service, HomeBackend { /// 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 Home helper socket.") } + 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) @@ -221,12 +221,12 @@ actor HomeService: Service, HomeBackend { bind(descriptor, $0, socklen_t(MemoryLayout.size)) } } - guard result == 0 else { throw HomeError("Cannot bind a Home helper socket.") } + 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 Home helper port.") } + guard nameResult == 0 else { throw HomeError("Cannot select a port for iMCP Helper.") } return UInt16(bigEndian: address.sin_port) } } diff --git a/CLITests/HomeProxyTests.swift b/CLITests/HomeProxyTests.swift index c4c7564d..db0457ce 100644 --- a/CLITests/HomeProxyTests.swift +++ b/CLITests/HomeProxyTests.swift @@ -4,8 +4,8 @@ import XCTest final class HomeProxyTests: XCTestCase { @MainActor func testLiveLaunchAndRecovery() async throws { - guard let path = ProcessInfo.processInfo.environment["IMCP_HOME_HELPER_PATH"] else { - throw XCTSkip("Set IMCP_HOME_HELPER_PATH to run the signed helper integration test.") + 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 @@ -23,7 +23,7 @@ final class HomeProxyTests: XCTestCase { } let helper = try XCTUnwrap( NSWorkspace.shared.runningApplications.first { - $0.bundleIdentifier == "co.dododo.iMCP.Home" + $0.bundleIdentifier == "co.dododo.iMCP.Helper" } ) XCTAssertTrue(helper.forceTerminate()) diff --git a/Home/App.swift b/Helper/App.swift similarity index 92% rename from Home/App.swift rename to Helper/App.swift index bafd4127..d1a9d7c4 100644 --- a/Home/App.swift +++ b/Helper/App.swift @@ -2,13 +2,13 @@ import Network import SwiftUI @main -struct HomeApp: App { - @StateObject private var runtime = HomeRuntime() +struct HelperApp: App { + @StateObject private var runtime = HelperRuntime() var body: some Scene { WindowGroup { VStack(alignment: .leading, spacing: 16) { - Text("iMCP Home").font(.title) + Text("iMCP Helper").font(.title) Text(runtime.status).textSelection(.enabled) if runtime.canRetry { Button("Retry") { Task { await runtime.start() } } @@ -22,7 +22,7 @@ struct HomeApp: App { } @MainActor -final class HomeRuntime: ObservableObject { +final class HelperRuntime: ObservableObject { @Published private(set) var canRetry = false @Published private(set) var status = "Starting HomeKit…" private let backend = HomeKitBackend() @@ -44,7 +44,7 @@ final class HomeRuntime: ObservableObject { try await backend.store.ensureLoaded() status = "HomeKit is ready. \(backend.store.homes.count) homes available." } catch { - status = "Home helper failed: \(homeErrorMessage(error))" + status = "iMCP Helper failed: \(homeErrorMessage(error))" started = false canRetry = true } diff --git a/Home/Extensions/HomeKit+Extensions.swift b/Helper/Extensions/HomeKit+Extensions.swift similarity index 97% rename from Home/Extensions/HomeKit+Extensions.swift rename to Helper/Extensions/HomeKit+Extensions.swift index fcc2305d..31a157d8 100644 --- a/Home/Extensions/HomeKit+Extensions.swift +++ b/Helper/Extensions/HomeKit+Extensions.swift @@ -87,7 +87,7 @@ func homeErrorMessage(_ error: Error) -> String { guard error.domain == HMErrorDomain else { return error.localizedDescription } switch HMError.Code(rawValue: error.code) { case .homeAccessNotAuthorized: - return "HomeKit access is not authorized. Enable iMCP Home in System Settings → Privacy & Security → HomeKit." + 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." diff --git a/Home/Home.entitlements b/Helper/Helper.entitlements similarity index 100% rename from Home/Home.entitlements rename to Helper/Helper.entitlements diff --git a/Home/Info.plist b/Helper/Info.plist similarity index 87% rename from Home/Info.plist rename to Helper/Info.plist index 3a9993d4..c1f15819 100644 --- a/Home/Info.plist +++ b/Helper/Info.plist @@ -6,7 +6,7 @@ NSBonjourServices - _imcp-home._tcp + _imcp-helper._tcp diff --git a/Home/Models/HomeModels.swift b/Helper/Models/HomeModels.swift similarity index 100% rename from Home/Models/HomeModels.swift rename to Helper/Models/HomeModels.swift diff --git a/Home/Server/HomeAdvertisement.swift b/Helper/Server/HelperAdvertisement.swift similarity index 83% rename from Home/Server/HomeAdvertisement.swift rename to Helper/Server/HelperAdvertisement.swift index 586ca8de..4929f4cc 100644 --- a/Home/Server/HomeAdvertisement.swift +++ b/Helper/Server/HelperAdvertisement.swift @@ -4,14 +4,14 @@ import dnssd /// Publishes the helper only to processes on this Mac, without multicast discovery. @MainActor -final class HomeAdvertisement { +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-home._tcp", domain: "local."), using: .tcp) + 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 @@ -25,8 +25,8 @@ final class HomeAdvertisement { &reference, 0, kDNSServiceInterfaceIndexLocalOnly, - "iMCP Home", - "_imcp-home._tcp", + "iMCP Helper", + "_imcp-helper._tcp", "local.", "localhost.", port.bigEndian, @@ -35,7 +35,7 @@ final class HomeAdvertisement { { _, _, error, _, _, _, context in guard let context else { return } MainActor.assumeIsolated { - let advertiser = Unmanaged.fromOpaque(context).takeUnretainedValue() + let advertiser = Unmanaged.fromOpaque(context).takeUnretainedValue() advertiser.finish(error) } }, @@ -60,7 +60,7 @@ final class HomeAdvertisement { stop() continuation?.resume( throwing: HomeError( - "Home helper Bonjour registration failed (\(error)). Allow iMCP Home in System Settings → Privacy & Security → Local Network, then retry." + "iMCP Helper Bonjour registration failed (\(error)). Allow iMCP Helper in System Settings → Privacy & Security → Local Network, then retry." ) ) } diff --git a/Home/Server/HelperServer.swift b/Helper/Server/HelperServer.swift similarity index 93% rename from Home/Server/HelperServer.swift rename to Helper/Server/HelperServer.swift index 3ef81c1d..3e1f5ab7 100644 --- a/Home/Server/HelperServer.swift +++ b/Helper/Server/HelperServer.swift @@ -8,10 +8,10 @@ actor HelperServer { private let backend: any HomeBackend private let port: NWEndpoint.Port? private var listener: NWListener? - private var advertisement: HomeAdvertisement? + private var advertisement: HelperAdvertisement? private var sessions: [UUID: MCP.Server] = [:] private var startup: CheckedContinuation? - private let log = Logger.service("home.server") + private let log = Logger.service("helper.server") init(backend: any HomeBackend, port: NWEndpoint.Port? = nil) { self.backend = backend @@ -45,8 +45,8 @@ actor HelperServer { // 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("The Home helper has no TCP port.") } - let advertisement = await HomeAdvertisement() + 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() @@ -78,13 +78,13 @@ actor HelperServer { self.startup = nil listener?.cancel() listener = nil - startup.resume(throwing: HomeError("The Home helper listener did not start within 10 seconds.")) + 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 Home", + name: "iMCP Helper", version: Bundle.main.shortVersionString ?? "1.0", capabilities: .init(tools: .init(listChanged: false)) ) diff --git a/Home/Services/HomeKitBackend.swift b/Helper/Services/HomeKitBackend.swift similarity index 100% rename from Home/Services/HomeKitBackend.swift rename to Helper/Services/HomeKitBackend.swift diff --git a/Home/Services/HomeKitStore.swift b/Helper/Services/HomeKitStore.swift similarity index 98% rename from Home/Services/HomeKitStore.swift rename to Helper/Services/HomeKitStore.swift index a64bd2a6..7f147f85 100644 --- a/Home/Services/HomeKitStore.swift +++ b/Helper/Services/HomeKitStore.swift @@ -34,7 +34,7 @@ final class HomeKitStore: NSObject, HMHomeManagerDelegate { try checkAuthorization() guard manager?.authorizationStatus.contains(.authorized) == true else { throw HomeError( - "HomeKit access is not authorized. Enable iMCP Home in System Settings → Privacy & Security → HomeKit." + "HomeKit access is not authorized. Enable iMCP Helper in System Settings → Privacy & Security → HomeKit." ) } } @@ -43,7 +43,7 @@ final class HomeKitStore: NSObject, HMHomeManagerDelegate { 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 Home in System Settings → Privacy & Security → HomeKit." + "HomeKit access is not authorized. Enable iMCP Helper in System Settings → Privacy & Security → HomeKit." ) } } diff --git a/README.md b/README.md index 0915a2f3..58f68eb3 100644 --- a/README.md +++ b/README.md @@ -350,13 +350,13 @@ a Swift package for working with structured data. It includes convenience initializers for types from Apple frameworks, such as those returned by iMCP tools. -### HomeKit Helper +### 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 Home`](/Home/), -bundled at `iMCP.app/Contents/Helpers/iMCP Home.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, @@ -378,18 +378,18 @@ 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-home.\_tcp" on the local-only DNS-SD interface instead. +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-home._tcp`. +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-home-helper.sh`](/Scripts/build-home-helper.sh), -which builds the `iMCP Home` scheme for Mac Catalyst in its own +[`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. @@ -400,9 +400,18 @@ 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. -If automatic provisioning doesn't include HomeKit at first, -enable the HomeKit capability for the iOS destination, -or build the helper once with `-allowProvisioningUpdates`. +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: @@ -410,7 +419,7 @@ 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 \ - HOME_HELPER_CODE_SIGNING_ALLOWED=NO build + HELPER_CODE_SIGNING_ALLOWED=NO build ``` ## Debugging diff --git a/Scripts/build-home-helper.sh b/Scripts/build-helper.sh similarity index 83% rename from Scripts/build-home-helper.sh rename to Scripts/build-helper.sh index 732e2dc7..f8e69600 100644 --- a/Scripts/build-home-helper.sh +++ b/Scripts/build-helper.sh @@ -8,24 +8,24 @@ set -euo pipefail : "${CONFIGURATION:?Missing Xcode configuration.}" : "${PROJECT_TEMP_DIR:?Missing Xcode intermediates directory.}" -helper_derived_data="$PROJECT_TEMP_DIR/HomeHelperDerivedData" -helper_signing="${HOME_HELPER_CODE_SIGNING_ALLOWED:-YES}" +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 Home" -configuration "$CONFIGURATION" \ + -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 Home.app" -helper_destination="$TARGET_BUILD_DIR/$WRAPPER_NAME/Contents/Helpers/iMCP Home.app" +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 Home helper build did not produce a Catalyst app.' >&2 + echo 'error: The iMCP Helper build did not produce a Catalyst app.' >&2 exit 1 fi mkdir -p "$(dirname "$helper_destination")" diff --git a/Scripts/check-home.py b/Scripts/check-home.py index 20d5afb2..31763ff6 100644 --- a/Scripts/check-home.py +++ b/Scripts/check-home.py @@ -2,7 +2,7 @@ # requires-python = ">=3.11" # dependencies = [] # /// -"""Check the running Home helper through imcp-server without changing the home.""" +"""Check the running iMCP Helper through imcp-server without changing the home.""" import argparse import json @@ -14,7 +14,7 @@ class HomeClient: - def __init__(self, server: Path, service_type: str = "_imcp-home._tcp"): + def __init__(self, server: Path, service_type: str = "_imcp-helper._tcp"): self.process = subprocess.Popen( [str(server.resolve())], stdin=subprocess.PIPE, @@ -30,7 +30,7 @@ def __init__(self, server: Path, service_type: str = "_imcp-home._tcp"): { "protocolVersion": "2025-11-25", "capabilities": {}, - "clientInfo": {"name": "iMCP Home checks", "version": "1.0"}, + "clientInfo": {"name": "iMCP Helper checks", "version": "1.0"}, }, ) self.send({"jsonrpc": "2.0", "method": "notifications/initialized"}) @@ -149,7 +149,7 @@ def main(): parser.add_argument( "server", type=Path, help="Path to the built imcp-server executable" ) - parser.add_argument("--service-type", default="_imcp-home._tcp") + parser.add_argument("--service-type", default="_imcp-helper._tcp") args = parser.parse_args() client = None try: diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index 138ba7ce..630fb3c8 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -91,7 +91,7 @@ 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 Home.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; path = "iMCP Home.app"; 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 = ""; }; @@ -114,7 +114,7 @@ ); target = F8F44E6C2D59038D0075D79C /* iMCP */; }; - A1000000000000000000000B = {isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = (Info.plist, Home.entitlements, ); target = A10000000000000000000003; }; + A1000000000000000000000B = {isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = (Info.plist, Helper.entitlements, ); target = A10000000000000000000003; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -131,7 +131,7 @@ path = CLI; sourceTree = ""; }; - A10000000000000000000002 /* Home */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A1000000000000000000000B, ); path = Home; sourceTree = ""; }; + A10000000000000000000002 /* Helper */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A1000000000000000000000B, ); path = Helper; sourceTree = ""; }; A20000000000000000000001 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; path = Shared; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -225,7 +225,7 @@ isa = PBXGroup; children = ( F8F44E6F2D59038D0075D79C /* App */, - A10000000000000000000002 /* Home */, + A10000000000000000000002 /* Helper */, A20000000000000000000001 /* Shared */, F8F44EB72D5908D00075D79C /* CLI */, F8F44E6E2D59038D0075D79C /* Products */, @@ -238,7 +238,7 @@ isa = PBXGroup; children = ( F8F44E6D2D59038D0075D79C /* iMCP.app */, - A10000000000000000000001 /* iMCP Home.app */, + A10000000000000000000001 /* iMCP Helper.app */, F8F44EB62D5908D00075D79C /* imcp-server */, 6A5CFBBEC66BC8CDA9D65496 /* imcp-serverTests.xctest */, ); @@ -333,16 +333,16 @@ productReference = F8F44EB62D5908D00075D79C /* imcp-server */; productType = "com.apple.product-type.tool"; }; - A10000000000000000000003 /* iMCP Home */ = { + A10000000000000000000003 /* iMCP Helper */ = { isa = PBXNativeTarget; buildConfigurationList = A10000000000000000000004; buildPhases = (A10000000000000000000005, A10000000000000000000006, A10000000000000000000007, ); buildRules = (); dependencies = (); fileSystemSynchronizedGroups = (A10000000000000000000002, A20000000000000000000001, ); - name = "iMCP Home"; + name = "iMCP Helper"; packageProductDependencies = (A20000000000000000000010, A20000000000000000000011, A20000000000000000000012, A20000000000000000000013, ); - productName = "iMCP Home"; + productName = "iMCP Helper"; productReference = A10000000000000000000001; productType = "com.apple.product-type.application"; }; @@ -393,7 +393,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( - A10000000000000000000003 /* iMCP Home */, + A10000000000000000000003 /* iMCP Helper */, F8F44E6C2D59038D0075D79C /* iMCP */, F8F44EB52D5908D00075D79C /* imcp-server */, 8F6A71ADC0CC6BD168A9167D /* imcp-serverTests */, @@ -430,7 +430,7 @@ name = "Build and Embed Home Helper"; runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/bash; - shellScript = "bash \"$SRCROOT/Scripts/build-home-helper.sh\""; + shellScript = "bash \"$SRCROOT/Scripts/build-helper.sh\""; }; /* End PBXShellScriptBuildPhase section */ @@ -635,7 +635,7 @@ F8F44E932D59038E0075D79C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - HOME_HELPER_CODE_SIGNING_ALLOWED = YES; + HELPER_CODE_SIGNING_ALLOWED = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -704,7 +704,7 @@ F8F44E942D59038E0075D79C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - HOME_HELPER_CODE_SIGNING_ALLOWED = YES; + HELPER_CODE_SIGNING_ALLOWED = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -809,7 +809,7 @@ A10000000000000000000008 = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_ENTITLEMENTS = Home/Home.entitlements; + CODE_SIGN_ENTITLEMENTS = Helper/Helper.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CODE_SIGNING_ALLOWED = YES; @@ -819,9 +819,9 @@ ENABLE_APP_SANDBOX = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Home/Info.plist; + 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 its Home helper on this Mac."; + 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; @@ -829,7 +829,7 @@ 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.Home; + PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP.Helper; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -844,7 +844,7 @@ A10000000000000000000009 = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_ENTITLEMENTS = Home/Home.entitlements; + CODE_SIGN_ENTITLEMENTS = Helper/Helper.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CODE_SIGNING_ALLOWED = YES; @@ -854,9 +854,9 @@ ENABLE_APP_SANDBOX = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Home/Info.plist; + 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 its Home helper on this Mac."; + 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; @@ -864,7 +864,7 @@ 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.Home; + PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP.Helper; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Home.xcscheme b/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme similarity index 90% rename from iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Home.xcscheme rename to iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme index 3c070eec..ffa048cf 100644 --- a/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Home.xcscheme +++ b/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme @@ -16,8 +16,8 @@ @@ -45,8 +45,8 @@ @@ -62,8 +62,8 @@ From 346baf40ad019216f9e0e480117546f3d32f2279 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Wed, 16 Sep 2026 05:48:39 -0700 Subject: [PATCH 6/9] Resolve an accessory's home by membership so the helper builds with the Xcode 26 SDK --- Helper/Services/HomeKitBackend.swift | 15 ++++++++------- Helper/Services/HomeKitStore.swift | 9 +++++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Helper/Services/HomeKitBackend.swift b/Helper/Services/HomeKitBackend.swift index 69f75d17..1fc165d3 100644 --- a/Helper/Services/HomeKitBackend.swift +++ b/Helper/Services/HomeKitBackend.swift @@ -67,7 +67,7 @@ final class HomeKitBackend: HomeBackend { case "accessories_assign_room": return try await assignRoom(args) case "accessories_remove": let accessory = try store.accessory(args.required("accessory")) - guard let home = accessory.home else { throw HomeError("The accessory has no home.") } + 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": @@ -147,7 +147,7 @@ final class HomeKitBackend: HomeBackend { for id in try args.ids("accessories", required: true) { do { let accessory = try store.accessory(id) - try requireSameHome(accessory.home, home) + try requireSameHome(store.home(containing: accessory), home) if accessory.room?.uniqueIdentifier != room.uniqueIdentifier { try await home.assignAccessory(accessory, to: room) } @@ -205,7 +205,7 @@ final class HomeKitBackend: HomeBackend { 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(characteristic.service?.accessory?.home, home) + try requireSameHome(store.home(containing: characteristic), home) guard seen.insert(characteristic.uniqueIdentifier).inserted else { throw HomeError("Actions must use distinct characteristic IDs.") } @@ -242,7 +242,7 @@ final class HomeKitBackend: HomeBackend { let remove = try Set( args.ids("remove_characteristics").map { id -> UUID in let characteristic = try store.characteristic(id) - try requireSameHome(characteristic.service?.accessory?.home, home) + try requireSameHome(store.home(containing: characteristic), home) return characteristic.uniqueIdentifier } ) @@ -332,7 +332,7 @@ final class HomeKitBackend: HomeBackend { } } else if let id = try triggerArgs.string("characteristic") { let characteristic = try store.characteristic(id) - try requireSameHome(characteristic.service?.accessory?.home, home) + try requireSameHome(store.home(containing: characteristic), home) let value = try HomeValue.decode(triggerArgs.value("value"), for: characteristic, writable: false) trigger = HMEventTrigger( name: name, @@ -420,13 +420,14 @@ extension HomeKitBackend { ) } func accessorySummary(_ accessory: HMAccessory) -> AccessorySummary { - let bridge = accessory.home?.accessories.first { + 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: accessory.home?.uniqueIdentifier.uuidString, + home: home?.uniqueIdentifier.uuidString, room: accessory.room?.uniqueIdentifier.uuidString, category: accessory.category.categoryType, categoryDescription: accessory.category.localizedDescription, diff --git a/Helper/Services/HomeKitStore.swift b/Helper/Services/HomeKitStore.swift index 7f147f85..b99487d9 100644 --- a/Helper/Services/HomeKitStore.swift +++ b/Helper/Services/HomeKitStore.swift @@ -71,6 +71,15 @@ final class HomeKitStore: NSObject, HMHomeManagerDelegate { 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 }) From dd0a30a47a833f29371fb91107cbe45712974b52 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Sun, 20 Sep 2026 08:43:45 -0700 Subject: [PATCH 7/9] Prevent Xcode debugger injection from crashing the HomeKit helper --- App/Services/Home.swift | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/App/Services/Home.swift b/App/Services/Home.swift index ed1da4d5..b66acd92 100644 --- a/App/Services/Home.swift +++ b/App/Services/Home.swift @@ -133,11 +133,18 @@ actor HomeService: Service, HomeBackend { } 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 launchedHelper != nil, Date() < deadline else { throw error } + 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 @@ -182,6 +189,18 @@ actor HomeService: Service, HomeBackend { // 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: { @@ -198,6 +217,13 @@ actor HomeService: Service, HomeBackend { 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( From 52e091ed59aa6a04a29537b231ad52380f0a9a39 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Sun, 20 Sep 2026 09:15:31 -0700 Subject: [PATCH 8/9] Update the helper scheme buildable name to iMCP Home --- iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme b/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme index ffa048cf..76da9dcf 100644 --- a/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme +++ b/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme @@ -16,7 +16,7 @@ @@ -45,7 +45,7 @@ @@ -62,7 +62,7 @@ From e30440729706b0aaf33bbe969756ed5f7a035a1d Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Sun, 20 Sep 2026 14:24:09 -0700 Subject: [PATCH 9/9] Restore the iMCP Helper buildable name in its scheme --- iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme b/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme index 76da9dcf..ffa048cf 100644 --- a/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme +++ b/iMCP.xcodeproj/xcshareddata/xcschemes/iMCP Helper.xcscheme @@ -16,7 +16,7 @@ @@ -45,7 +45,7 @@ @@ -62,7 +62,7 @@