From a1bec6b3fb1e14695a2a232732cc4da397b0f335 Mon Sep 17 00:00:00 2001 From: Oleksaner Date: Tue, 18 Aug 2026 20:52:20 +0200 Subject: [PATCH 01/56] Wire up Sparkle so the app actually checks for updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Info.plist has declared SUFeedURL/SUPublicEDKey (and the entitlements reserve Sparkle's -spks/-spki XPC mach ports) since day one, but no target ever linked the Sparkle package or instantiated an updater. The result: every install is permanently frozen at whatever version it first shipped, silently, with no error and no "Check for Updates" menu item to surface the problem. This is why users can stay on known-crashy builds indefinitely: my own installed copy was v1.4.0 (Jan 2026), three months and one release behind the fix for the Bonjour-disconnect crash in #166/1.4.1, with no way to find out short of reading raw Claude Desktop MCP logs. - Add the Sparkle SPM package to the iMCP app target. - Construct SPUStandardUpdaterController(startingUpdater: true, ...) in App.swift so Sparkle's normal periodic background check actually runs, using the existing SUFeedURL/SUPublicEDKey. - Add a "Check for Updates..." item to the menu bar dropdown, wired to updater.checkForUpdates() and disabled while a check is unavailable. Verified: package graph resolves, `xcodebuild build -scheme iMCP` succeeds, the built app launches and stays up with Sparkle.framework loaded, `imcp-serverTests` (incl. ServiceGroupConfigurationTests) still pass, and `swift format lint --strict --recursive .` is clean. Note: https://downloads.imcp.app/appcast.xml (the configured SUFeedURL) currently fails to resolve in DNS (NXDOMAIN), so this alone won't help until that feed is live — flagging separately in the PR description. --- App/App.swift | 14 +++++++++++++- App/Views/ContentView.swift | 11 ++++++++++- iMCP.xcodeproj/project.pbxproj | 17 +++++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 11 ++++++++++- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/App/App.swift b/App/App.swift index e395e546..e97b9b63 100644 --- a/App/App.swift +++ b/App/App.swift @@ -1,4 +1,5 @@ import MenuBarExtraAccess +import Sparkle import SwiftUI @main @@ -7,12 +8,23 @@ struct App: SwiftUI.App { @AppStorage("isEnabled") private var isEnabled = true @State private var isMenuPresented = false + // `startingUpdater: true` makes this the sole owner of update checking for + // the app's lifetime. Without it (or without ever constructing an updater + // at all), the SUFeedURL / SUPublicEDKey keys in Info.plist are inert and + // installs never learn about newer releases, no matter how long they run. + private let updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + var body: some Scene { MenuBarExtra("iMCP", image: #"MenuIcon-\#(isEnabled ? "On" : "Off")"#) { ContentView( serverManager: serverController, isEnabled: $isEnabled, - isMenuPresented: $isMenuPresented + isMenuPresented: $isMenuPresented, + updater: updaterController.updater ) } .menuBarExtraStyle(.window) diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index 8b6aa6ee..7dc08521 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -1,5 +1,6 @@ import AppKit import MenuBarExtraAccess +import Sparkle import SwiftUI struct ContentView: View { @@ -9,6 +10,7 @@ struct ContentView: View { @Environment(\.openSettings) private var openSettings private let aboutWindowController: AboutWindowController + private let updater: SPUUpdater private var serviceConfigs: [ServiceConfig] { serverController.computedServiceConfigs @@ -25,12 +27,14 @@ struct ContentView: View { init( serverManager: ServerController, isEnabled: Binding, - isMenuPresented: Binding + isMenuPresented: Binding, + updater: SPUUpdater ) { self.serverController = serverManager self._isEnabled = isEnabled self._isMenuPresented = isMenuPresented self.aboutWindowController = AboutWindowController() + self.updater = updater } var body: some View { @@ -104,6 +108,11 @@ struct ContentView: View { openSettings() } + MenuButton("Check for Updates...", isMenuPresented: $isMenuPresented) { + updater.checkForUpdates() + } + .disabled(!updater.canCheckForUpdates) + MenuButton("About iMCP", isMenuPresented: $isMenuPresented) { aboutWindowController.showWindow(nil) NSApp.activate(ignoringOtherApps: true) diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index 45b1bea1..756fba00 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ F825BDC12D9AD00E0063ADD7 /* UnixSignals in Frameworks */ = {isa = PBXBuildFile; productRef = F825BDC02D9AD00E0063ADD7 /* UnixSignals */; }; F873F48D2D712BCF0035CD0A /* Ontology in Frameworks */ = {isa = PBXBuildFile; productRef = F873F48C2D712BCF0035CD0A /* Ontology */; }; F87796FC2E0764AE00328CC6 /* MenuBarExtraAccess in Frameworks */ = {isa = PBXBuildFile; productRef = F87796FB2E0764AE00328CC6 /* MenuBarExtraAccess */; }; + 158948D22D0E610E83A4433C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B75D49165A453E912E36DBFE /* Sparkle */; }; F88358372D64A085000317CD /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = F88358362D64A085000317CD /* Logging */; }; F885C6B82D66079100963B25 /* imcp-server in Copy Executables */ = {isa = PBXBuildFile; fileRef = F8F44EB62D5908D00075D79C /* imcp-server */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; F8A7DE3E2DCB76F700530587 /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = F8A7DE3D2DCB76F700530587 /* MCP */; }; @@ -103,6 +104,7 @@ files = ( F809556E2D7888920055B911 /* TypedStream in Frameworks */, F87796FC2E0764AE00328CC6 /* MenuBarExtraAccess in Frameworks */, + 158948D22D0E610E83A4433C /* Sparkle in Frameworks */, F873F48D2D712BCF0035CD0A /* Ontology in Frameworks */, F8D7C31A2DCBD32100A4775F /* MCP in Frameworks */, F8A7DE3E2DCB76F700530587 /* MCP in Frameworks */, @@ -224,6 +226,7 @@ F8D7C3192DCBD32100A4775F /* MCP */, F8D8C48D2DCE0E6800369E5C /* JSONSchema */, F87796FB2E0764AE00328CC6 /* MenuBarExtraAccess */, + B75D49165A453E912E36DBFE /* Sparkle */, ); productName = iMCP; productReference = F8F44E6D2D59038D0075D79C /* iMCP.app */; @@ -290,6 +293,7 @@ F8D7C3182DCBD32100A4775F /* XCRemoteSwiftPackageReference "swift-sdk" */, F8D8C48C2DCE0E6800369E5C /* XCRemoteSwiftPackageReference "JSONSchema" */, F87796FA2E0764AE00328CC6 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */, + 26120478122FBFCB2B073DE5 /* XCRemoteSwiftPackageReference "Sparkle" */, ); preferredProjectObjectVersion = 77; productRefGroup = F8F44E6E2D59038D0075D79C /* Products */; @@ -768,6 +772,14 @@ minimumVersion = 1.3.0; }; }; + 26120478122FBFCB2B073DE5 /* XCRemoteSwiftPackageReference "Sparkle" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sparkle-project/Sparkle"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.6.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -847,6 +859,11 @@ isa = XCSwiftPackageProductDependency; productName = MCP; }; + B75D49165A453E912E36DBFE /* Sparkle */ = { + isa = XCSwiftPackageProductDependency; + package = 26120478122FBFCB2B073DE5 /* XCRemoteSwiftPackageReference "Sparkle" */; + productName = Sparkle; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = F8F44E652D59038D0075D79C /* Project object */; diff --git a/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0e03dd92..cb00a290 100644 --- a/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0604c8a8c049fb6e696fcd8af68cdc36f2f23f938eec962e31f24cc19d550d45", + "originHash" : "ef84066429fbd998da047140f11ad84f410b82811512bb502cfd69ad588f22d1", "pins" : [ { "identity" : "eventsource", @@ -46,6 +46,15 @@ "version" : "0.8.0" } }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a", + "version" : "2.9.6" + } + }, { "identity" : "swift-async-algorithms", "kind" : "remoteSourceControl", From 9436cfc43fce539b533e6672054ff84856e34e3b Mon Sep 17 00:00:00 2001 From: Adam Van Prooyen Date: Wed, 2 Sep 2026 11:50:45 -0700 Subject: [PATCH 02/56] Fix SIGTRAP crash from continuation misuse in connection handling (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP SDK's NetworkTransport has reconnection logic that creates unstructured tasks holding CheckedContinuation references. For server-side incoming connections, this reconnection path can cause double-resume when connections are cancelled, crashing the app. Disable reconnection and heartbeats for server-accepted connections since they are client features — if a client disconnects, it should reconnect itself. Also make removeConnection idempotent to prevent stop() being called twice on the same connection. Co-authored-by: Claude Opus 4.6 (1M context) From 6f90737a4ac8307c47ebe7451b620027a9716c67 Mon Sep 17 00:00:00 2001 From: Mattt Date: Wed, 2 Sep 2026 12:25:10 -0700 Subject: [PATCH 03/56] Cancel the Bonjour browser on every discovery exit path (#199) * Cancel the Bonjour browser on every discovery exit path imcp-server retries discovery in a loop, creating a new NWBrowser each time. The 30-second timeout path never cancelled the previous browser, so every timed-out attempt left a DNSServiceBrowse connection to mDNSResponder open. On a machine where the app wasn't running, that leaked one connection every ~35 seconds until the daemon ran out of descriptors and DNS failed for every process. Move discovery into BonjourDiscovery.discoverEndpoint, which cancels the browser in a defer, and share the file with the CLI test target so the behavior is covered by a test against a real NWBrowser. Also drop a module-level browser that was never started and a write-only property. Fixes #192. Fixes #174. * Use a valid absent service type and require the timeout error in the test --- CLI/BonjourDiscovery.swift | 73 ++++++++++++++++ CLI/main.swift | 121 ++++----------------------- CLITests/BonjourDiscoveryTests.swift | 49 +++++++++++ iMCP.xcodeproj/project.pbxproj | 8 ++ 4 files changed, 144 insertions(+), 107 deletions(-) create mode 100644 CLI/BonjourDiscovery.swift create mode 100644 CLITests/BonjourDiscoveryTests.swift diff --git a/CLI/BonjourDiscovery.swift b/CLI/BonjourDiscovery.swift new file mode 100644 index 00000000..b3cf0fea --- /dev/null +++ b/CLI/BonjourDiscovery.swift @@ -0,0 +1,73 @@ +import Network + +/// Guards a continuation so it is resumed exactly once. +actor ConnectionState { + private var hasResumed = false + + func checkAndSetResumed() -> Bool { + if !hasResumed { + hasResumed = true + return true + } + return false + } +} + +/// Finds a Bonjour endpoint with a browser, +/// and guarantees the browser is cancelled afterwards. +/// +/// Every `NWBrowser` that has been started holds a `DNSServiceBrowse` connection +/// to `mDNSResponder` until it is cancelled. +/// `imcp-server` retries discovery in a loop, +/// so a browser left running on any exit path leaks one connection per attempt (#192). +enum BonjourDiscovery { + enum Error: Swift.Error { + case timeout + } + + /// Starts `browser` on the main queue + /// and returns the first result that satisfies `preferring`, + /// or the first result at all if none does. + /// + /// The browser is cancelled before this function returns or throws, + /// whether a result was found, the browser failed, or `timeout` elapsed. + static func discoverEndpoint( + using browser: NWBrowser, + timeout: Duration, + preferring isPreferred: @escaping @Sendable (NWBrowser.Result) -> Bool + ) async throws -> NWEndpoint { + defer { browser.cancel() } + + let state = ConnectionState() + return try await withCheckedThrowingContinuation { continuation in + let timeoutTask = Task { + try await Task.sleep(for: timeout) + if await state.checkAndSetResumed() { + continuation.resume(throwing: Error.timeout) + } + } + + browser.stateUpdateHandler = { browserState in + guard case .failed(let error) = browserState else { return } + Task { + if await state.checkAndSetResumed() { + timeoutTask.cancel() + continuation.resume(throwing: error) + } + } + } + + browser.browseResultsChangedHandler = { results, _ in + guard let selected = results.first(where: isPreferred) ?? results.first else { return } + Task { + if await state.checkAndSetResumed() { + timeoutTask.cancel() + continuation.resume(returning: selected.endpoint) + } + } + } + + browser.start(queue: .main) + } + } +} diff --git a/CLI/main.swift b/CLI/main.swift index face9c92..7c21e35d 100644 --- a/CLI/main.swift +++ b/CLI/main.swift @@ -24,25 +24,6 @@ if let tcpOptions = parameters.defaultProtocolStack.internetProtocol as? NWProto tcpOptions.version = .v4 } -// Create browser at top level -let browser = NWBrowser( - for: .bonjour(type: serviceType, domain: nil), - using: parameters -) -log.info("Created Bonjour browser for service type: \(serviceType)") - -actor ConnectionState { - private var hasResumed = false - - func checkAndSetResumed() -> Bool { - if !hasResumed { - hasResumed = true - return true - } - return false - } -} - /// An actor that provides a configurable proxy between standard I/O and network connections actor StdioProxy { // Connection configuration @@ -471,7 +452,6 @@ enum StdioProxyError: Swift.Error { // Create MCPService class to manage lifecycle actor MCPService: Service { - private var browser: NWBrowser? private var currentProxy: StdioProxy? func run() async throws { @@ -483,95 +463,22 @@ actor MCPService: Service { for: .bonjour(type: serviceType, domain: nil), using: parameters ) - self.browser = browser - - // Find and connect to iMCP app with improved reliability - let endpoint: NWEndpoint = try await withCheckedThrowingContinuation { - continuation in - let connectionState = ConnectionState() - - // Set up a timeout task to ensure we don't wait forever - let timeoutTask = Task { - // Allow 30 seconds to find the service - try await Task.sleep(for: .seconds(30)) - - // If we haven't found a service by now, resume with an error - if await connectionState.checkAndSetResumed() { - await log.error("Bonjour service discovery timed out after 30 seconds") - continuation.resume( - throwing: MCPError.internalError("Service discovery timeout") - ) - } - } - - // Convert async handlers to sync handlers - browser.stateUpdateHandler = { state in - Task { - switch state { - case .failed(let error): - await log.error("Browser failed: \(error)") - if await connectionState.checkAndSetResumed() { - timeoutTask.cancel() - browser.cancel() - continuation.resume(throwing: error) - } - case .ready: - await log.info("Browser is ready and searching for services") - case .waiting(let error): - await log.warning("Browser is waiting: \(error)") - default: - await log.debug("Browser state changed: \(state)") - } - } - } - browser.browseResultsChangedHandler = { results, changes in - Task { - await log.debug("Found \(results.count) Bonjour services") - - // Log all discovered services for debugging - for (index, result) in results.enumerated() { - await log.debug("Service \(index + 1): \(result.endpoint)") - } - - // If we have results, select the most appropriate one - if !results.isEmpty { - // First, try to find a service with "iMCP" in the endpoint description - let imcpServices = results.filter { - String(describing: $0.endpoint).contains("iMCP") - } - - let selectedService: NWBrowser.Result - - if !imcpServices.isEmpty { - // Prefer services with iMCP in the description - selectedService = imcpServices.first! - await log.info( - "Selected iMCP service: \(selectedService.endpoint)" - ) - } else { - // Fall back to the first available service - selectedService = results.first! - await log.info( - "No specific iMCP service found, using: \(selectedService.endpoint)" - ) - } - - if await connectionState.checkAndSetResumed() { - timeoutTask.cancel() - browser.cancel() - await log.info("Selected endpoint: \(selectedService.endpoint)") - continuation.resume(returning: selectedService.endpoint) - } - } - } - } - - Task { - await log.info("Starting Bonjour browser to discover MCP services...") - } - browser.start(queue: .main) + // Prefer a service advertised as iMCP; fall back to any MCP service. + // The helper cancels the browser on every exit path, + // so a timed-out attempt doesn't leak a DNS-SD connection (#192). + let endpoint: NWEndpoint + do { + endpoint = try await BonjourDiscovery.discoverEndpoint( + using: browser, + timeout: .seconds(30), + preferring: { String(describing: $0.endpoint).contains("iMCP") } + ) + } catch BonjourDiscovery.Error.timeout { + await log.error("Bonjour service discovery timed out after 30 seconds") + throw MCPError.internalError("Service discovery timeout") } + await log.info("Selected endpoint: \(endpoint)") await log.info("Creating connection to endpoint...") diff --git a/CLITests/BonjourDiscoveryTests.swift b/CLITests/BonjourDiscoveryTests.swift new file mode 100644 index 00000000..0f748294 --- /dev/null +++ b/CLITests/BonjourDiscoveryTests.swift @@ -0,0 +1,49 @@ +import Network +import XCTest + +/// Regression tests for `BonjourDiscovery`. +/// +/// `imcp-server` retries discovery in a loop. +/// Every browser it starts must be cancelled before the next attempt, +/// or each one keeps a `DNSServiceBrowse` connection to `mDNSResponder` open. +/// Leaking one per attempt eventually exhausts the daemon's descriptors +/// and breaks DNS for every process on the machine (#192). +final class BonjourDiscoveryTests: XCTestCase { + + /// A browser that times out without finding anything must be cancelled, + /// not left running. + func testTimedOutBrowserIsCancelled() async throws { + // Bonjour service names are limited to 15 characters, + // so keep this one valid; an invalid type fails the browser immediately + // instead of exercising the timeout path. + let browser = NWBrowser( + for: .bonjour(type: "_imcp-absent._tcp", domain: nil), + using: .tcp + ) + + do { + _ = try await BonjourDiscovery.discoverEndpoint( + using: browser, + timeout: .milliseconds(500), + preferring: { _ in true } + ) + XCTFail("Expected discovery to time out") + } catch BonjourDiscovery.Error.timeout { + // Expected: nothing advertises this type, so only the timeout can end discovery. + } catch { + XCTFail("Expected the timeout error, got \(error)") + } + + let cancelled = await waitForCancellation(of: browser) + XCTAssertTrue(cancelled, "Browser was left running after timeout") + } + + /// Cancellation is reported asynchronously, so poll briefly for it. + private func waitForCancellation(of browser: NWBrowser, attempts: Int = 40) async -> Bool { + for _ in 0 ..< attempts { + if case .cancelled = browser.state { return true } + try? await Task.sleep(for: .milliseconds(50)) + } + return false + } +} diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index 45b1bea1..ab838386 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 826C0834A5B647F8A918E54D /* ServiceLifecycle in Frameworks */ = {isa = PBXBuildFile; productRef = DCE061F8A68D0CD546D919E2 /* ServiceLifecycle */; }; 8DFCE9E4CBCBEB93A32E8560 /* ServiceLifecycleTestKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1CD6B9E157EEC273F178543D /* ServiceLifecycleTestKit */; }; 930214FCD680D7D74158F215 /* ServiceGroupConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75E148EAC5AC8B3263C5AA13 /* ServiceGroupConfigurationTests.swift */; }; + B0D1DEADBEEF000000000002 /* BonjourDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF000000000001 /* BonjourDiscovery.swift */; }; + B0D1DEADBEEF000000000004 /* BonjourDiscoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF000000000003 /* BonjourDiscoveryTests.swift */; }; F809556E2D7888920055B911 /* TypedStream in Frameworks */ = {isa = PBXBuildFile; productRef = F809556D2D7888920055B911 /* TypedStream */; }; F80955702D7888920055B911 /* iMessage in Frameworks */ = {isa = PBXBuildFile; productRef = F809556F2D7888920055B911 /* iMessage */; }; F825BDBF2D9AD00E0063ADD7 /* ServiceLifecycle in Frameworks */ = {isa = PBXBuildFile; productRef = F825BDBE2D9AD00E0063ADD7 /* ServiceLifecycle */; }; @@ -54,6 +56,8 @@ /* Begin PBXFileReference section */ 6A5CFBBEC66BC8CDA9D65496 /* imcp-serverTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "imcp-serverTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 75E148EAC5AC8B3263C5AA13 /* ServiceGroupConfigurationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ServiceGroupConfigurationTests.swift; sourceTree = ""; }; + B0D1DEADBEEF000000000001 /* BonjourDiscovery.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BonjourDiscovery.swift; path = ../CLI/BonjourDiscovery.swift; sourceTree = ""; }; + B0D1DEADBEEF000000000003 /* BonjourDiscoveryTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BonjourDiscoveryTests.swift; sourceTree = ""; }; 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; }; @@ -139,6 +143,8 @@ isa = PBXGroup; children = ( 75E148EAC5AC8B3263C5AA13 /* ServiceGroupConfigurationTests.swift */, + B0D1DEADBEEF000000000003 /* BonjourDiscoveryTests.swift */, + B0D1DEADBEEF000000000001 /* BonjourDiscovery.swift */, ); path = CLITests; sourceTree = ""; @@ -326,6 +332,8 @@ buildActionMask = 2147483647; files = ( 930214FCD680D7D74158F215 /* ServiceGroupConfigurationTests.swift in Sources */, + B0D1DEADBEEF000000000004 /* BonjourDiscoveryTests.swift in Sources */, + B0D1DEADBEEF000000000002 /* BonjourDiscovery.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 4e646f87a76216bf65e02864184ec6c5f152acea Mon Sep 17 00:00:00 2001 From: Mattt Date: Wed, 2 Sep 2026 12:25:20 -0700 Subject: [PATCH 04/56] Exempt connections awaiting approval from the setup timeout (#200) * Exempt connections awaiting approval from the setup timeout handleNewConnection races connection setup against a 10-second stall timer, but setup includes waiting for the user to answer the approval dialog. A first connection from a new client was therefore torn down before anyone could click Allow, which is why the first attempt always failed and the retry (now trusted) succeeded. Track connections whose setup is blocked on the approval handler and skip them in the timeout check. The handshake before the prompt is still covered, and a denied dialog still closes the connection. Fixes #193. * Pause the setup timer during approval and restart it afterwards A one-shot timer that merely skipped connections awaiting approval could still close a connection approved just before the deadline, and stopped enforcing anything once approval finished. Keep a timer per connection: cancel it when the approval handler starts, give the connection a fresh budget when approval returns, and clear it when setup ends. --- App/Controllers/ServerController.swift | 31 ++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 12bc2a61..20b7ee96 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -628,6 +628,11 @@ actor ServerNetworkManager { private var discoveryManager: NetworkDiscoveryManager? private var connections: [UUID: MCPConnectionManager] = [:] private var connectionTasks: [UUID: Task] = [:] + /// Setup timers, keyed by connection. + /// A timer is cancelled while the user is deciding on the approval dialog + /// and started again with a fresh budget once they have, + /// so human deliberation never counts as a stalled handshake (#193). + private var setupTimeoutTasks: [UUID: Task] = [:] private var pendingConnections: [UUID: String] = [:] private var removedConnections: Set = [] @@ -814,6 +819,7 @@ actor ServerNetworkManager { // Ensure this task is removed so the timeout logic doesn't fire afterward. defer { self.connectionTasks.removeValue(forKey: connectionID) + self.cancelSetupTimeout(for: connectionID) } do { @@ -824,7 +830,15 @@ actor ServerNetworkManager { } try await connectionManager.start { clientInfo in - await approvalHandler(connectionID, clientInfo) + // From here the wait is on a person, not the client: + // stop the setup timer for the dialog + // and restart it for the rest of the handshake afterwards. + self.cancelSetupTimeout(for: connectionID) + let approved = await approvalHandler(connectionID, clientInfo) + if approved { + self.scheduleSetupTimeout(for: connectionID) + } + return approved } log.notice("Connection \(connectionID) successfully established") @@ -837,8 +851,17 @@ actor ServerNetworkManager { connectionTasks[connectionID] = task // Time out stalled setups to avoid orphaned connections. - Task { + scheduleSetupTimeout(for: connectionID) + } + + /// Gives the connection a fresh setup budget. + /// Any previous timer for it is replaced. + private func scheduleSetupTimeout(for connectionID: UUID) { + setupTimeoutTasks[connectionID]?.cancel() + setupTimeoutTasks[connectionID] = Task { try? await Task.sleep(nanoseconds: 10_000_000_000) // 10 seconds + guard !Task.isCancelled else { return } + self.setupTimeoutTasks.removeValue(forKey: connectionID) // If the setup task is still registered, treat it as timed out. if self.connectionTasks[connectionID] != nil, @@ -852,6 +875,10 @@ actor ServerNetworkManager { } } + private func cancelSetupTimeout(for connectionID: UUID) { + setupTimeoutTasks.removeValue(forKey: connectionID)?.cancel() + } + func registerHandlers(for server: MCP.Server, connectionID: UUID) async { await server.withMethodHandler(ListPrompts.self) { _ in log.debug("Handling ListPrompts request for \(connectionID)") From 45e024b88a251c6c9e7226e97f992c205d2d244c Mon Sep 17 00:00:00 2001 From: Ole Lilienthal Date: Wed, 2 Sep 2026 21:25:42 +0200 Subject: [PATCH 05/56] Fix events_fetch: date-only inputs silently ignored; missing @id on events (#175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Set @id on calendar events from EKEvent.eventIdentifier Without an identifier, Event values produced from EKEvent encoded as JSON-LD blank nodes. Two events with the same name/dates/calendar serialised to byte-identical objects, and downstream JSON-LD-aware consumers collapsed them into a single node. Symptom: events_fetch appeared to silently filter out duplicate-named events. Populate Event.identifier from EKEvent.eventIdentifier in both events_fetch and events_create so each event carries a stable @id, matching what Ontology already does for Person (CNContact.identifier) and ItemList (EKCalendar.calendarIdentifier). * Fix date-only inputs being misread as having a timezone suffix ISO8601DateFormatter.lenientDate(fromISO8601String:) ran a regex to decide whether the input already carried a timezone offset and thus should not be re-parsed in local time. The regex #"([Zz]|[+-]\\d{2}(:?\\d{2})?)$"# matches the trailing -DD of any bare yyyy-MM-dd date — "2026-06-15" looks like it ends with a -15 offset — so date-only inputs returned nil instead of falling through to the "yyyy-MM-dd" DateFormatter fallback. In events_fetch this manifested as a silent regression: when the caller passed e.g. start="2026-06-02", end="2026-06-15", parsing failed for both, hasStart/hasEnd stayed false, and the tool fell back to its 'now → now + 1 week' default range. The user saw events from the current week and assumed events past that point had been filtered out (in this case two same-name events where only the earlier one was in the default range). Gate the timezone-suffix check on dateString.count > 10 so it can't fire on a bare yyyy-MM-dd. The day component can no longer collide with the offset branch of the regex; date-only inputs now reach the "yyyy-MM-dd" fallback as intended. --- App/Extensions/Foundation+Extensions.swift | 10 ++++++++-- App/Services/Calendar.swift | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/App/Extensions/Foundation+Extensions.swift b/App/Extensions/Foundation+Extensions.swift index 53f3121b..2989b823 100644 --- a/App/Extensions/Foundation+Extensions.swift +++ b/App/Extensions/Foundation+Extensions.swift @@ -36,9 +36,15 @@ extension ISO8601DateFormatter { } } - // If the string already includes a timezone, don't guess with local-time parsing. + // If the string already includes a timezone suffix on a *time* component, + // don't guess with local-time parsing. We must NOT run this check on a + // bare `yyyy-MM-dd` because the trailing `-DD` would match the + // `[+-]\d{2}` branch of this regex and falsely classify the day as a + // timezone offset, causing the parser to bail before trying the + // `yyyy-MM-dd` fallback below. let hasTimeZoneInfo = - dateString.range( + dateString.count > 10 + && dateString.range( of: #"([Zz]|[+-]\d{2}(:?\d{2})?)$"#, options: .regularExpression ) != nil diff --git a/App/Services/Calendar.swift b/App/Services/Calendar.swift index 1a58890d..161c084a 100644 --- a/App/Services/Calendar.swift +++ b/App/Services/Calendar.swift @@ -216,7 +216,11 @@ final class CalendarService: Service { events = events.filter { ($0.hasRecurrenceRules) == isRecurring } } - return events.map { Event($0) } + return events.map { event -> Event in + var e = Event(event) + e.identifier = event.eventIdentifier + return e + } } Tool( name: "events_create", @@ -533,7 +537,9 @@ final class CalendarService: Service { // Save the event try self.eventStore.save(event, span: .thisEvent) - return Event(event) + var result = Event(event) + result.identifier = event.eventIdentifier + return result } } } From 077ff3850126d884f49d320ecc171b685129960e Mon Sep 17 00:00:00 2001 From: Mattt Date: Wed, 2 Sep 2026 12:26:02 -0700 Subject: [PATCH 06/56] Bump Madrid to 0.5.0 and adopt FetchRequest (#201) Madrid 0.4.0 deprecated fetchMessages(with:in:limit:), and the project treats warnings as errors, so any bump past 0.1.0 failed the build at the call in Messages.swift. Move that call to fetch(_:) with an equivalent predicate, and pin 0.5.0, which reads the write-ahead log and exposes Message.chatID for the follow-up Messages PRs. --- App/Services/Messages.swift | 17 +++++++++++++---- iMCP.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index 7a517bef..16841022 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -128,11 +128,20 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { log.debug( "Fetching messages with date range: \(String(describing: dateRange)), limit: \(limit ?? -1)" ) - for message in try db.fetchMessages( - with: Set(handles), - in: dateRange, + // Match the old fetchMessages(with:in:limit:) semantics: + // no participant filter when no handles matched. + var predicates: [MessagePredicate] = [] + if !handles.isEmpty { + predicates.append(.participantHandles(Set(handles))) + } + if let dateRange { + predicates.append(.dateRange(dateRange)) + } + let request = FetchRequest( + predicate: .and(predicates), limit: max(limit ?? defaultLimit, 1024) - ) { + ) + for message in try db.fetch(request) { guard messages.count < (limit ?? defaultLimit) else { break } guard !message.text.isEmpty else { continue } diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index ab838386..e633217d 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -725,7 +725,7 @@ repositoryURL = "https://github.com/loopwork-ai/madrid"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.1.0; + minimumVersion = 0.5.0; }; }; F825BDBD2D9AD00E0063ADD7 /* XCRemoteSwiftPackageReference "swift-service-lifecycle" */ = { diff --git a/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0e03dd92..631b6854 100644 --- a/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/loopwork-ai/madrid", "state" : { - "revision" : "9d9fdb20424483fb592e5a026d9d0816cd5c43eb", - "version" : "0.1.0" + "revision" : "11ec80cb67700e47948f90cef6de38bce4dbfb12", + "version" : "0.5.0" } }, { From 91fc1215e4755370e9cb3de18ba53eadc2ab1c82 Mon Sep 17 00:00:00 2001 From: Seb Pretzer <24555985+sebpretzer@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:27:09 -0500 Subject: [PATCH 07/56] Add read status to messages_fetch (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump Madrid to 0.4.0 iMCP has been pinned to Madrid 0.1.0. Madrid has since shipped 0.2.0, 0.3.0, and 0.4.0. This bumps the pin to 0.4.0 with no behaviour change, so that later work can use APIs added in those releases (notably `Message.readAt` / `Message.isRead` from 0.3.0, for #154). The version requirement was already `upToNextMajorVersion` from 0.1.0, which 0.4.0 satisfies, so this is mechanically a re-resolve. The declared minimum is raised alongside the lockfile to keep the two in agreement. Source compatibility was verified by compiling iMCP's Madrid call sites (`Database()`, `Database(path:)`, `fetchParticipant(matching:)`, `fetchMessages(with:in:limit:)`, and the `Message` properties read in `messages_fetch`) against 0.4.0. They compile unchanged. Behaviour was checked against 0.1.0 for the two cases that could have differed, and matches in both: - With no participants, 0.1.0 skipped the participant condition. In 0.4.0 the deprecated `fetchMessages` shim likewise omits the predicate for an empty set, and `.and([])` compiles to an absent WHERE clause rather than a false one. - Message ordering was `ORDER BY m.date DESC` in 0.1.0, and the 0.4.0 default sort is `[.date(.descending), .id(.descending)]` — the same order, with an added tiebreak. Co-Authored-By: Claude Opus 5 * Add read status to messages_fetch Closes #154. Adds an optional `isRead` filter to `messages_fetch`, and reports read status on every message it returns: - `isRead` (input, optional): if true, fetch read messages; if false, unread incoming; if omitted, fetch all. - `isRead` (output): present on every message. - `dateRead` (output): ISO 8601, omitted when the message is unread. The tri-state input mirrors `reminders_fetch`'s `completed` parameter rather than introducing a single-purpose `unreadOnly` flag, so the same parameter also expresses "only messages I have read". Filtering for unread excludes outgoing messages. Read receipts populate a read date on messages you sent, so without that guard "unread" would also report your own messages the recipient has not read yet. Read status is derived from Madrid's `Message.readAt`, which is the read timestamp `chat.db` records rather than a dedicated unread flag. A message marked read without a recorded timestamp therefore counts as unread, so this is a superset of genuinely-unread and may not match the Messages app badge exactly. Known limitation: `messages_fetch` fetches `max(limit, 1024)` candidates and filters them down to `limit`, so the filter only sees the most recent 1024 messages. A large *read* backlog can push unread messages outside that window. Filtering in SQL instead would remove the limitation, but Madrid has no read-status predicate to compose; mattt/Madrid#17 proposes one. The tool's schema would not change if the filtering later moves into the query. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: mattt --- App/Services/Messages.swift | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index 16841022..d53bdf52 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -72,6 +72,9 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { "query": .string( description: "Search term to filter messages by content" ), + "isRead": .boolean( + description: "If true, fetch read messages; if false, unread incoming; if omitted, fetch all" + ), "limit": .integer( description: "Maximum messages to return", default: .int(defaultLimit) @@ -117,6 +120,7 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } let searchTerm = arguments["query"]?.stringValue + let isReadFilter = arguments["isRead"]?.boolValue let limit = arguments["limit"]?.intValue let db = try self.createDatabaseConnection() @@ -145,6 +149,14 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { guard messages.count < (limit ?? defaultLimit) else { break } guard !message.text.isEmpty else { continue } + if let isReadFilter { + if isReadFilter { + guard message.isRead else { continue } + } else { + guard !message.isFromMe, !message.isRead else { continue } + } + } + let sender: String if message.isFromMe { sender = "me" @@ -160,14 +172,20 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } } - messages.append([ + var object: [String: Value] = [ "@id": .string(message.id.description), "sender": [ "@id": .string(sender) ], "text": .string(message.text), "createdAt": .string(message.date.formatted(.iso8601)), - ]) + "isRead": .bool(message.isRead), + ] + if let readAt = message.readAt { + object["dateRead"] = .string(readAt.formatted(.iso8601)) + } + + messages.append(object) } log.debug("Successfully fetched \(messages.count) messages") From 0c67edfcdc49d9ec57893c39387ee1619b14f59a Mon Sep 17 00:00:00 2001 From: Oleksandr Lazariev Date: Fri, 4 Sep 2026 00:06:01 +0200 Subject: [PATCH 08/56] Play confirmation sound when copying server command (#181) "Copy server command to clipboard" silently copies with no feedback, so there's no way to tell the click registered. Play the existing system Pop sound cue on copy, matching the confirmation pattern already used elsewhere in the app. Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV Co-authored-by: Claude Sonnet 5 --- App/Views/ContentView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index 8b6aa6ee..f0028e60 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -91,6 +91,8 @@ struct ContentView: View { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(command, forType: .string) + + _ = NSSound.play(.pop) } } .padding(.top, 8) From 31650694d842b5644eb12663d2488ad06313d8aa Mon Sep 17 00:00:00 2001 From: Oleksandr Lazariev Date: Fri, 4 Sep 2026 00:06:24 +0200 Subject: [PATCH 09/56] Add accessibility labels to service toggle buttons (#182) VoiceOver announced each service's icon and name as two separate, unlabeled elements with no indication of on/off state. Add an accessibility label and value so each toggle reads as e.g. "Calendar, Enabled". Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV Co-authored-by: Claude Sonnet 5 --- App/Views/ServiceToggleView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/App/Views/ServiceToggleView.swift b/App/Views/ServiceToggleView.swift index a0ca7429..1db08b07 100644 --- a/App/Views/ServiceToggleView.swift +++ b/App/Views/ServiceToggleView.swift @@ -41,6 +41,9 @@ struct ServiceToggleView: View { .buttonStyle(PlainButtonStyle()) .disabled(!isEnabled) .frame(width: buttonSize, height: buttonSize) + .accessibilityLabel(config.name) + .accessibilityValue(config.binding.wrappedValue ? "Enabled" : "Disabled") + .accessibilityAddTraits(.isButton) Text(config.name) .frame(maxWidth: .infinity, alignment: .leading) From 701e140f874c0eac230ff99ecdcd75bba58522ee Mon Sep 17 00:00:00 2001 From: Oleksandr Lazariev Date: Fri, 4 Sep 2026 00:06:47 +0200 Subject: [PATCH 10/56] Add per-row remove button to trusted clients list (#184) Removing a single trusted client required a right-click context menu or select-then-Delete, neither of which is discoverable, while bulk removal already had a visible "Remove All" button. Add a visible per-row remove button alongside the existing context menu. Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV Co-authored-by: Claude Sonnet 5 --- App/Views/SettingsView.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/App/Views/SettingsView.swift b/App/Views/SettingsView.swift index c4703894..3e364c67 100644 --- a/App/Views/SettingsView.swift +++ b/App/Views/SettingsView.swift @@ -110,6 +110,14 @@ struct GeneralSettingsView: View { Text(client) .font(.system(.body, design: .monospaced)) Spacer() + Button { + serverController.removeTrustedClient(client) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help("Remove Client") } .contextMenu { Button("Remove Client", role: .destructive) { From c23262f3e93e9275f25f5f8b992e8180008a6aaf Mon Sep 17 00:00:00 2001 From: Oleksandr Lazariev Date: Fri, 4 Sep 2026 00:07:10 +0200 Subject: [PATCH 11/56] Explain trusted clients in Settings (#186) The Trusted Clients section didn't say how entries get there, that trusted clients connect silently apart from a notification, or that trust is keyed to the client's self-reported name. Spell all three out: an actionable empty state, a fuller section caption, and a footnote on name-based identity. Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV Co-authored-by: Claude Fable 5 --- App/Views/SettingsView.swift | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/App/Views/SettingsView.swift b/App/Views/SettingsView.swift index 3e364c67..e5e5980a 100644 --- a/App/Views/SettingsView.swift +++ b/App/Views/SettingsView.swift @@ -90,18 +90,24 @@ struct GeneralSettingsView: View { } } - Text("Clients that automatically connect without approval.") - .font(.caption) - .foregroundStyle(.secondary) + Text( + "Clients that connect automatically, without an approval dialog. A notification appears whenever one connects." + ) + .font(.caption) + .foregroundStyle(.secondary) } .padding(.bottom, 4) if trustedClients.isEmpty { - HStack { + VStack(alignment: .leading, spacing: 4) { Text("No trusted clients") .foregroundStyle(.secondary) .italic() - Spacer() + Text( + "Clients appear here when you check \"Always trust this client\" while approving a connection." + ) + .font(.caption) + .foregroundStyle(.secondary) } .padding(.vertical, 8) } else { @@ -132,6 +138,10 @@ struct GeneralSettingsView: View { } selectedClients.removeAll() } + + Text("Clients are identified by the name they report, not by a verified identity.") + .font(.caption) + .foregroundStyle(.secondary) } } } From f128fc0de0fdb595d722d5bede8318e5fe810042 Mon Sep 17 00:00:00 2001 From: Oleksandr Lazariev Date: Fri, 4 Sep 2026 00:07:32 +0200 Subject: [PATCH 12/56] Add Launch at Login setting (#187) Registers the app as a login item via SMAppService with a toggle in Settings > General. State is re-read on appear so changes made in System Settings > Login Items stay in sync, and the toggle reverts if registration fails. Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV Co-authored-by: Claude Fable 5 --- App/Views/SettingsView.swift | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/App/Views/SettingsView.swift b/App/Views/SettingsView.swift index e5e5980a..25044398 100644 --- a/App/Views/SettingsView.swift +++ b/App/Views/SettingsView.swift @@ -1,3 +1,4 @@ +import ServiceManagement import SwiftUI struct SettingsView: View { @@ -68,6 +69,7 @@ struct GeneralSettingsView: View { @ObservedObject var serverController: ServerController @State private var showingResetAlert = false @State private var selectedClients = Set() + @State private var launchAtLogin = SMAppService.mainApp.status == .enabled private var trustedClients: [String] { serverController.getTrustedClients() @@ -75,6 +77,30 @@ struct GeneralSettingsView: View { var body: some View { Form { + Section { + VStack(alignment: .leading, spacing: 4) { + Toggle("Launch at Login", isOn: $launchAtLogin) + .onChange(of: launchAtLogin) { _, enabled in + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + launchAtLogin = SMAppService.mainApp.status == .enabled + } + } + + Text("Opens iMCP automatically when you log in.") + .font(.caption) + .foregroundStyle(.secondary) + } + .onAppear { + launchAtLogin = SMAppService.mainApp.status == .enabled + } + } + Section { VStack(alignment: .leading, spacing: 12) { HStack { From b4d3edd5ab9b7814f70d76cacbf3ffef6a3c462c Mon Sep 17 00:00:00 2001 From: Oleksandr Lazariev Date: Fri, 4 Sep 2026 00:07:55 +0200 Subject: [PATCH 13/56] Fix Settings window intermittently not appearing (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix Settings window intermittently not appearing iMCP is an accessory app (LSUIElement = YES, no Dock icon). SwiftUI's openSettings() routes through a responder-chain action that only reliably lands when the app is already frontmost, which is ambiguous right as the custom menu bar popover dismisses — causing the window to randomly fail to open. Every other window-showing action in this file (About iMCP, connection approval) already works around this by calling NSApp.activate(ignoringOtherApps: true) first; Settings was the one path missing it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV * Trim comment to one line Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV --------- Co-authored-by: Claude Sonnet 5 --- App/Views/ContentView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index f0028e60..a4ac366f 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -103,6 +103,8 @@ struct ContentView: View { Divider() MenuButton("Settings...", isMenuPresented: $isMenuPresented) { + // openSettings() alone is unreliable for this accessory (LSUIElement) app. + NSApp.activate(ignoringOtherApps: true) openSettings() } From 8dd6a3d0c36dd2ef2294553473be8f10eb2d8ace Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 3 Sep 2026 15:22:21 -0700 Subject: [PATCH 14/56] Stop caching Xcode's module cache between CI runs (#202) Clang module files record the mtime of the SDK headers they were built from. When the runner image updates its Xcode, a restored ModuleCache.noindex is rejected wholesale ("has been modified since the module file was built"), which is what broke main after #188 landed. Keep caching DerivedData and the SwiftPM cache, but let the module cache rebuild; it is cheap. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac501cc7..29e1ec42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: with: path: | ~/Library/Developer/Xcode/DerivedData + !~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex ~/Library/Caches/org.swift.swiftpm key: ${{ runner.os }}-xcode-${{ matrix.xcode }}-derived-${{ hashFiles('**/Package.resolved') }} restore-keys: | From 9326064bd488b98e96e70eb424393ed9b94c4b63 Mon Sep 17 00:00:00 2001 From: Oleksandr Lazariev Date: Fri, 4 Sep 2026 03:27:57 +0200 Subject: [PATCH 15/56] List enabled services in the connection approval dialog (#185) The dialog said "This will give the client access to enabled services" without naming them, so users had no way to know what they were actually granting access to. Pass the currently enabled service names through to the dialog and list them explicitly. Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV Co-authored-by: Claude Sonnet 5 Co-authored-by: mattt --- App/Controllers/ServerController.swift | 4 ++++ App/Views/ConnectionApprovalView.swift | 19 +++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 20b7ee96..23f5aca3 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -381,6 +381,10 @@ final class ServerController: ObservableObject { approvalWindowController.showApprovalWindow( clientName: clientID, + enabledServiceNames: + computedServiceConfigs + .filter { $0.binding.wrappedValue } + .map { $0.name }, onApprove: { alwaysTrust in if alwaysTrust { self.addTrustedClient(clientID) diff --git a/App/Views/ConnectionApprovalView.swift b/App/Views/ConnectionApprovalView.swift index 1df36145..8edd5d28 100644 --- a/App/Views/ConnectionApprovalView.swift +++ b/App/Views/ConnectionApprovalView.swift @@ -3,6 +3,7 @@ import SwiftUI struct ConnectionApprovalView: View { let clientName: String + let enabledServiceNames: [String] let onApprove: (Bool) -> Void // Bool parameter is for "always trust" let onDeny: () -> Void @@ -26,10 +27,17 @@ struct ConnectionApprovalView: View { VStack(alignment: .leading, spacing: 8) { Text("Allow \"\(clientName)\" to connect to iMCP?") - Text("This will give the client access to enabled services.") - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) + if enabledServiceNames.isEmpty { + Text("No services are currently enabled.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } else { + Text("This will give the client access to: \(enabledServiceNames.joined(separator: ", ")).") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } } // Always trust checkbox @@ -91,12 +99,14 @@ class ConnectionApprovalWindowController: NSObject { func showApprovalWindow( clientName: String, + enabledServiceNames: [String], onApprove: @escaping (Bool) -> Void, onDeny: @escaping () -> Void ) { // Create the SwiftUI view let approvalView = ConnectionApprovalView( clientName: clientName, + enabledServiceNames: enabledServiceNames, onApprove: { alwaysTrust in onApprove(alwaysTrust) self.closeWindow() @@ -160,6 +170,7 @@ class ConnectionApprovalWindowController: NSObject { #Preview { ConnectionApprovalView( clientName: "Claude Desktop", + enabledServiceNames: ["Calendar", "Contacts", "Location"], onApprove: { alwaysTrust in print("Approved with always trust: \(alwaysTrust)") }, From 10997d16d18d8f30f7c598b4904176fdca4f4a02 Mon Sep 17 00:00:00 2001 From: Oleksandr Lazariev Date: Fri, 4 Sep 2026 03:28:20 +0200 Subject: [PATCH 16/56] Fix oversized menu panel after toggling MCP server off (#189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MenuBarExtra's window-style panel keeps its stale taller frame when the Services section collapses, leaving the compact content vertically centered in an oversized translucent panel — and reopening the menu doesn't fix it, since the panel is only sized at first presentation. Track the content's measured height and resize the panel window to match, both on content changes and each time the menu is presented. The window is looked up by class name directly because MenuBarExtraAccess's introspection fails to find it on macOS 26. The collapse animation is removed so the frame can't lag the content, and the content is top-aligned as a fallback. Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV Co-authored-by: Claude Fable 5 --- App/Views/ContentView.swift | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index a4ac366f..cb9f5f14 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -9,6 +9,7 @@ struct ContentView: View { @Environment(\.openSettings) private var openSettings private let aboutWindowController: AboutWindowController + @State private var contentHeight: CGFloat = 0 private var serviceConfigs: [ServiceConfig] { serverController.computedServiceConfigs @@ -72,9 +73,8 @@ struct ContentView: View { await serverController.updateServiceBindings(serviceBindings) } } - .transition(.opacity.combined(with: .move(edge: .top))) - .animation(.easeInOut(duration: 0.3), value: isEnabled) } + // No animation: it desyncs this auto-sizing panel's frame from its content. VStack(alignment: .leading, spacing: 2) { Divider() @@ -121,7 +121,35 @@ struct ContentView: View { .padding(.horizontal, 2) } .padding(.vertical, 6) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + contentHeight = height + resizeMenuBarExtraWindow() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Material.thick) + .onChange(of: isMenuPresented) { _, presented in + if presented { + resizeMenuBarExtraWindow() + } + } + } + + // MenuBarExtra's window keeps a stale taller frame when its content shrinks, + // leaving the content vertically centered in an oversized panel. Resize it to fit. + private func resizeMenuBarExtraWindow() { + DispatchQueue.main.async { + guard contentHeight > 0, + let window = NSApp.windows.first(where: { $0.className.contains("MenuBarExtraWindow") }) + else { return } + let frame = window.frame + guard abs(frame.height - contentHeight) > 0.5 else { return } + window.setFrame( + NSRect(x: frame.minX, y: frame.maxY - contentHeight, width: frame.width, height: contentHeight), + display: true + ) + } } } From bac35e2ced492bfd63567a483a299185cf9cbd50 Mon Sep 17 00:00:00 2001 From: Oleksandr Lazariev Date: Fri, 4 Sep 2026 15:22:27 +0200 Subject: [PATCH 17/56] Add per-tool toggles and show what each service exposes (#190) * Add disabled-tools state and enforce it in the MCP server A JSON-encoded set of disabled tool names is persisted the same way as trusted clients and pushed into ServerNetworkManager the same way as service bindings. ListTools omits disabled tools and CallTool rejects them, so a client holding a stale list still cannot invoke one. Connected clients get tools/listChanged on every change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV * Guard disabled-tools pushes against out-of-order delivery Each write to ServerController.disabledTools fired an unstructured Task carrying a snapshot to the actor. Two rapid writes could reach the actor out of order, and the actor's equality guard would accept the stale snapshot, leaving live ListTools/CallTool enforcement diverged from the persisted value until the next toggle. A monotonic generation counter is now attached to each push; the actor discards any delivery whose generation is not newer than the last one it applied. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV * Add Services section to Settings with per-tool toggles Each service lists its tools with human-readable titles, descriptions, and a Read-only badge from the tool annotations, plus a switch per tool backed by the disabled-tools set. The service master toggle is the same binding the menu uses. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV * Fix accessibility and initial paint of service tool toggles Two Important findings from the Task 2 review, both in App/Views/ServicesSettingsView.swift: 1. `.accessibilityElement(children: .combine)` on each tool row merged the label and the interactive Toggle into a single non-actionable AX element, so VoiceOver users could not flip a tool's switch. Removed the combine modifier and gave both the per-tool and per-service Toggle real accessibility labels (tool title / service name) with `.labelsHidden()` to keep the visual layout unchanged while exposing a named, actionable control to assistive technology. 2. Freshly rendered/scrolled switch rows could transiently paint "off" while the persisted value was "on" (view identity was reused across rows with different underlying state). Per the controller's ruling, mitigated by keying each switch's view identity to its current value via `.id("\(name)-\(currentValue)")`, so a newly materialized row constructs its switch with the right state. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV * List each service's tools in its menu row tooltip Hovering a service row now shows what enabling it exposes, using the tool annotation titles. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV * Route Settings service toggles through activation and client notification The Settings service toggle (ServicesSettingsView.serviceBinding) wrote config.binding.wrappedValue directly, bypassing the activation flow that the menu path (ServiceToggleView) already performs. Enabling a service like Calendar from Settings never triggered the macOS permission prompt, and there was no revert-to-off if activation failed or was denied. It also never pushed the updated bindings to ServerNetworkManager, so connected MCP clients were not notified via tools/listChanged when a service was toggled from Settings (only the menu scene's ContentView .onChange did this). Add ServerController.setService(_:enabled:), which updates the binding, calls config.service.activate() when enabling an unactivated service (reverting on failure), and pushes currentServiceBindings to the network manager so notifyToolListChanged fires for connected clients. Route ServicesSettingsView.serviceBinding's setter through it, removing the now-redundant manual objectWillChange.send() from the view. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QLDiNzrDds62dnD9NoCEJV --------- Co-authored-by: Claude Fable 5 Co-authored-by: mattt --- App/Controllers/ServerController.swift | 86 ++++++++++++++++++++++++ App/Views/ServiceToggleView.swift | 7 ++ App/Views/ServicesSettingsView.swift | 91 ++++++++++++++++++++++++++ App/Views/SettingsView.swift | 5 ++ 4 files changed, 189 insertions(+) create mode 100644 App/Views/ServicesSettingsView.swift diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 23f5aca3..4a371d75 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -179,6 +179,10 @@ final class ServerController: ObservableObject { // MARK: - AppStorage for Trusted Clients @AppStorage("trustedClients") private var trustedClientsData = Data() + // MARK: - AppStorage for Disabled Tools + @AppStorage("disabledTools") private var disabledToolsData = Data() + private var disabledToolsGeneration = 0 + // MARK: - Computed Properties for Service Configurations and Bindings var computedServiceConfigs: [ServiceConfig] { ServiceRegistry.configureServices( @@ -237,6 +241,51 @@ final class ServerController: ObservableObject { trustedClients = Set() } + // MARK: - Disabled Tools Management + var disabledTools: Set { + get { + (try? JSONDecoder().decode(Set.self, from: disabledToolsData)) ?? [] + } + set { + objectWillChange.send() + disabledToolsData = (try? JSONEncoder().encode(newValue)) ?? Data() + disabledToolsGeneration += 1 + let generation = disabledToolsGeneration + Task { await networkManager.updateDisabledTools(newValue, generation: generation) } + } + } + + func isToolEnabled(_ name: String) -> Bool { + !disabledTools.contains(name) + } + + func setTool(_ name: String, enabled: Bool) { + var tools = disabledTools + if enabled { + tools.remove(name) + } else { + tools.insert(name) + } + disabledTools = tools + } + + func setService(_ config: ServiceConfig, enabled: Bool) { + objectWillChange.send() + config.binding.wrappedValue = enabled + + Task { + if enabled, await !config.isActivated { + do { + try await config.service.activate() + } catch { + self.objectWillChange.send() + config.binding.wrappedValue = false + } + } + await networkManager.updateServiceBindings(self.currentServiceBindings) + } + } + // MARK: - Connection Approval Methods private func cleanupApprovalState() { pendingClientName = "" @@ -265,6 +314,7 @@ final class ServerController: ObservableObject { Task { // Initialize bindings from AppStorage before the server starts. await networkManager.updateServiceBindings(self.currentServiceBindings) + await networkManager.updateDisabledTools(self.disabledTools, generation: 0) await self.networkManager.start() self.updateServerStatus("Running") @@ -645,6 +695,8 @@ actor ServerNetworkManager { private let services = ServiceRegistry.services private var serviceBindings: [String: Binding] = [:] + private var disabledTools: Set = [] + private var disabledToolsLastGeneration = -1 init() { do { @@ -911,6 +963,10 @@ actor ServerNetworkManager { isServiceEnabled { for tool in service.tools { + if await self.disabledTools.contains(tool.name) { + log.debug("Skipping disabled tool: \(tool.name)") + continue + } log.debug("Adding tool: \(tool.name)") tools.append( .init( @@ -953,6 +1009,20 @@ actor ServerNetworkManager { ) } + if await self.disabledTools.contains(params.name) { + log.notice("Tool call rejected: \(params.name) is disabled") + return CallTool.Result( + content: [ + .text( + text: "Tool \(params.name) is currently disabled in iMCP settings.", + annotations: nil, + _meta: nil + ) + ], + isError: true + ) + } + for service in await self.services { let serviceId = String(describing: type(of: service)) @@ -1059,4 +1129,20 @@ actor ServerNetworkManager { } } } + + // Update the disabled tool set, discarding out-of-order deliveries. + func updateDisabledTools(_ newDisabledTools: Set, generation: Int) async { + guard generation > disabledToolsLastGeneration else { return } + disabledToolsLastGeneration = generation + + guard disabledTools != newDisabledTools else { return } + self.disabledTools = newDisabledTools + + // Notify clients that tool availability may have changed. + Task { + for (_, connectionManager) in connections { + await connectionManager.notifyToolListChanged() + } + } + } } diff --git a/App/Views/ServiceToggleView.swift b/App/Views/ServiceToggleView.swift index 1db08b07..53a83d44 100644 --- a/App/Views/ServiceToggleView.swift +++ b/App/Views/ServiceToggleView.swift @@ -44,6 +44,7 @@ struct ServiceToggleView: View { .accessibilityLabel(config.name) .accessibilityValue(config.binding.wrappedValue ? "Enabled" : "Disabled") .accessibilityAddTraits(.isButton) + .help("\(config.name) tools: \(toolSummary)") Text(config.name) .frame(maxWidth: .infinity, alignment: .leading) @@ -72,4 +73,10 @@ struct ServiceToggleView: View { return .primary.opacity(isEnabled ? 0.7 : 0.4) } } + + private var toolSummary: String { + config.service.tools + .map { $0.annotations.title ?? $0.name } + .joined(separator: ", ") + } } diff --git a/App/Views/ServicesSettingsView.swift b/App/Views/ServicesSettingsView.swift new file mode 100644 index 00000000..2397b377 --- /dev/null +++ b/App/Views/ServicesSettingsView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct ServicesSettingsView: View { + @ObservedObject var serverController: ServerController + + var body: some View { + Form { + ForEach(serverController.computedServiceConfigs) { config in + Section { + ForEach(config.service.tools, id: \.name) { tool in + toolRow(tool) + } + .disabled(!config.binding.wrappedValue) + } header: { + HStack(spacing: 8) { + Circle() + .fill(config.color) + .overlay( + Image(systemName: config.iconName) + .resizable() + .scaledToFit() + .foregroundColor(.white) + .padding(4) + ) + .frame(width: 20, height: 20) + + Text(config.name) + + Spacer() + + Toggle(config.name, isOn: serviceBinding(config)) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + .id("\(config.id)-\(config.binding.wrappedValue)") + } + } + } + } + .formStyle(.grouped) + } + + @ViewBuilder + private func toolRow(_ tool: Tool) -> some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(tool.annotations.title ?? tool.name) + + if tool.annotations.readOnlyHint == true { + Text("Read-only") + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Color.secondary.opacity(0.15))) + .foregroundStyle(.secondary) + } + } + + Text(tool.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer() + + Toggle(tool.annotations.title ?? tool.name, isOn: toolBinding(tool.name)) + .toggleStyle(.switch) + .controlSize(.mini) + .labelsHidden() + .id("\(tool.name)-\(serverController.isToolEnabled(tool.name))") + } + } + + // Delegate to ServerController so toggling activates the service (permission + // prompt, revert on failure) and pushes updated bindings to connected clients. + private func serviceBinding(_ config: ServiceConfig) -> Binding { + Binding( + get: { config.binding.wrappedValue }, + set: { serverController.setService(config, enabled: $0) } + ) + } + + private func toolBinding(_ name: String) -> Binding { + Binding( + get: { serverController.isToolEnabled(name) }, + set: { serverController.setTool(name, enabled: $0) } + ) + } +} diff --git a/App/Views/SettingsView.swift b/App/Views/SettingsView.swift index 25044398..df2c2d94 100644 --- a/App/Views/SettingsView.swift +++ b/App/Views/SettingsView.swift @@ -7,12 +7,14 @@ struct SettingsView: View { enum SettingsSection: String, CaseIterable, Identifiable { case general = "General" + case services = "Services" var id: String { self.rawValue } var icon: String { switch self { case .general: return "gear" + case .services: return "square.grid.2x2" } } } @@ -41,6 +43,9 @@ struct SettingsView: View { GeneralSettingsView(serverController: serverController) .navigationTitle("General") .formStyle(.grouped) + case .services: + ServicesSettingsView(serverController: serverController) + .navigationTitle("Services") } } else { Text("Select a category") From 1bb75435eac0448feb7da02402f073cebbab65d5 Mon Sep 17 00:00:00 2001 From: Anders Bratland Date: Fri, 4 Sep 2026 16:10:33 +0200 Subject: [PATCH 18/56] Add Call History service (#157) * Add Call History service for phone call log access Adds a new MCP service that reads the macOS CallHistory database (synced from iPhone) at ~/Library/Application Support/CallHistoryDB/ CallHistory.storedata. Exposes a callhistory_fetch tool with filtering by participant, date range, and call type (incoming/outgoing/missed). Follows the same patterns as the existing Messages service: direct SQLite access, security-scoped bookmark persistence, and file picker fallback for sandboxed environments. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix CallHistory SQL schema and CLI graceful shutdown - CallHistory: Use ZADDRESS and ZNAME columns directly from ZCALLRECORD instead of JOINing on a non-existent ZHANDLE foreign key. The macOS CallHistory database stores phone numbers inline, not via a handle table. - CallHistory: Include contact name in results when available. - CLI: Add successTerminationBehavior: .gracefullyShutdownGroup to prevent ServiceGroup from crashing with a fatal error on normal disconnect. Co-Authored-By: Claude Opus 4.6 (1M context) * Make call_type an enum in the schema * Fix formatting for swift-format lint --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: mattt --- App/Controllers/ServerController.swift | 11 + App/Services/CallHistory.swift | 457 +++++++++++++++++++++++++ 2 files changed, 468 insertions(+) create mode 100644 App/Services/CallHistory.swift diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 4a371d75..f6d19fe7 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -51,6 +51,7 @@ enum ServiceRegistry { static let services: [any Service] = { var services: [any Service] = [ CalendarService.shared, + CallHistoryService.shared, CaptureService.shared, ContactsService.shared, LocationService.shared, @@ -68,6 +69,7 @@ enum ServiceRegistry { static func configureServices( calendarEnabled: Binding, + callHistoryEnabled: Binding, captureEnabled: Binding, contactsEnabled: Binding, locationEnabled: Binding, @@ -86,6 +88,13 @@ enum ServiceRegistry { service: CalendarService.shared, binding: calendarEnabled ), + ServiceConfig( + name: "Call History", + iconName: "phone.fill", + color: .green.mix(with: .blue, by: 0.3), + service: CallHistoryService.shared, + binding: callHistoryEnabled + ), ServiceConfig( name: "Capture", iconName: "camera.on.rectangle.fill", @@ -166,6 +175,7 @@ final class ServerController: ObservableObject { // MARK: - AppStorage for Service Enablement States @AppStorage("calendarEnabled") private var calendarEnabled = false + @AppStorage("callHistoryEnabled") private var callHistoryEnabled = false @AppStorage("captureEnabled") private var captureEnabled = false @AppStorage("contactsEnabled") private var contactsEnabled = false @AppStorage("locationEnabled") private var locationEnabled = false @@ -187,6 +197,7 @@ final class ServerController: ObservableObject { var computedServiceConfigs: [ServiceConfig] { ServiceRegistry.configureServices( calendarEnabled: $calendarEnabled, + callHistoryEnabled: $callHistoryEnabled, captureEnabled: $captureEnabled, contactsEnabled: $contactsEnabled, locationEnabled: $locationEnabled, diff --git a/App/Services/CallHistory.swift b/App/Services/CallHistory.swift new file mode 100644 index 00000000..cda077da --- /dev/null +++ b/App/Services/CallHistory.swift @@ -0,0 +1,457 @@ +import AppKit +import OSLog +import SQLite3 + +private let log = Logger.service("callhistory") +private let callHistoryDatabasePath = + "/Users/\(NSUserName())/Library/Application Support/CallHistoryDB/CallHistory.storedata" +private let callHistoryDatabaseBookmarkKey: String = "me.mattt.iMCP.callHistoryDatabaseBookmark" +private let defaultLimit = 30 + +// Apple's Core Data epoch: 2001-01-01 00:00:00 UTC +private let coreDataEpoch: TimeInterval = 978_307_200 + +final class CallHistoryService: NSObject, Service, NSOpenSavePanelDelegate { + static let shared = CallHistoryService() + + func activate() async throws { + log.debug("Starting call history service activation") + + if canAccessDatabaseAtDefaultPath { + log.debug("Successfully activated using default database path") + return + } + + if canAccessDatabaseUsingBookmark { + log.debug("Successfully activated using stored bookmark") + return + } + + log.debug("Opening file picker for manual database selection") + guard try await showDatabaseAccessAlert() else { + throw DatabaseAccessError.userDeclinedAccess + } + + let selectedURL = try await showFilePicker() + + guard FileManager.default.isReadableFile(atPath: selectedURL.path) else { + throw DatabaseAccessError.fileNotReadable + } + + storeBookmark(for: selectedURL) + log.debug("Successfully activated call history service") + } + + var isActivated: Bool { + get async { + let isActivated = canAccessDatabaseAtDefaultPath || canAccessDatabaseUsingBookmark + log.debug("Call history service activation status: \(isActivated)") + return isActivated + } + } + + var tools: [Tool] { + Tool( + name: "callhistory_fetch", + description: "Fetch phone call history from the Mac (synced from iPhone)", + inputSchema: .object( + properties: [ + "participant": .string( + description: + "Phone number or contact name to filter by (partial match supported)" + ), + "start": .string( + description: + "Start of the date range (inclusive). ISO 8601 format. If timezone is omitted, local time is assumed.", + format: .dateTime + ), + "end": .string( + description: + "End of the date range (exclusive). ISO 8601 format. If timezone is omitted, local time is assumed.", + format: .dateTime + ), + "call_type": .string( + description: "Filter by call type, or omit for all", + enum: ["incoming", "outgoing", "missed"] + ), + "limit": .integer( + description: "Maximum calls to return", + default: .int(defaultLimit) + ), + ], + additionalProperties: false + ), + annotations: .init( + title: "Fetch Call History", + readOnlyHint: true, + openWorldHint: false + ) + ) { arguments in + log.debug("Starting call history fetch with arguments: \(arguments)") + try await self.activate() + + let participant = arguments["participant"]?.stringValue + let callTypeFilter = arguments["call_type"]?.stringValue + let limit = arguments["limit"]?.intValue ?? defaultLimit + + var startDate: Date? + var endDate: Date? + if let startStr = arguments["start"]?.stringValue, + let parsedStart = ISO8601DateFormatter.parsedLenientISO8601Date( + fromISO8601String: startStr + ) + { + startDate = parsedStart.date + } + if let endStr = arguments["end"]?.stringValue, + let parsedEnd = ISO8601DateFormatter.parsedLenientISO8601Date( + fromISO8601String: endStr + ) + { + endDate = parsedEnd.date + } + + let databaseURL = try self.resolveDatabaseURL() + let calls = try self.fetchCalls( + from: databaseURL, + participant: participant, + startDate: startDate, + endDate: endDate, + callTypeFilter: callTypeFilter, + limit: limit + ) + + log.debug("Successfully fetched \(calls.count) calls") + return [ + "@context": "https://schema.org", + "@type": "ItemList", + "name": "Call History", + "numberOfItems": .int(calls.count), + "itemListElement": Value.array(calls.map({ .object($0) })), + ] + } + } + + // MARK: - Database Access + + private var canAccessDatabaseAtDefaultPath: Bool { + return FileManager.default.isReadableFile(atPath: callHistoryDatabasePath) + } + + private var canAccessDatabaseUsingBookmark: Bool { + do { + let url = try resolveBookmarkURL() + return try withSecurityScopedAccess(url) { url in + FileManager.default.isReadableFile(atPath: url.path) + } + } catch { + log.error("Error accessing database with bookmark: \(error.localizedDescription)") + return false + } + } + + private func resolveDatabaseURL() throws -> URL { + if canAccessDatabaseAtDefaultPath { + return URL(fileURLWithPath: callHistoryDatabasePath) + } + return try resolveBookmarkURL() + } + + private func resolveBookmarkURL() throws -> URL { + guard + let bookmarkData = UserDefaults.standard.data(forKey: callHistoryDatabaseBookmarkKey) + else { + throw DatabaseAccessError.noBookmarkFound + } + + var isStale = false + return try URL( + resolvingBookmarkData: bookmarkData, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + } + + private func withSecurityScopedAccess(_ url: URL, _ operation: (URL) throws -> T) throws -> T { + guard url.startAccessingSecurityScopedResource() else { + log.error("Failed to start accessing security-scoped resource") + throw DatabaseAccessError.securityScopeAccessFailed + } + defer { url.stopAccessingSecurityScopedResource() } + return try operation(url) + } + + // MARK: - SQLite Query + + private func fetchCalls( + from databaseURL: URL, + participant: String?, + startDate: Date?, + endDate: Date?, + callTypeFilter: String?, + limit: Int + ) throws -> [[String: Value]] { + let accessBlock: (URL) throws -> [[String: Value]] = { url in + var db: OpaquePointer? + guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + let errorMessage = String(cString: sqlite3_errmsg(db)) + sqlite3_close(db) + throw DatabaseAccessError.sqliteError(errorMessage) + } + defer { sqlite3_close(db) } + + // Build query with filters + var conditions: [String] = [] + var params: [Any] = [] + + if let participant = participant { + conditions.append("(c.ZADDRESS LIKE ? OR c.ZNAME LIKE ?)") + params.append("%\(participant)%") + params.append("%\(participant)%") + } + + if let startDate = startDate { + let coreDataTimestamp = startDate.timeIntervalSince1970 - coreDataEpoch + conditions.append("c.ZDATE >= ?") + params.append(coreDataTimestamp) + } + + if let endDate = endDate { + let coreDataTimestamp = endDate.timeIntervalSince1970 - coreDataEpoch + conditions.append("c.ZDATE < ?") + params.append(coreDataTimestamp) + } + + if let callTypeFilter = callTypeFilter { + switch callTypeFilter.lowercased() { + case "incoming": + conditions.append("c.ZORIGINATED = 0 AND c.ZANSWERED = 1") + case "outgoing": + conditions.append("c.ZORIGINATED = 1") + case "missed": + conditions.append("c.ZORIGINATED = 0 AND c.ZANSWERED = 0") + default: + break + } + } + + let whereClause = + conditions.isEmpty ? "" : "WHERE " + conditions.joined(separator: " AND ") + + let query = """ + SELECT + c.Z_PK, + c.ZADDRESS, + c.ZNAME, + c.ZDATE, + c.ZDURATION, + c.ZORIGINATED, + c.ZANSWERED, + c.ZSERVICE_PROVIDER + FROM ZCALLRECORD c + \(whereClause) + ORDER BY c.ZDATE DESC + LIMIT ? + """ + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { + let errorMessage = String(cString: sqlite3_errmsg(db)) + throw DatabaseAccessError.sqliteError(errorMessage) + } + defer { sqlite3_finalize(stmt) } + + // Bind parameters + var paramIndex: Int32 = 1 + for param in params { + if let stringParam = param as? String { + sqlite3_bind_text( + stmt, + paramIndex, + (stringParam as NSString).utf8String, + -1, + nil + ) + } else if let doubleParam = param as? Double { + sqlite3_bind_double(stmt, paramIndex, doubleParam) + } else if let timeInterval = param as? TimeInterval { + sqlite3_bind_double(stmt, paramIndex, timeInterval) + } + paramIndex += 1 + } + sqlite3_bind_int(stmt, paramIndex, Int32(limit)) + + var calls: [[String: Value]] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + // Columns: 0=Z_PK, 1=ZADDRESS, 2=ZNAME, 3=ZDATE, 4=ZDURATION, + // 5=ZORIGINATED, 6=ZANSWERED, 7=ZSERVICE_PROVIDER + let id = Int(sqlite3_column_int64(stmt, 0)) + + let address: String + if let cStr = sqlite3_column_text(stmt, 1) { + address = String(cString: cStr) + } else { + address = "Unknown" + } + + let name: String? + if let cStr = sqlite3_column_text(stmt, 2) { + let n = String(cString: cStr) + name = n.isEmpty ? nil : n + } else { + name = nil + } + + let coreDataDate = sqlite3_column_double(stmt, 3) + let unixTimestamp = coreDataDate + coreDataEpoch + let date = Date(timeIntervalSince1970: unixTimestamp) + + let duration = sqlite3_column_double(stmt, 4) + let originated = sqlite3_column_int(stmt, 5) + let answered = sqlite3_column_int(stmt, 6) + + let callType: String + if originated == 1 { + callType = "outgoing" + } else if answered == 1 { + callType = "incoming" + } else { + callType = "missed" + } + + let serviceProvider: String + if let cStr = sqlite3_column_text(stmt, 7) { + serviceProvider = String(cString: cStr) + } else { + serviceProvider = "unknown" + } + + let durationMinutes = Int(duration) / 60 + let durationSeconds = Int(duration) % 60 + let durationStr = + durationMinutes > 0 + ? "\(durationMinutes)m \(durationSeconds)s" : "\(durationSeconds)s" + + var entry: [String: Value] = [ + "@id": .string(String(id)), + "phoneNumber": .string(address), + "callType": .string(callType), + "date": .string(date.formatted(.iso8601)), + "duration": .string(durationStr), + "durationSeconds": .double(duration), + "serviceProvider": .string(serviceProvider), + ] + if let name = name { + entry["name"] = .string(name) + } + + calls.append(entry) + } + + return calls + } + + // Use security-scoped access if needed + if canAccessDatabaseAtDefaultPath { + return try accessBlock(databaseURL) + } else { + return try withSecurityScopedAccess(databaseURL) { url in + try accessBlock(url) + } + } + } + + // MARK: - Errors + + private enum DatabaseAccessError: LocalizedError { + case noBookmarkFound + case securityScopeAccessFailed + case userDeclinedAccess + case invalidFileSelected + case fileNotReadable + case sqliteError(String) + + var errorDescription: String? { + switch self { + case .noBookmarkFound: + return "No stored bookmark found for call history database access" + case .securityScopeAccessFailed: + return "Failed to access security-scoped resource" + case .userDeclinedAccess: + return "User declined to grant access to the call history database" + case .invalidFileSelected: + return "Call history database access denied or invalid file selected" + case .fileNotReadable: + return "Selected database file is not readable" + case .sqliteError(let message): + return "SQLite error: \(message)" + } + } + } + + // MARK: - UI + + @MainActor + private func showDatabaseAccessAlert() async throws -> Bool { + let alert = NSAlert() + alert.messageText = "Call History Database Access Required" + alert.informativeText = """ + To read your phone call history, we need to open your database file. + + In the next screen, please select the file `CallHistory.storedata` and click "Grant Access". + """ + alert.alertStyle = .informational + alert.addButton(withTitle: "Continue") + alert.addButton(withTitle: "Cancel") + + return alert.runModal() == .alertFirstButtonReturn + } + + @MainActor + private func showFilePicker() async throws -> URL { + let openPanel = NSOpenPanel() + openPanel.delegate = self + openPanel.message = + "Please select the Call History database file (CallHistory.storedata)" + openPanel.prompt = "Grant Access" + openPanel.allowedContentTypes = [.item] + openPanel.directoryURL = URL(fileURLWithPath: callHistoryDatabasePath) + .deletingLastPathComponent() + openPanel.allowsMultipleSelection = false + openPanel.canChooseDirectories = false + openPanel.canChooseFiles = true + openPanel.showsHiddenFiles = true + + guard openPanel.runModal() == .OK, + let url = openPanel.url, + url.lastPathComponent == "CallHistory.storedata" + else { + throw DatabaseAccessError.invalidFileSelected + } + + return url + } + + private func storeBookmark(for url: URL) { + do { + let bookmarkData = try url.bookmarkData( + options: .securityScopeAllowOnlyReadAccess, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + UserDefaults.standard.set(bookmarkData, forKey: callHistoryDatabaseBookmarkKey) + log.debug("Successfully created and stored bookmark") + } catch { + log.error("Failed to create bookmark: \(error.localizedDescription)") + } + } + + func panel(_ sender: Any, shouldEnable url: URL) -> Bool { + let shouldEnable = url.lastPathComponent == "CallHistory.storedata" + log.debug( + "File selection panel: \(shouldEnable ? "enabling" : "disabling") URL: \(url.path)" + ) + return shouldEnable + } +} From d50fa669eaae0a6fbb5c7f88c3978c0c1d263f93 Mon Sep 17 00:00:00 2001 From: Mattt Date: Fri, 4 Sep 2026 07:43:07 -0700 Subject: [PATCH 19/56] Ignore .DS_Store (#204) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index bf7f3637..20d5d751 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# macOS +.DS_Store + # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore From 9c1c6886ed0a766d1ae43bf9411b9dcf9132e8cc Mon Sep 17 00:00:00 2001 From: Mattt Date: Fri, 4 Sep 2026 07:43:29 -0700 Subject: [PATCH 20/56] Rotate the Sparkle public key (#203) The private half of the previous key was not available, so update checks are signed with a new pair. No shipped build contains Sparkle yet, so nothing in the field verifies against the old key. --- App/Info.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/Info.plist b/App/Info.plist index 57abec83..855567cc 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -13,6 +13,6 @@ SUFeedURL https://downloads.imcp.app/appcast.xml SUPublicEDKey - +2ibtYfPSNKpUJsn1R9Ywc1GnjQA5vemVN95d0EzGxg= + TOvMWGidcnJi5xIqSmkkYFolRFpmHLLpO9GYVZfDC1U= From c46245fc334e2cd0dcdf353f56533fe67525cc19 Mon Sep 17 00:00:00 2001 From: Mattt Date: Fri, 4 Sep 2026 10:43:53 -0700 Subject: [PATCH 21/56] Automate releases with GitHub Actions and a Sparkle appcast step (#205) * Automate releases with GitHub Actions and a Sparkle appcast step Scripts/release.sh gains an appcast command that signs the release zip with the Sparkle EdDSA key, generates appcast.xml, and uploads it next to the release asset, refusing to continue when the app's SUPublicEDKey doesn't match the signing key. notarytool can now take an App Store Connect API key instead of a keychain profile, uploads and release creation honor DRY_RUN, and the release zip is built after stapling so the shipped bundle carries the ticket. The Release workflow runs the same steps on a version tag push, importing the Developer ID certificate and provisioning profile from repository secrets. Manual runs default to a dry run and attach the build products to the workflow run. * Publish releases only after every asset is in place Create the GitHub release as a draft, upload the zip and the appcast, then publish it and mark it latest, so a failure partway through never leaves a half-built release public. Publishing requires the version tag at HEAD, and manual workflow runs that publish must start from that tag. Capture the notarization credential helper's output so its validation failure stops the script. * Sign archives with the Developer ID identity and make publishing rerunnable The project's Release configuration signs with an Apple Development identity, which a release machine doesn't have, so archive with the configured Developer ID certificate, routing the provisioning profile to the app target alone because the embedded CLI has its own bundle identifier. Reuse an existing draft release on a rerun and refuse to touch one that is already published. Stop the local release flow after the tag push, since the Release workflow publishes from there. * Require the release tag on origin before creating a release Check that the tag has been pushed and matches the local tag, and pass --verify-tag so gh never mints a tag from the default branch. * Push only the release tag Every pushed version tag starts a release, so pushing all local tags could publish more than one. --- .github/workflows/release.yml | 211 ++++++++++++++++++++++++ Scripts/release.sh | 293 ++++++++++++++++++++++++++++++++-- 2 files changed, 487 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..82ce35ef --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,211 @@ +name: Release + +# Builds, signs, notarizes, and publishes a release +# with the same Scripts/release.sh steps used locally. +# +# A pushed version tag (for example 1.5.0) runs the full flow. +# Manual runs default to a dry run, +# which does everything except create the GitHub release and upload assets; +# the artifacts are attached to the workflow run instead. + +on: + push: + tags: ["[0-9]+.[0-9]+.[0-9]+"] + workflow_dispatch: + inputs: + version: + description: "Version to release (defaults to the tag name when run from a tag)" + required: false + type: string + dry_run: + description: "Skip creating the GitHub release and uploading assets" + required: false + type: boolean + default: true + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + DEVELOPER_DIR: "/Applications/Xcode_26.0.app/Contents/Developer" + TEAM_ID: TTY35UM57S + SIGNING_CERTIFICATE: "Developer ID Application: dododo, LLC (TTY35UM57S)" + SPARKLE_VERSION: "2.9.6" + SPARKLE_SHA256: "52bf9e88cdd972fc0c81501377a880e90d47031bd8ca5462488f843e2609e192" + +jobs: + release: + name: Build, notarize, and publish + runs-on: macos-26 + timeout-minutes: 60 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Resolve version + id: version + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + version="${GITHUB_REF_NAME}" + else + version="${INPUT_VERSION}" + fi + if [[ -z "${version}" ]]; then + echo "No version: push a version tag or pass the version input." >&2 + exit 1 + fi + project_version="$(grep -m1 -o 'MARKETING_VERSION = [0-9.]*' iMCP.xcodeproj/project.pbxproj | awk '{print $3}')" + if [[ "${project_version}" != "${version}" ]]; then + echo "MARKETING_VERSION in the project is ${project_version}, not ${version}." >&2 + exit 1 + fi + echo "VERSION=${version}" >> "${GITHUB_ENV}" + + # Tag pushes always publish; manual runs publish only when dry_run is off. + - name: Note dry run + if: github.event_name == 'workflow_dispatch' && inputs.dry_run + run: echo "DRY_RUN=1" >> "${GITHUB_ENV}" + + # A manual publish must run from the version's tag, + # so the release's source matches the binary it ships. + - name: Require the release tag + if: github.event_name == 'workflow_dispatch' && !inputs.dry_run + run: | + if [[ "${GITHUB_REF_TYPE}" != "tag" || "${GITHUB_REF_NAME}" != "${VERSION}" ]]; then + echo "Publishing runs must be started from the ${VERSION} tag, not ${GITHUB_REF_TYPE} ${GITHUB_REF_NAME}." >&2 + exit 1 + fi + + - name: Import signing certificate + env: + CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} + CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + run: | + keychain="${RUNNER_TEMP}/release.keychain-db" + keychain_password="$(uuidgen)" + certificate="${RUNNER_TEMP}/certificate.p12" + echo "${CERTIFICATE_P12}" | base64 --decode > "${certificate}" + security create-keychain -p "${keychain_password}" "${keychain}" + security set-keychain-settings -lut 21600 "${keychain}" + security unlock-keychain -p "${keychain_password}" "${keychain}" + security import "${certificate}" -P "${CERTIFICATE_PASSWORD}" -A -t cert -f pkcs12 -k "${keychain}" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${keychain_password}" "${keychain}" > /dev/null + security list-keychains -d user -s "${keychain}" login.keychain-db + rm -f "${certificate}" + security find-identity -v -p codesigning "${keychain}" + echo "RELEASE_KEYCHAIN=${keychain}" >> "${GITHUB_ENV}" + + - name: Install provisioning profile + env: + PROVISIONING_PROFILE: ${{ secrets.PROVISIONING_PROFILE }} + run: | + profile="${RUNNER_TEMP}/profile.provisionprofile" + echo "${PROVISIONING_PROFILE}" | base64 --decode > "${profile}" + plist="${RUNNER_TEMP}/profile.plist" + security cms -D -i "${profile}" > "${plist}" + uuid="$(/usr/libexec/PlistBuddy -c "Print UUID" "${plist}")" + for dir in "${HOME}/Library/MobileDevice/Provisioning Profiles" "${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles"; do + mkdir -p "${dir}" + cp "${profile}" "${dir}/${uuid}.provisionprofile" + done + echo "PROVISIONING_PROFILE_UUID=${uuid}" >> "${GITHUB_ENV}" + /usr/libexec/PlistBuddy -c "Print Name" "${plist}" + + - name: Write notarization key + env: + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} + run: | + key="${RUNNER_TEMP}/AuthKey.p8" + printf '%s\n' "${APP_STORE_CONNECT_PRIVATE_KEY}" > "${key}" + chmod 600 "${key}" + echo "NOTARY_KEY_FILE=${key}" >> "${GITHUB_ENV}" + + - name: Install Sparkle tools + run: | + archive="${RUNNER_TEMP}/Sparkle-${SPARKLE_VERSION}.tar.xz" + curl -sSL -o "${archive}" "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" + echo "${SPARKLE_SHA256} ${archive}" | shasum -a 256 -c - + mkdir -p "${RUNNER_TEMP}/sparkle" + tar -xJf "${archive}" -C "${RUNNER_TEMP}/sparkle" ./bin/generate_appcast ./bin/sign_update + echo "SPARKLE_BIN=${RUNNER_TEMP}/sparkle/bin" >> "${GITHUB_ENV}" + + - name: Write Sparkle signing key + env: + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + run: | + key="${RUNNER_TEMP}/sparkle_private_key" + printf '%s' "${SPARKLE_PRIVATE_KEY}" > "${key}" + chmod 600 "${key}" + echo "SPARKLE_PRIVATE_KEY_FILE=${key}" >> "${GITHUB_ENV}" + + - name: Archive + run: ./Scripts/release.sh archive + + - name: Export + run: ./Scripts/release.sh export + + - name: Notarize + env: + NOTARY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + run: ./Scripts/release.sh notarize + + - name: Staple + run: | + ./Scripts/release.sh staple + xcrun stapler validate dist/export/*.app + + - name: Package + run: ./Scripts/release.sh package + + - name: Generate appcast + env: + SPARKLE_PUBLIC_KEY: ${{ vars.SPARKLE_PUBLIC_KEY }} + run: ./Scripts/release.sh appcast + + # Everything is built and validated before anything is published. + # The release stays a draft until both uploads have succeeded. + - name: Create draft release + env: + GH_TOKEN: ${{ github.token }} + run: ./Scripts/release.sh release + + - name: Upload release asset + env: + GH_TOKEN: ${{ github.token }} + run: ./Scripts/release.sh upload + + - name: Upload appcast + env: + GH_TOKEN: ${{ github.token }} + run: ./Scripts/release.sh upload-appcast + + - name: Publish release + env: + GH_TOKEN: ${{ github.token }} + run: ./Scripts/release.sh publish + + - name: Attach build products to the workflow run + if: always() + uses: actions/upload-artifact@v4 + with: + name: iMCP-${{ env.VERSION }} + path: | + dist/*.zip + dist/*.sha256 + dist/appcast/appcast.xml + if-no-files-found: ignore + + - name: Clean up secrets + if: always() + run: | + rm -f "${RUNNER_TEMP}/sparkle_private_key" "${RUNNER_TEMP}/AuthKey.p8" "${RUNNER_TEMP}/profile.provisionprofile" + if [[ -n "${RELEASE_KEYCHAIN:-}" && -f "${RELEASE_KEYCHAIN}" ]]; then + security delete-keychain "${RELEASE_KEYCHAIN}" + fi diff --git a/Scripts/release.sh b/Scripts/release.sh index 471ede8b..f3efe595 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -19,6 +19,16 @@ SIGNING_CERTIFICATE="${SIGNING_CERTIFICATE:-Developer ID Application}" BUNDLE_ID="${BUNDLE_ID:-}" PROVISIONING_PROFILE_NAME="${PROVISIONING_PROFILE_NAME:-}" PROVISIONING_PROFILE_UUID="${PROVISIONING_PROFILE_UUID:-}" +NOTARY_KEY_ID="${NOTARY_KEY_ID:-}" +NOTARY_ISSUER_ID="${NOTARY_ISSUER_ID:-}" +NOTARY_KEY_FILE="${NOTARY_KEY_FILE:-}" +SPARKLE_BIN="${SPARKLE_BIN:-}" +SPARKLE_PRIVATE_KEY_FILE="${SPARKLE_PRIVATE_KEY_FILE:-}" +SPARKLE_PUBLIC_KEY="${SPARKLE_PUBLIC_KEY:-}" +APPCAST_DIR="${APPCAST_DIR:-${DIST_DIR}/appcast}" +APPCAST_LINK="${APPCAST_LINK:-https://imcp.app}" +RELEASE_DOWNLOAD_BASE="${RELEASE_DOWNLOAD_BASE:-https://github.com/mattt/iMCP/releases/download}" +DRY_RUN="${DRY_RUN:-}" # Derived artifact names for notarization/release steps. NOTARY_ZIP="${DIST_DIR}/${APP_NAME}-notarize.zip" @@ -28,7 +38,7 @@ print_usage() { Usage: Scripts/release.sh [command] Commands: - all Build check, bump, archive, export, package, notarize, staple, commit/tag, release, upload (default) + all Build check, bump, archive, export, notarize, staple, package, commit/tag, push tag (default); the Release workflow publishes check Quick release build check bump Bump version/build numbers archive Create an Xcode archive for direct distribution @@ -38,14 +48,20 @@ Commands: notarize Submit the app bundle for notarization staple Staple the notarization ticket to the app bundle commit Commit version bump and create release tag - release Create a GitHub release (no assets) - upload Upload the release asset to GitHub + appcast Generate and validate the signed Sparkle appcast + release Create a draft GitHub release for the tag at HEAD (no assets) + upload Upload the release asset to the draft release + upload-appcast Upload the appcast to the draft release + publish Publish the draft release and mark it latest help Show this help Environment: APP_NAME App name (default: iMCP) APP_BUNDLE App bundle path (default: ${APP_NAME}.app) - KEYCHAIN_PROFILE Required for notarize + KEYCHAIN_PROFILE notarytool keychain profile (notarize; or set the NOTARY_* keys) + NOTARY_KEY_ID App Store Connect API key ID (notarize, alternative to KEYCHAIN_PROFILE) + NOTARY_ISSUER_ID App Store Connect API issuer ID (with NOTARY_KEY_ID) + NOTARY_KEY_FILE Path to the App Store Connect API .p8 key (with NOTARY_KEY_ID) VERSION Required for bumping, commit, release, and upload BUILD_NUMBER Optional; used when bumping build number SCHEME Xcode scheme for build check (default: iMCP) @@ -61,6 +77,13 @@ Environment: BUNDLE_ID Bundle identifier for export profiles (optional) PROVISIONING_PROFILE_NAME Provisioning profile name for export (optional) PROVISIONING_PROFILE_UUID Provisioning profile UUID for export (optional) + SPARKLE_BIN Directory containing Sparkle's generate_appcast (default: found in DerivedData or PATH) + SPARKLE_PRIVATE_KEY_FILE Path to the Sparkle EdDSA private key (default: the login keychain) + SPARKLE_PUBLIC_KEY Expected SUPublicEDKey; the appcast step fails if the app carries a different key (optional) + APPCAST_DIR Working directory for the appcast (default: dist/appcast) + APPCAST_LINK Link element for appcast items (default: https://imcp.app) + RELEASE_DOWNLOAD_BASE Base URL for release assets (default: https://github.com/mattt/iMCP/releases/download) + DRY_RUN If set, skip creating the GitHub release and uploading assets EOF } @@ -93,11 +116,27 @@ require_app_bundle() { fi } -require_keychain_profile() { +# notarytool accepts either a stored keychain profile +# or an App Store Connect API key passed explicitly. +# The API key form is what CI uses. +notary_credential_args() { + if [[ -n "${NOTARY_KEY_ID}" || -n "${NOTARY_ISSUER_ID}" || -n "${NOTARY_KEY_FILE}" ]]; then + if [[ -z "${NOTARY_KEY_ID}" || -z "${NOTARY_ISSUER_ID}" || -z "${NOTARY_KEY_FILE}" ]]; then + echo "NOTARY_KEY_ID, NOTARY_ISSUER_ID, and NOTARY_KEY_FILE must be set together." >&2 + exit 1 + fi + if [[ ! -f "${NOTARY_KEY_FILE}" ]]; then + echo "Missing notary key file: ${NOTARY_KEY_FILE}" >&2 + exit 1 + fi + printf '%s\n' "--key" "${NOTARY_KEY_FILE}" "--key-id" "${NOTARY_KEY_ID}" "--issuer" "${NOTARY_ISSUER_ID}" + return 0 + fi if [[ -z "${KEYCHAIN_PROFILE}" ]]; then - echo "Missing keychain profile. Set KEYCHAIN_PROFILE." >&2 + echo "Missing notarization credentials. Set KEYCHAIN_PROFILE, or NOTARY_KEY_ID, NOTARY_ISSUER_ID, and NOTARY_KEY_FILE." >&2 exit 1 fi + printf '%s\n' "--keychain-profile" "${KEYCHAIN_PROFILE}" } require_version() { @@ -263,10 +302,44 @@ build_check() { resolve_app_bundle } +# The project's Release configuration signs the app with an Apple Development identity, +# which a release machine may not have. +# When a Developer ID certificate is configured, archive with it directly; +# export re-signs with the provisioning profile afterwards. +archive_signing_args() { + if [[ -z "${TEAM_ID}" ]]; then + return 0 + fi + printf '%s\n' \ + "CODE_SIGN_STYLE=Manual" \ + "CODE_SIGN_IDENTITY=${SIGNING_CERTIFICATE}" \ + "DEVELOPMENT_TEAM=${TEAM_ID}" + # The app's WeatherKit entitlement needs its provisioning profile at archive time, + # but a command-line override applies to every target, + # and the embedded CLI has a different bundle identifier. + # Route the profile through a setting keyed by product name + # so only the app target resolves to it. + local profile_value="" + if [[ -n "${PROVISIONING_PROFILE_UUID}" ]]; then + profile_value="${PROVISIONING_PROFILE_UUID}" + elif [[ -n "${PROVISIONING_PROFILE_NAME}" ]]; then + profile_value="${PROVISIONING_PROFILE_NAME}" + fi + if [[ -n "${profile_value}" ]]; then + printf '%s\n' \ + 'PROVISIONING_PROFILE_SPECIFIER=$(RELEASE_PROFILE_FOR_$(PRODUCT_NAME))' \ + "RELEASE_PROFILE_FOR_${APP_NAME}=${profile_value}" + fi +} + archive_app() { ensure_dist_dir + local signing_args=() + while IFS= read -r line; do + [[ -n "${line}" ]] && signing_args+=("${line}") + done <<< "$(archive_signing_args)" echo "Archiving app to ${ARCHIVE_PATH}" - xcodebuild -quiet -scheme "${SCHEME}" -configuration "${CONFIGURATION}" -destination "generic/platform=macOS" archive -archivePath "${ARCHIVE_PATH}" + xcodebuild -quiet -scheme "${SCHEME}" -configuration "${CONFIGURATION}" -destination "generic/platform=macOS" archive -archivePath "${ARCHIVE_PATH}" ${signing_args[@]+"${signing_args[@]}"} } write_export_options() { @@ -326,12 +399,19 @@ export_app() { notarize() { require_app_bundle - require_keychain_profile + # Capture first so a validation failure in the helper stops the script; + # a process substitution would swallow its exit status. + local credential_output + credential_output="$(notary_credential_args)" + local credential_args=() + while IFS= read -r line; do + credential_args+=("${line}") + done <<< "${credential_output}" ensure_dist_dir echo "Zipping for notarization: ${NOTARY_ZIP}" ditto -c -k --keepParent "${APP_BUNDLE}" "${NOTARY_ZIP}" echo "Submitting to notarization" - xcrun notarytool submit "${NOTARY_ZIP}" --wait --keychain-profile="${KEYCHAIN_PROFILE}" + xcrun notarytool submit "${NOTARY_ZIP}" --wait "${credential_args[@]}" } staple() { @@ -387,18 +467,78 @@ push_tags() { exit 1 fi local response="" - read -r -p "Push tags to origin? [y/N] " response + read -r -p "Push tag ${VERSION} to origin? [y/N] " response if [[ "${response}" != "y" && "${response}" != "Y" ]]; then echo "Tag push cancelled." exit 1 fi - git push --tags + # Push only this release's tag: every pushed version tag starts a release. + git push origin "refs/tags/${VERSION}" +} + +# The release tag must exist and point at the commit being released, +# or gh would mint a tag on the default branch that doesn't match the binary. +require_release_tag() { + require_version + local tag_commit head_commit + tag_commit="$(git rev-list -n 1 "refs/tags/${VERSION}" 2>/dev/null || true)" + if [[ -z "${tag_commit}" ]]; then + echo "Missing tag: ${VERSION}. Tag the release commit before publishing." >&2 + exit 1 + fi + head_commit="$(git rev-parse HEAD)" + if [[ "${tag_commit}" != "${head_commit}" ]]; then + echo "Tag ${VERSION} points at ${tag_commit}, but HEAD is ${head_commit}." >&2 + exit 1 + fi + # The remote tag is what the release records, so it must exist and match too. + local remote_commit + remote_commit="$(git ls-remote --tags origin "refs/tags/${VERSION}^{}" "refs/tags/${VERSION}" 2>/dev/null | awk '{print $1}' | tail -n 1 || true)" + if [[ -z "${remote_commit}" ]]; then + echo "Tag ${VERSION} hasn't been pushed to origin." >&2 + exit 1 + fi + if [[ "${remote_commit}" != "${tag_commit}" && "${remote_commit}" != "$(git rev-parse "refs/tags/${VERSION}")" ]]; then + echo "Tag ${VERSION} on origin (${remote_commit}) doesn't match the local tag (${tag_commit})." >&2 + exit 1 + fi } +# The release starts as a draft so a failure partway through +# never leaves a half-populated release as the latest one. create_release() { require_version - echo "Creating GitHub release ${VERSION}" - gh release create "${VERSION}" --generate-notes + if [[ -n "${DRY_RUN}" ]]; then + echo "Dry run: would create draft GitHub release ${VERSION}" + return 0 + fi + require_release_tag + # A rerun after a partial failure finds the draft from the last attempt; + # reuse it so the --clobber uploads can repair it. + local is_draft + if is_draft="$(gh release view "${VERSION}" --json isDraft --jq '.isDraft' 2>/dev/null)"; then + if [[ "${is_draft}" == "true" ]]; then + echo "Reusing existing draft release ${VERSION}" + return 0 + fi + echo "Release ${VERSION} is already published." >&2 + exit 1 + fi + echo "Creating draft GitHub release ${VERSION}" + gh release create "${VERSION}" --draft --generate-notes --verify-tag +} + +publish_release() { + require_version + if [[ -n "${DRY_RUN}" ]]; then + echo "Dry run: would publish GitHub release ${VERSION}" + return 0 + fi + echo "Publishing GitHub release ${VERSION}" + gh release edit "${VERSION}" --draft=false --latest + if [[ -t 1 && -z "${CI:-}" ]]; then + gh release view --web "${VERSION}" + fi } upload_asset() { @@ -413,9 +553,113 @@ upload_asset() { if [[ "${release_zip_path}" != "${upload_path}" ]]; then cp -f "${release_zip_path}" "${upload_path}" fi + if [[ -n "${DRY_RUN}" ]]; then + echo "Dry run: would upload ${upload_path} to release ${VERSION}" + return 0 + fi echo "Uploading release asset ${upload_path}" gh release upload "${VERSION}" "${upload_path}" --clobber - gh release view --web "${VERSION}" +} + +# Sparkle ships generate_appcast inside its SwiftPM artifact, +# so look there before falling back to PATH. +resolve_sparkle_bin() { + if [[ -n "${SPARKLE_BIN}" ]]; then + if [[ ! -x "${SPARKLE_BIN}/generate_appcast" ]]; then + echo "generate_appcast not found in SPARKLE_BIN: ${SPARKLE_BIN}" >&2 + exit 1 + fi + return 0 + fi + local candidate + candidate="$(find "${HOME}/Library/Developer/Xcode/DerivedData" -type f -path '*/artifacts/sparkle/Sparkle/bin/generate_appcast' 2>/dev/null | head -n 1 || true)" + if [[ -n "${candidate}" ]]; then + SPARKLE_BIN="$(dirname "${candidate}")" + return 0 + fi + if command -v generate_appcast >/dev/null 2>&1; then + SPARKLE_BIN="$(dirname "$(command -v generate_appcast)")" + return 0 + fi + echo "Sparkle's generate_appcast was not found. Set SPARKLE_BIN to its directory." >&2 + exit 1 +} + +# The app must embed the public half of the key that signs the appcast, +# or Sparkle refuses the update on the client. +verify_sparkle_public_key() { + if [[ -z "${SPARKLE_PUBLIC_KEY}" ]]; then + return 0 + fi + require_app_bundle + local embedded_key + embedded_key="$("/usr/libexec/PlistBuddy" -c "Print SUPublicEDKey" "${APP_BUNDLE}/Contents/Info.plist" 2>/dev/null || true)" + if [[ "${embedded_key}" != "${SPARKLE_PUBLIC_KEY}" ]]; then + echo "SUPublicEDKey in ${APP_BUNDLE} does not match SPARKLE_PUBLIC_KEY." >&2 + echo " app: ${embedded_key:-}" >&2 + echo " expected: ${SPARKLE_PUBLIC_KEY}" >&2 + exit 1 + fi +} + +build_appcast() { + require_version + resolve_sparkle_bin + verify_sparkle_public_key + local release_zip_path + release_zip_path="$(release_zip)" + if [[ ! -f "${release_zip_path}" ]]; then + echo "Missing release asset: ${release_zip_path}" >&2 + exit 1 + fi + # generate_appcast reads every archive in its directory, + # so give it a directory holding only this release, + # named as the asset is named on GitHub. + rm -rf "${APPCAST_DIR}" + mkdir -p "${APPCAST_DIR}" + cp -f "${release_zip_path}" "${APPCAST_DIR}/${APP_NAME}.zip" + local key_args=() + if [[ -n "${SPARKLE_PRIVATE_KEY_FILE}" ]]; then + if [[ ! -f "${SPARKLE_PRIVATE_KEY_FILE}" ]]; then + echo "Missing Sparkle private key file: ${SPARKLE_PRIVATE_KEY_FILE}" >&2 + exit 1 + fi + key_args=(--ed-key-file "${SPARKLE_PRIVATE_KEY_FILE}") + fi + echo "Generating appcast in ${APPCAST_DIR}" + # macOS ships bash 3.2, where an empty array trips set -u. + "${SPARKLE_BIN}/generate_appcast" \ + ${key_args[@]+"${key_args[@]}"} \ + --download-url-prefix "${RELEASE_DOWNLOAD_BASE}/${VERSION}/" \ + --link "${APPCAST_LINK}" \ + "${APPCAST_DIR}" + local appcast_path="${APPCAST_DIR}/appcast.xml" + if [[ ! -f "${appcast_path}" ]]; then + echo "generate_appcast did not write ${appcast_path}" >&2 + exit 1 + fi + # generate_appcast leaves the signature off silently + # when the app's SUPublicEDKey doesn't match the signing key. + if ! grep -q 'sparkle:edSignature="' "${appcast_path}"; then + echo "Appcast item is unsigned. Check that SUPublicEDKey in the app matches the Sparkle private key." >&2 + exit 1 + fi + echo "Done: ${appcast_path}" +} + +upload_appcast() { + require_version + local appcast_path="${APPCAST_DIR}/appcast.xml" + if [[ ! -f "${appcast_path}" ]]; then + echo "Missing appcast: ${appcast_path}" >&2 + exit 1 + fi + if [[ -n "${DRY_RUN}" ]]; then + echo "Dry run: would upload ${appcast_path} to release ${VERSION}" + return 0 + fi + echo "Uploading appcast to release ${VERSION}" + gh release upload "${VERSION}" "${appcast_path}" --clobber } all() { @@ -425,13 +669,19 @@ all() { bump_version archive_app export_app - package_release notarize staple + package_release commit_and_tag push_tags - create_release - upload_asset + # The Release workflow publishes from the pushed tag. + # Publishing here too would race it for the same release. + echo "Tag ${VERSION} pushed. The Release workflow builds and publishes it." + echo "Use the appcast, release, upload, upload-appcast, and publish commands only if it can't." +} + +appcast() { + build_appcast } release() { @@ -483,6 +733,15 @@ case "${COMMAND}" in upload) upload ;; + appcast) + appcast + ;; + upload-appcast) + upload_appcast + ;; + publish) + publish_release + ;; help|-h|--help) print_usage ;; From 837f1a746a1591512a46f468f53272db4ac065fe Mon Sep 17 00:00:00 2001 From: Mattt Date: Fri, 4 Sep 2026 10:55:11 -0700 Subject: [PATCH 22/56] Key the CI cache on the Xcode build number (#207) A runner image update changes the toolchain but not the cache key, so the restored module cache no longer matched and every build failed until the caches were deleted by hand. Reading the build number from xcodebuild makes the key change with the image. --- .github/workflows/ci.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29e1ec42..46f9b41f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,13 @@ jobs: - name: Checkout code uses: actions/checkout@v6 + # Key the cache on the exact Xcode build, + # so a runner image update can't restore module files + # compiled against an older toolchain. + - name: Read Xcode build number + id: xcode + run: echo "build=$(xcodebuild -version | awk '/Build version/ {print $3}')" >> "${GITHUB_OUTPUT}" + - name: Cache Xcode build artifacts uses: actions/cache@v5 with: @@ -34,9 +41,9 @@ jobs: ~/Library/Developer/Xcode/DerivedData !~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex ~/Library/Caches/org.swift.swiftpm - key: ${{ runner.os }}-xcode-${{ matrix.xcode }}-derived-${{ hashFiles('**/Package.resolved') }} + key: ${{ runner.os }}-xcode-${{ steps.xcode.outputs.build }}-derived-${{ hashFiles('**/Package.resolved') }} restore-keys: | - ${{ runner.os }}-xcode-${{ matrix.xcode }}-derived- + ${{ runner.os }}-xcode-${{ steps.xcode.outputs.build }}-derived- - name: Lint run: swift format lint --strict --recursive . From 087ae5e7b26a93b563ffecbf28553d91415ccd1b Mon Sep 17 00:00:00 2001 From: Mattt Date: Sat, 5 Sep 2026 10:09:46 -0700 Subject: [PATCH 23/56] Fix reconnect loop that drops Claude Desktop connections (#208) When the MCP client closed stdin, the stdin handler returned without an error and MCPService browsed Bonjour again immediately, so each new session died at once and the helper spun until it was killed. stdin EOF now fails the session, a clean return terminates the helper so the client can relaunch it, and the connection is cancelled before the task group unwinds so a pending receive can't keep it alive. The menubar app registers the approval handler before advertising the service, and StdioProxy and MCPService move out of the CLI entry point. --- App/Controllers/ServerController.swift | 8 +- CLI/MCPService.swift | 86 ++++ CLI/MCPServiceLoop.swift | 47 +++ CLI/StdioProxy.swift | 403 +++++++++++++++++++ CLI/main.swift | 518 ------------------------- CLITests/MCPServiceLoopTests.swift | 40 ++ iMCP.xcodeproj/project.pbxproj | 8 + 7 files changed, 590 insertions(+), 520 deletions(-) create mode 100644 CLI/MCPService.swift create mode 100644 CLI/MCPServiceLoop.swift create mode 100644 CLI/StdioProxy.swift create mode 100644 CLITests/MCPServiceLoopTests.swift diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index f6d19fe7..4755634c 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -326,9 +326,10 @@ final class ServerController: ObservableObject { // Initialize bindings from AppStorage before the server starts. await networkManager.updateServiceBindings(self.currentServiceBindings) await networkManager.updateDisabledTools(self.disabledTools, generation: 0) - await self.networkManager.start() - self.updateServerStatus("Running") + // Register the approval handler before advertising the Bonjour + // service. Otherwise the first connection after launch is rejected + // immediately because the handler is still nil. await networkManager.setConnectionApprovalHandler { [weak self] connectionID, clientInfo in guard let self = self else { @@ -358,6 +359,9 @@ final class ServerController: ObservableObject { } } } + + await self.networkManager.start() + self.updateServerStatus("Running") } } diff --git a/CLI/MCPService.swift b/CLI/MCPService.swift new file mode 100644 index 00000000..d9c455ce --- /dev/null +++ b/CLI/MCPService.swift @@ -0,0 +1,86 @@ +import Logging +import MCP +import Network +import ServiceLifecycle + +private let serviceType = "_mcp._tcp" + +private let parameters: NWParameters = { + let parameters = NWParameters.tcp + parameters.acceptLocalOnly = true + parameters.includePeerToPeer = false + + if let tcpOptions = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options { + tcpOptions.version = .v4 + } + + return parameters +}() + +/// Discovers the menubar app over Bonjour and proxies stdio to it. +actor MCPService: Service { + private var currentProxy: StdioProxy? + + func run() async throws { + while !Task.isShuttingDownGracefully { + do { + await log.info("Starting Bonjour service discovery...") + + let browser = NWBrowser( + for: .bonjour(type: serviceType, domain: nil), + using: parameters + ) + + // Prefer a service advertised as iMCP; fall back to any MCP service. + // The helper cancels the browser on every exit path, + // so a timed-out attempt doesn't leak a DNS-SD connection (#192). + let endpoint: NWEndpoint + do { + endpoint = try await BonjourDiscovery.discoverEndpoint( + using: browser, + timeout: .seconds(30), + preferring: { String(describing: $0.endpoint).contains("iMCP") } + ) + } catch BonjourDiscovery.Error.timeout { + await log.error("Bonjour service discovery timed out after 30 seconds") + throw MCPError.internalError("Service discovery timeout") + } + await log.info("Selected endpoint: \(endpoint)") + + await log.info("Creating connection to endpoint...") + + let proxy = StdioProxy( + endpoint: endpoint, + parameters: parameters, + stdinBufferSize: 10 * 1024 * 1024, + networkBufferSize: 10 * 1024 * 1024 + ) + self.currentProxy = proxy + + let sessionError: (any Swift.Error)? + do { + try await proxy.start() + sessionError = nil + } catch { + sessionError = error + } + + switch MCPServiceLoop.decision(error: sessionError) { + case .terminate: + await log.critical("Connection closed, terminating...") + return + case .reconnect(let seconds): + if let sessionError { + await log.error("Connection error: \(sessionError)") + } + await log.info("Will retry connection in \(Int(seconds)) seconds...") + try await Task.sleep(for: .seconds(seconds)) + } + } catch { + await log.error("Connection error: \(error)") + await log.info("Will retry connection in 5 seconds...") + try await Task.sleep(for: .seconds(5)) + } + } + } +} diff --git a/CLI/MCPServiceLoop.swift b/CLI/MCPServiceLoop.swift new file mode 100644 index 00000000..ed554287 --- /dev/null +++ b/CLI/MCPServiceLoop.swift @@ -0,0 +1,47 @@ +import Network + +/// Errors thrown by ``StdioProxy`` I/O handlers. +enum StdioProxyError: Swift.Error { + case networkTimeout + case connectionClosed +} + +/// Chooses whether `imcp-server` should exit or retry after a stdio-proxy session. +enum MCPServiceLoop { + enum Decision: Equatable { + case terminate + case reconnect(seconds: Double) + } + + /// Returns the next action after a proxy session ends. + /// + /// A `nil` error means the proxy returned normally, which happens when the + /// MCP client closed stdin. Reconnecting in that state opens and drops + /// Bonjour connections in a tight loop until the process is killed. + static func decision(error: (any Error)?) -> Decision { + guard let error else { + return .terminate + } + + if error is CancellationError { + return .terminate + } + + if let proxyError = error as? StdioProxyError { + switch proxyError { + case .networkTimeout: + return .reconnect(seconds: 1) + case .connectionClosed: + return .terminate + } + } + + if let nwError = error as? NWError, + nwError.errorCode == 54 || nwError.errorCode == 57 + { + return .terminate + } + + return .reconnect(seconds: 5) + } +} diff --git a/CLI/StdioProxy.swift b/CLI/StdioProxy.swift new file mode 100644 index 00000000..a2c5b20b --- /dev/null +++ b/CLI/StdioProxy.swift @@ -0,0 +1,403 @@ +import Logging +import MCP +import Network +import SystemPackage + +import struct Foundation.Data + +/// A configurable proxy between standard I/O and a network connection. +actor StdioProxy { + private let endpoint: NWEndpoint + private let parameters: NWParameters + private let stdinBufferSize: Int + private let networkBufferSize: Int + + private var connection: NWConnection? + private var isRunning = false + + private var networkToStdoutBuffer = Data() + + /// Creates a proxy that forwards stdin to `endpoint` and network data to stdout. + /// + /// - Parameters: + /// - endpoint: The network endpoint to connect to. + /// - parameters: Network connection parameters. + /// - stdinBufferSize: Buffer size for reading from stdin. + /// - networkBufferSize: Buffer size for reading from the network. + init( + endpoint: NWEndpoint, + parameters: NWParameters = .tcp, + stdinBufferSize: Int = 10 * 1024 * 1024, + networkBufferSize: Int = 10 * 1024 * 1024 + ) { + self.endpoint = endpoint + self.parameters = parameters + self.stdinBufferSize = stdinBufferSize + self.networkBufferSize = networkBufferSize + } + + /// Starts the proxy. + func start() async throws { + guard !isRunning else { return } + isRunning = true + + let connection = NWConnection(to: endpoint, using: parameters) + self.connection = connection + + connection.start(queue: .main) + + connection.stateUpdateHandler = { state in + Task { [weak self] in + await self?.handleConnectionState(state, continuation: nil, connectionState: nil) + } + } + + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + let connectionState = ConnectionState() + connection.stateUpdateHandler = { state in + Task { [weak self] in + await self?.handleConnectionState( + state, + continuation: continuation, + connectionState: connectionState + ) + } + } + } + + var sessionError: (any Swift.Error)? + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { [stdinBufferSize] in + do { + try await self.handleStdinToNetwork(bufferSize: stdinBufferSize) + } catch { + await log.error("Stdin handler failed: \(error)") + throw error + } + } + + group.addTask { [networkBufferSize] in + do { + try await self.handleNetworkToStdout(bufferSize: networkBufferSize) + } catch { + await log.error("Network handler failed: \(error)") + throw error + } + } + + // Wait for any task to complete (or fail). + // On throw, skip a bare `cancelAll()`: the sibling is often + // parked in `NWConnection.receive`, which is not + // cancellation-aware. Cancel the connection first so that + // receive completes and the group can finish unwinding. + let firstError: (any Swift.Error)? + do { + try await group.next() + firstError = nil + } catch { + firstError = error + } + await log.debug("A task completed, cancelling remaining tasks") + await self.stop() + group.cancelAll() + if let firstError { + throw firstError + } + } + } catch { + sessionError = error + } + + await self.stop() + if let sessionError { + throw sessionError + } + } + + /// Stops the proxy and releases the network connection. + func stop() async { + isRunning = false + connection?.cancel() + connection = nil + } + + private func handleConnectionState( + _ state: NWConnection.State, + continuation: CheckedContinuation?, + connectionState: ConnectionState? + ) async { + switch state { + case .ready: + await log.debug("Connection established to \(endpoint)") + if await shouldResume(connectionState: connectionState) { + continuation?.resume() + } + case .failed(let error): + await log.debug("Connection failed: \(error)") + if let continuation = continuation, + await shouldResume(connectionState: connectionState) + { + continuation.resume(throwing: error) + } + await stop() + case .cancelled: + await log.debug("Connection cancelled") + if let continuation = continuation, + await shouldResume(connectionState: connectionState) + { + continuation.resume(throwing: CancellationError()) + } + await stop() + case .waiting(let error): + await log.debug("Connection waiting: \(error)") + case .preparing: + await log.debug("Connection preparing...") + case .setup: + await log.debug("Connection setup...") + @unknown default: + await log.debug("Unknown connection state") + } + } + + private func shouldResume(connectionState: ConnectionState?) async -> Bool { + if let connectionState = connectionState { + return await connectionState.checkAndSetResumed() + } + return true + } + + private func setNonBlocking(fileDescriptor: FileDescriptor) throws { + let flags = fcntl(fileDescriptor.rawValue, F_GETFL) + guard flags >= 0 else { + throw MCPError.transportError(Errno.badFileDescriptor) + } + let result = fcntl(fileDescriptor.rawValue, F_SETFL, flags | O_NONBLOCK) + guard result >= 0 else { + throw MCPError.transportError(Errno.badFileDescriptor) + } + } + + private func handleStdinToNetwork(bufferSize: Int) async throws { + let stdin = FileDescriptor.standardInput + try setNonBlocking(fileDescriptor: stdin) + + var buffer = [UInt8](repeating: 0, count: bufferSize) + var pendingData = Data() + + while true { + guard isRunning, let connection = self.connection else { + await log.debug("Connection no longer active, stopping stdin handler") + throw StdioProxyError.connectionClosed + } + + if connection.state != .ready && connection.state != .preparing { + await log.debug( + "Connection state changed to \(connection.state), stopping stdin handler" + ) + throw StdioProxyError.connectionClosed + } + + do { + let bytesRead = try buffer.withUnsafeMutableBufferPointer { pointer in + try stdin.read(into: UnsafeMutableRawBufferPointer(pointer)) + } + + if bytesRead == 0 { + // The MCP client closed stdin. Exit rather than return: + // a clean return made MCPService reconnect in a tight loop + // while stdin stayed at EOF. + await log.debug("EOF reached on stdin, stopping stdin handler") + throw StdioProxyError.connectionClosed + } + + if bytesRead > 0 { + pendingData.append(contentsOf: buffer[0 ..< bytesRead]) + + let isOnlyWhitespace = pendingData.allSatisfy { + let char = Character(UnicodeScalar($0)) + return char.isWhitespace || char.isNewline + } + + if !isOnlyWhitespace && !pendingData.isEmpty { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + connection.send( + content: pendingData, + completion: .contentProcessed { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + ) + } + + await log.debug("Sent \(pendingData.count) bytes to network") + } else if isOnlyWhitespace && !pendingData.isEmpty { + await log.trace( + "Skipping send of \(pendingData.count) whitespace-only bytes" + ) + } + + pendingData.removeAll(keepingCapacity: true) + } + } catch let error as StdioProxyError { + throw error + } catch { + if let posixError = error as? Errno, posixError == .wouldBlock { + try await Task.sleep(for: .milliseconds(10)) + continue + } + + await log.error("Error in stdin handler: \(error)") + throw error + } + } + } + + private func handleNetworkToStdout(bufferSize: Int) async throws { + let stdout = FileDescriptor.standardOutput + var consecutiveEmptyReads = 0 + let maxConsecutiveEmptyReads = 100 + + while true { + guard isRunning, let connection = self.connection else { + await log.debug("Connection no longer active, stopping network handler") + throw StdioProxyError.connectionClosed + } + + if connection.state != .ready && connection.state != .preparing { + await log.debug( + "Connection state changed to \(connection.state), stopping network handler" + ) + throw StdioProxyError.connectionClosed + } + + do { + if consecutiveEmptyReads > 0 + && consecutiveEmptyReads % maxConsecutiveEmptyReads == 0 + { + if consecutiveEmptyReads > maxConsecutiveEmptyReads * 10 { + await log.warning( + "Network read timed out after \(consecutiveEmptyReads) consecutive empty reads" + ) + throw StdioProxyError.networkTimeout + } + } + + let data = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + connection.receive(minimumIncompleteLength: 1, maximumLength: bufferSize) { + data, + _, + isComplete, + error in + if let error = error { + continuation.resume(throwing: error) + return + } + + if let data = data { + continuation.resume(returning: data) + } else if isComplete { + log.debug("Network connection complete") + continuation.resume(throwing: StdioProxyError.connectionClosed) + } else { + continuation.resume(returning: Data()) + } + } + } + + var processedData = data + + if NetworkTransport.Heartbeat.isHeartbeat(processedData) { + await log.debug( + "Heartbeat signature detected in received network data using MCP definition." + ) + + if let heartbeat = NetworkTransport.Heartbeat.from(data: processedData) { + let heartbeatLength = heartbeat.rawValue.count + await log.debug( + "Full MCP heartbeat message (\(heartbeatLength) bytes) received from network, skipping output." + ) + processedData = processedData.dropFirst(heartbeatLength) + } else { + let expectedHeartbeatLength = MCP.NetworkTransport.Heartbeat().rawValue + .count + await log.debug( + "Partial MCP heartbeat message (<\(expectedHeartbeatLength) bytes) received, discarding this chunk to prevent garbled output." + ) + processedData = Data() + } + } + + if processedData.isEmpty { + if !data.isEmpty { + consecutiveEmptyReads = 0 + } else { + consecutiveEmptyReads += 1 + } + try await Task.sleep(for: .milliseconds(10)) + continue + } else { + consecutiveEmptyReads = 0 + await log.debug( + "Received \(processedData.count) bytes of application data from network" + ) + } + + networkToStdoutBuffer.append(processedData) + + while let newlineIndex = networkToStdoutBuffer.firstIndex(of: UInt8(ascii: "\n")) { + let messageData = networkToStdoutBuffer[..) in - let connectionState = ConnectionState() - connection.stateUpdateHandler = { state in - Task { [weak self] in - await self?.handleConnectionState( - state, - continuation: continuation, - connectionState: connectionState - ) - } - } - } - - // Create a structured concurrency task group for handling I/O - try await withThrowingTaskGroup(of: Void.self) { group in - // Add task for handling stdin to network - group.addTask { [stdinBufferSize] in - do { - try await self.handleStdinToNetwork(bufferSize: stdinBufferSize) - } catch { - await log.error("Stdin handler failed: \(error)") - throw error - } - } - - // Add task for handling network to stdout - group.addTask { [networkBufferSize] in - do { - try await self.handleNetworkToStdout(bufferSize: networkBufferSize) - } catch { - await log.error("Network handler failed: \(error)") - throw error - } - } - - // Wait for any task to complete (or fail) - try await group.next() - await log.debug("A task completed, cancelling remaining tasks") - - // If we get here, one of the tasks completed or failed - // Cancel all remaining tasks - group.cancelAll() - - // Stop the proxy - await self.stop() - } - } - - /// Stops the proxy and cleans up resources - func stop() async { - isRunning = false - connection?.cancel() - connection = nil - } - - /// Handles connection state changes - private func handleConnectionState( - _ state: NWConnection.State, - continuation: CheckedContinuation?, - connectionState: ConnectionState? - ) async { - switch state { - case .ready: - await log.debug("Connection established to \(endpoint)") - if await shouldResume(connectionState: connectionState) { - continuation?.resume() - } - case .failed(let error): - await log.debug("Connection failed: \(error)") - if let continuation = continuation, - await shouldResume(connectionState: connectionState) - { - continuation.resume(throwing: error) - } - await stop() - case .cancelled: - await log.debug("Connection cancelled") - if let continuation = continuation, - await shouldResume(connectionState: connectionState) - { - continuation.resume(throwing: CancellationError()) - } - await stop() - case .waiting(let error): - await log.debug("Connection waiting: \(error)") - case .preparing: - await log.debug("Connection preparing...") - case .setup: - await log.debug("Connection setup...") - @unknown default: - await log.debug("Unknown connection state") - } - } - - private func shouldResume(connectionState: ConnectionState?) async -> Bool { - if let connectionState = connectionState { - return await connectionState.checkAndSetResumed() - } - return true - } - - private func setNonBlocking(fileDescriptor: FileDescriptor) throws { - let flags = fcntl(fileDescriptor.rawValue, F_GETFL) - guard flags >= 0 else { - throw MCPError.transportError(Errno.badFileDescriptor) - } - let result = fcntl(fileDescriptor.rawValue, F_SETFL, flags | O_NONBLOCK) - guard result >= 0 else { - throw MCPError.transportError(Errno.badFileDescriptor) - } - } - - /// Handles forwarding data from stdin to the network - private func handleStdinToNetwork(bufferSize: Int) async throws { - let stdin = FileDescriptor.standardInput - try setNonBlocking(fileDescriptor: stdin) - - var buffer = [UInt8](repeating: 0, count: bufferSize) - var pendingData = Data() - - while true { - // Check connection state at the beginning of each loop iteration - guard isRunning, let connection = self.connection else { - await log.debug("Connection no longer active, stopping stdin handler") - throw StdioProxyError.connectionClosed - } - - // Also check connection state - if connection.state != .ready && connection.state != .preparing { - await log.debug( - "Connection state changed to \(connection.state), stopping stdin handler" - ) - throw StdioProxyError.connectionClosed - } - - do { - // Read data from stdin using SystemPackage approach - let bytesRead = try buffer.withUnsafeMutableBufferPointer { pointer in - try stdin.read(into: UnsafeMutableRawBufferPointer(pointer)) - } - - if bytesRead == 0 { - // EOF reached - await log.debug("EOF reached on stdin, stopping stdin handler") - break - } - - if bytesRead > 0 { - // Append the read bytes to pending data - pendingData.append(contentsOf: buffer[0 ..< bytesRead]) - - // Check if the data is only whitespace - let isOnlyWhitespace = pendingData.allSatisfy { - let char = Character(UnicodeScalar($0)) - return char.isWhitespace || char.isNewline - } - - // Only send if we have non-whitespace content - if !isOnlyWhitespace && !pendingData.isEmpty { - // Send data to the network connection - try await withCheckedThrowingContinuation { - (continuation: CheckedContinuation) in - connection.send( - content: pendingData, - completion: .contentProcessed { error in - if let error = error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } - ) - } - - await log.debug("Sent \(pendingData.count) bytes to network") - } else if isOnlyWhitespace && !pendingData.isEmpty { - await log.trace( - "Skipping send of \(pendingData.count) whitespace-only bytes" - ) - } - - // Clear pending data after processing - pendingData.removeAll(keepingCapacity: true) - } - } catch { - if let posixError = error as? Errno, posixError == .wouldBlock { - try await Task.sleep(for: .milliseconds(10)) // Keep the sleep to yield CPU - continue - } - - await log.error("Error in stdin handler: \(error)") - throw error - } - } - - await log.debug("Stdin handler task completed") - } - - /// Handles forwarding data from the network to stdout - private func handleNetworkToStdout(bufferSize: Int) async throws { - let stdout = FileDescriptor.standardOutput - var consecutiveEmptyReads = 0 - let maxConsecutiveEmptyReads = 100 // After this many consecutive empty reads, we'll check connection state - - while true { - // Check connection state at the beginning of each loop iteration - guard isRunning, let connection = self.connection else { - await log.debug("Connection no longer active, stopping network handler") - throw StdioProxyError.connectionClosed - } - - // Also check connection state - if connection.state != .ready && connection.state != .preparing { - await log.debug( - "Connection state changed to \(connection.state), stopping network handler" - ) - throw StdioProxyError.connectionClosed - } - - do { - // Check connection state periodically if we're getting consecutive empty reads - if consecutiveEmptyReads > 0 - && consecutiveEmptyReads % maxConsecutiveEmptyReads == 0 - { - // If we've had too many empty reads, consider it a timeout - if consecutiveEmptyReads > maxConsecutiveEmptyReads * 10 { - await log.warning( - "Network read timed out after \(consecutiveEmptyReads) consecutive empty reads" - ) - throw StdioProxyError.networkTimeout - } - } - - // Receive data from the network connection - let data = try await withCheckedThrowingContinuation { - (continuation: CheckedContinuation) in - connection.receive(minimumIncompleteLength: 1, maximumLength: bufferSize) { - data, - _, - isComplete, - error in - if let error = error { - continuation.resume(throwing: error) - return - } - - if let data = data { - continuation.resume(returning: data) - } else if isComplete { - log.debug("Network connection complete") - continuation.resume(throwing: StdioProxyError.connectionClosed) - } else { - continuation.resume(returning: Data()) - } - } - } - - var processedData = data - - // Check for and filter out heartbeat messages using MCP.NetworkTransport.Heartbeat - // Assuming MCP module and NetworkTransport.Heartbeat are available - if NetworkTransport.Heartbeat.isHeartbeat(processedData) { - await log.debug( - "Heartbeat signature detected in received network data using MCP definition." - ) - - // Try to parse a full heartbeat. MCP.NetworkTransport.Heartbeat.from(data:) checks for minimum length internally. - if let heartbeat = NetworkTransport.Heartbeat.from(data: processedData) { - let heartbeatLength = heartbeat.rawValue.count // This should typically be 12 - await log.debug( - "Full MCP heartbeat message (\(heartbeatLength) bytes) received from network, skipping output." - ) - // Remove the full heartbeat from the data - processedData = processedData.dropFirst(heartbeatLength) - } else { - // MCP.NetworkTransport.Heartbeat.isHeartbeat was true, but .from(data:) failed. - // This means we have the magic bytes but not the full message (e.g., data length < 12 but >= 4). - let expectedHeartbeatLength = MCP.NetworkTransport.Heartbeat().rawValue - .count // Get expected length (12) - await log.debug( - "Partial MCP heartbeat message (<\(expectedHeartbeatLength) bytes) received, discarding this chunk to prevent garbled output." - ) - processedData = Data() // Discard the chunk - } - } - - if processedData.isEmpty { - // No data available (or entire chunk was a heartbeat), yield to other tasks - // If original data was not empty, but processedData is, it means it was a heartbeat. - if !data.isEmpty { // Original data was not empty, so this was a heartbeat - consecutiveEmptyReads = 0 // Reset counter as we did receive something (a heartbeat) - } else { - consecutiveEmptyReads += 1 - } - try await Task.sleep(for: .milliseconds(10)) - continue - } else { - // Reset counter when we get actual data (not just a heartbeat) - consecutiveEmptyReads = 0 - await log.debug( - "Received \(processedData.count) bytes of application data from network" - ) - } - - // Add data to buffer for message assembly - networkToStdoutBuffer.append(processedData) - - // Process complete messages (delimited by newlines) - while let newlineIndex = networkToStdoutBuffer.firstIndex(of: UInt8(ascii: "\n")) { - let messageData = networkToStdoutBuffer[.. Date: Sat, 5 Sep 2026 12:59:57 -0700 Subject: [PATCH 24/56] Drop the module cache before the CI cache is saved --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46f9b41f..540d5bba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,3 +50,11 @@ jobs: - name: Build run: xcodebuild -quiet -scheme iMCP -configuration Debug -destination "platform=macOS" 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 + # toolchain file timestamps, which makes a restored module cache fatal. + # Remove it before the cache is saved at the end of the job. + - name: Drop the module cache before saving + if: always() + run: rm -rf ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex From a408030abe43ff5239f98a82a42d4855de8c7a65 Mon Sep 17 00:00:00 2001 From: patp Date: Sun, 6 Sep 2026 14:42:59 -0400 Subject: [PATCH 25/56] Don't drop company-type contacts from search and list (#206) --- App/Services/Contacts.swift | 92 ++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 1 deletion(-) diff --git a/App/Services/Contacts.swift b/App/Services/Contacts.swift index 76940970..696918b6 100644 --- a/App/Services/Contacts.swift +++ b/App/Services/Contacts.swift @@ -221,7 +221,59 @@ final class ContactsService: Service { ) } - return contacts.compactMap { Person($0) } + return contacts.compactMap { listedPerson(from: $0) } + } + + Tool( + name: "contacts_list", + description: + "List contacts in a stable order. Returns every contact by default; use limit and offset to page through large address books.", + inputSchema: .object( + properties: [ + "limit": .integer( + description: "Maximum number of contacts to return", + minimum: 1 + ), + "offset": .integer( + description: "Number of contacts to skip, in the same stable order", + default: .int(0), + minimum: 0 + ), + ], + additionalProperties: false + ), + annotations: .init( + title: "List Contacts", + readOnlyHint: true, + openWorldHint: false + ) + ) { arguments in + var limit = Int.max + if case .int(let value) = arguments["limit"], value > 0 { + limit = value + } + var offset = 0 + if case .int(let value) = arguments["offset"], value > 0 { + offset = value + } + + let contacts = try await self.runContactStore { + var results: [CNContact] = [] + let request = CNContactFetchRequest(keysToFetch: contactKeys) + request.unifyResults = true + try self.contactStore.enumerateContacts(with: request) { contact, _ in + results.append(contact) + } + return results + } + + // Map then page. `Person(CNContact)` is nil for `.organization` cards, + // so paging the raw store and compactMapping afterward can return fewer + // than `limit` items for a full page — callers treat that as EOF and + // never request the next offset. + let people = contacts.compactMap { listedPerson(from: $0) } + .sorted { ($0.identifier ?? "") < ($1.identifier ?? "") } + return Array(people.dropFirst(offset).prefix(limit)) } Tool( @@ -329,3 +381,41 @@ final class ContactsService: Service { } } } + +/// `Person(CNContact)` returns nil when `contactType == .organization`. Contacts.app +/// still stores people that way (the company toggle) with given/family names filled +/// in. Represent those cards as `Person` so search and list don't omit them. +private func listedPerson(from contact: CNContact) -> Person? { + if let person = Person(contact) { + return person + } + guard contact.contactType == .organization else { return nil } + + let display = [contact.givenName, contact.familyName] + .filter { !$0.isEmpty } + .joined(separator: " ") + let fallback = display.isEmpty ? contact.organizationName : display + guard !fallback.isEmpty else { return nil } + + var person = Person(name: fallback) + person.identifier = contact.identifier + person.givenName = contact.givenName.isEmpty ? nil : contact.givenName + person.familyName = contact.familyName.isEmpty ? nil : contact.familyName + if person.givenName == nil && person.familyName == nil { + person.familyName = contact.organizationName + } + person.email = + contact.emailAddresses.isEmpty + ? nil : contact.emailAddresses.map { $0.value as String } + person.telephone = + contact.phoneNumbers.isEmpty + ? nil : contact.phoneNumbers.map { $0.value.stringValue } + person.jobTitle = contact.jobTitle.isEmpty ? nil : contact.jobTitle + if !contact.organizationName.isEmpty { + person.worksFor = Organization(name: contact.organizationName) + } + if !contact.postalAddresses.isEmpty { + person.address = contact.postalAddresses.map { PostalAddress($0.value) } + } + return person +} From 3aef7aeb6eb0ee09072d7ba0c5a9363aa0bf2399 Mon Sep 17 00:00:00 2001 From: mattt Date: Sun, 6 Sep 2026 11:43:34 -0700 Subject: [PATCH 26/56] Reuse Person(CNContact) for company-toggled cards and sort contacts_list by the user's default order Flip contactType on a mutable copy instead of re-implementing the mapping, so company-toggled cards keep URLs, birthday, social profiles, IM, and relations. Cards with only an organization name stay excluded. Route contacts_me through the same helper. Follow-up to #206. --- App/Services/Contacts.swift | 48 +++++++++++-------------------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/App/Services/Contacts.swift b/App/Services/Contacts.swift index 696918b6..48938e11 100644 --- a/App/Services/Contacts.swift +++ b/App/Services/Contacts.swift @@ -147,7 +147,7 @@ final class ContactsService: Service { let contact = try await self.runContactStore { try self.contactStore.unifiedMeContactWithKeys(toFetch: contactKeys) } - return Person(contact) + return listedPerson(from: contact) } Tool( @@ -261,6 +261,7 @@ final class ContactsService: Service { var results: [CNContact] = [] let request = CNContactFetchRequest(keysToFetch: contactKeys) request.unifyResults = true + request.sortOrder = .userDefault try self.contactStore.enumerateContacts(with: request) { contact, _ in results.append(contact) } @@ -272,7 +273,6 @@ final class ContactsService: Service { // than `limit` items for a full page — callers treat that as EOF and // never request the next offset. let people = contacts.compactMap { listedPerson(from: $0) } - .sorted { ($0.identifier ?? "") < ($1.identifier ?? "") } return Array(people.dropFirst(offset).prefix(limit)) } @@ -382,40 +382,20 @@ final class ContactsService: Service { } } -/// `Person(CNContact)` returns nil when `contactType == .organization`. Contacts.app -/// still stores people that way (the company toggle) with given/family names filled -/// in. Represent those cards as `Person` so search and list don't omit them. +/// `Person(CNContact)` returns nil when `contactType == .organization`. +/// Contacts.app still stores people that way (the "Company" toggle) +/// with given and family names filled in. +/// Treat those cards as people so search and list don't omit them; +/// cards that carry only an organization name stay excluded. private func listedPerson(from contact: CNContact) -> Person? { if let person = Person(contact) { return person } - guard contact.contactType == .organization else { return nil } - - let display = [contact.givenName, contact.familyName] - .filter { !$0.isEmpty } - .joined(separator: " ") - let fallback = display.isEmpty ? contact.organizationName : display - guard !fallback.isEmpty else { return nil } - - var person = Person(name: fallback) - person.identifier = contact.identifier - person.givenName = contact.givenName.isEmpty ? nil : contact.givenName - person.familyName = contact.familyName.isEmpty ? nil : contact.familyName - if person.givenName == nil && person.familyName == nil { - person.familyName = contact.organizationName - } - person.email = - contact.emailAddresses.isEmpty - ? nil : contact.emailAddresses.map { $0.value as String } - person.telephone = - contact.phoneNumbers.isEmpty - ? nil : contact.phoneNumbers.map { $0.value.stringValue } - person.jobTitle = contact.jobTitle.isEmpty ? nil : contact.jobTitle - if !contact.organizationName.isEmpty { - person.worksFor = Organization(name: contact.organizationName) - } - if !contact.postalAddresses.isEmpty { - person.address = contact.postalAddresses.map { PostalAddress($0.value) } - } - return person + guard contact.contactType == .organization, + !contact.givenName.isEmpty || !contact.familyName.isEmpty, + let copy = contact.mutableCopy() as? CNMutableContact + else { return nil } + + copy.contactType = .person + return Person(copy) } From da2dc67f41b9c92def23f0fe60d8fa8328e38909 Mon Sep 17 00:00:00 2001 From: lel <88410471+Iamlel@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:08:10 -0400 Subject: [PATCH 27/56] Add an events_delete calendar tool (#209) --- App/Services/Calendar.swift | 64 +++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/App/Services/Calendar.swift b/App/Services/Calendar.swift index 161c084a..0db759d1 100644 --- a/App/Services/Calendar.swift +++ b/App/Services/Calendar.swift @@ -541,5 +541,69 @@ final class CalendarService: Service { result.identifier = event.eventIdentifier return result } + + Tool( + name: "events_delete", + description: "Delete a calendar event by identifier", + inputSchema: .object( + properties: [ + "identifier": .string(description: "The event identifier"), + "span": .string( + description: + "For recurring events: delete only this occurrence, or this and all future events", + default: .string("thisEvent"), + enum: ["thisEvent", "futureEvents"] + ), + ], + required: ["identifier"], + additionalProperties: false + ), + annotations: .init( + title: "Delete Event", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false + ) + ) { arguments in + guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { + log.error("Calendar access not authorized") + throw NSError( + domain: "CalendarError", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Calendar access not authorized"] + ) + } + + guard case .string(let identifier) = arguments["identifier"] else { + throw NSError( + domain: "CalendarError", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Event identifier is required"] + ) + } + + // For a recurring series this returns the first occurrence, + // so `.futureEvents` on it removes the whole series. + guard let event = self.eventStore.event(withIdentifier: identifier) else { + throw NSError( + domain: "CalendarError", + code: 3, + userInfo: [ + NSLocalizedDescriptionKey: "No event found with identifier \(identifier)" + ] + ) + } + + let span: EKSpan = + arguments["span"]?.stringValue == "futureEvents" ? .futureEvents : .thisEvent + + try self.eventStore.remove(event, span: span, commit: true) + + return Value.object([ + "deleted": .bool(true), + "identifier": .string(identifier), + ]) + } } } From 43e61bf48e191805083e8b605bdb05d55d12a044 Mon Sep 17 00:00:00 2001 From: mattt Date: Sun, 6 Sep 2026 12:08:07 -0700 Subject: [PATCH 28/56] Target the exact occurrence in events_delete and reject unknown spans Every occurrence of a recurring event shares one identifier, and event(withIdentifier:) resolves it to the first occurrence, so thisEvent could remove the wrong one and futureEvents could remove the whole series. An optional start date now refetches the exact occurrence, recurring events without one are rejected, and an unknown span throws instead of defaulting to thisEvent. Follow-up to #209. --- App/Services/Calendar.swift | 88 ++++++++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/App/Services/Calendar.swift b/App/Services/Calendar.swift index 0db759d1..e248055e 100644 --- a/App/Services/Calendar.swift +++ b/App/Services/Calendar.swift @@ -544,10 +544,15 @@ final class CalendarService: Service { Tool( name: "events_delete", - description: "Delete a calendar event by identifier", + description: + "Delete a calendar event by identifier. For a recurring event, pass the start date of the occurrence to delete.", inputSchema: .object( properties: [ "identifier": .string(description: "The event identifier"), + "start": .string( + description: + "Start date of the occurrence to delete (ISO 8601). Required for recurring events, since every occurrence shares the same identifier." + ), "span": .string( description: "For recurring events: delete only this occurrence, or this and all future events", @@ -583,9 +588,29 @@ final class CalendarService: Service { ) } - // For a recurring series this returns the first occurrence, - // so `.futureEvents` on it removes the whole series. - guard let event = self.eventStore.event(withIdentifier: identifier) else { + let span: EKSpan + switch arguments["span"]?.stringValue { + case nil, "thisEvent": + span = .thisEvent + case "futureEvents": + span = .futureEvents + case let other?: + throw NSError( + domain: "CalendarError", + code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "Invalid span \"\(other)\". Expected \"thisEvent\" or \"futureEvents\"." + ] + ) + } + + // Every occurrence of a recurring event shares one identifier, + // and `event(withIdentifier:)` always resolves it to the first occurrence. + // When a start date is given, + // refetch that exact occurrence so `thisEvent` removes the right one + // and `futureEvents` starts from it rather than from the whole series. + guard let anyOccurrence = self.eventStore.event(withIdentifier: identifier) else { throw NSError( domain: "CalendarError", code: 3, @@ -595,8 +620,59 @@ final class CalendarService: Service { ) } - let span: EKSpan = - arguments["span"]?.stringValue == "futureEvents" ? .futureEvents : .thisEvent + let event: EKEvent + if case .string(let startString) = arguments["start"] { + guard + let parsedStart = ISO8601DateFormatter.parsedLenientISO8601Date( + fromISO8601String: startString + ) + else { + throw NSError( + domain: "CalendarError", + code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "Invalid start date format. Expected ISO 8601 format." + ] + ) + } + let startDate = Calendar.current.normalizedStartDate( + from: parsedStart.date, + isDateOnly: parsedStart.isDateOnly + ) + let predicate = self.eventStore.predicateForEvents( + withStart: startDate, + end: startDate.addingTimeInterval(1), + calendars: nil + ) + guard + let occurrence = self.eventStore.events(matching: predicate).first(where: { + $0.eventIdentifier == identifier + && abs($0.startDate.timeIntervalSince(startDate)) < 1 + }) + else { + throw NSError( + domain: "CalendarError", + code: 3, + userInfo: [ + NSLocalizedDescriptionKey: + "No occurrence of event \(identifier) starts at \(startString)" + ] + ) + } + event = occurrence + } else if anyOccurrence.hasRecurrenceRules { + throw NSError( + domain: "CalendarError", + code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "Event \(identifier) is recurring. Pass the start date of the occurrence to delete." + ] + ) + } else { + event = anyOccurrence + } try self.eventStore.remove(event, span: span, commit: true) From ecef7a73af750c103120686a30424126749975fd Mon Sep 17 00:00:00 2001 From: Mattt Date: Tue, 8 Sep 2026 10:48:50 -0700 Subject: [PATCH 29/56] Rename Call History to Phone and add a phone_call tool (#213) * Rename Call History service to Phone * Use green for the Phone service to match the app icon * Add a phone_call tool that dials a number via tel: URL * Model the phone_call result as a CommunicateAction type * Move call history SQLite access into CallHistoryDatabase and CallRecord types * Validate phone tool arguments and request database access only on fetch * Incorporate review feedback * Reject * and # in phone_call numbers since the Phone app will not dial them --- App/Controllers/ServerController.swift | 22 +- App/Services/CallHistory.swift | 457 ----------------- App/Services/Phone.swift | 671 +++++++++++++++++++++++++ 3 files changed, 682 insertions(+), 468 deletions(-) delete mode 100644 App/Services/CallHistory.swift create mode 100644 App/Services/Phone.swift diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 4755634c..9f33527a 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -51,12 +51,12 @@ enum ServiceRegistry { static let services: [any Service] = { var services: [any Service] = [ CalendarService.shared, - CallHistoryService.shared, CaptureService.shared, ContactsService.shared, LocationService.shared, MapsService.shared, MessageService.shared, + PhoneService.shared, RemindersService.shared, ShortcutsService.shared, UtilitiesService.shared, @@ -69,12 +69,12 @@ enum ServiceRegistry { static func configureServices( calendarEnabled: Binding, - callHistoryEnabled: Binding, captureEnabled: Binding, contactsEnabled: Binding, locationEnabled: Binding, mapsEnabled: Binding, messagesEnabled: Binding, + phoneEnabled: Binding, remindersEnabled: Binding, shortcutsEnabled: Binding, utilitiesEnabled: Binding, @@ -88,13 +88,6 @@ enum ServiceRegistry { service: CalendarService.shared, binding: calendarEnabled ), - ServiceConfig( - name: "Call History", - iconName: "phone.fill", - color: .green.mix(with: .blue, by: 0.3), - service: CallHistoryService.shared, - binding: callHistoryEnabled - ), ServiceConfig( name: "Capture", iconName: "camera.on.rectangle.fill", @@ -130,6 +123,13 @@ enum ServiceRegistry { service: MessageService.shared, binding: messagesEnabled ), + ServiceConfig( + name: "Phone", + iconName: "phone.fill", + color: .green, + service: PhoneService.shared, + binding: phoneEnabled + ), ServiceConfig( name: "Reminders", iconName: "list.bullet", @@ -175,12 +175,12 @@ final class ServerController: ObservableObject { // MARK: - AppStorage for Service Enablement States @AppStorage("calendarEnabled") private var calendarEnabled = false - @AppStorage("callHistoryEnabled") private var callHistoryEnabled = false @AppStorage("captureEnabled") private var captureEnabled = false @AppStorage("contactsEnabled") private var contactsEnabled = false @AppStorage("locationEnabled") private var locationEnabled = false @AppStorage("mapsEnabled") private var mapsEnabled = true // Default enabled @AppStorage("messagesEnabled") private var messagesEnabled = false + @AppStorage("phoneEnabled") private var phoneEnabled = false @AppStorage("remindersEnabled") private var remindersEnabled = false @AppStorage("shortcutsEnabled") private var shortcutsEnabled = false @AppStorage("utilitiesEnabled") private var utilitiesEnabled = true // Default enabled @@ -197,12 +197,12 @@ final class ServerController: ObservableObject { var computedServiceConfigs: [ServiceConfig] { ServiceRegistry.configureServices( calendarEnabled: $calendarEnabled, - callHistoryEnabled: $callHistoryEnabled, captureEnabled: $captureEnabled, contactsEnabled: $contactsEnabled, locationEnabled: $locationEnabled, mapsEnabled: $mapsEnabled, messagesEnabled: $messagesEnabled, + phoneEnabled: $phoneEnabled, remindersEnabled: $remindersEnabled, shortcutsEnabled: $shortcutsEnabled, utilitiesEnabled: $utilitiesEnabled, diff --git a/App/Services/CallHistory.swift b/App/Services/CallHistory.swift deleted file mode 100644 index cda077da..00000000 --- a/App/Services/CallHistory.swift +++ /dev/null @@ -1,457 +0,0 @@ -import AppKit -import OSLog -import SQLite3 - -private let log = Logger.service("callhistory") -private let callHistoryDatabasePath = - "/Users/\(NSUserName())/Library/Application Support/CallHistoryDB/CallHistory.storedata" -private let callHistoryDatabaseBookmarkKey: String = "me.mattt.iMCP.callHistoryDatabaseBookmark" -private let defaultLimit = 30 - -// Apple's Core Data epoch: 2001-01-01 00:00:00 UTC -private let coreDataEpoch: TimeInterval = 978_307_200 - -final class CallHistoryService: NSObject, Service, NSOpenSavePanelDelegate { - static let shared = CallHistoryService() - - func activate() async throws { - log.debug("Starting call history service activation") - - if canAccessDatabaseAtDefaultPath { - log.debug("Successfully activated using default database path") - return - } - - if canAccessDatabaseUsingBookmark { - log.debug("Successfully activated using stored bookmark") - return - } - - log.debug("Opening file picker for manual database selection") - guard try await showDatabaseAccessAlert() else { - throw DatabaseAccessError.userDeclinedAccess - } - - let selectedURL = try await showFilePicker() - - guard FileManager.default.isReadableFile(atPath: selectedURL.path) else { - throw DatabaseAccessError.fileNotReadable - } - - storeBookmark(for: selectedURL) - log.debug("Successfully activated call history service") - } - - var isActivated: Bool { - get async { - let isActivated = canAccessDatabaseAtDefaultPath || canAccessDatabaseUsingBookmark - log.debug("Call history service activation status: \(isActivated)") - return isActivated - } - } - - var tools: [Tool] { - Tool( - name: "callhistory_fetch", - description: "Fetch phone call history from the Mac (synced from iPhone)", - inputSchema: .object( - properties: [ - "participant": .string( - description: - "Phone number or contact name to filter by (partial match supported)" - ), - "start": .string( - description: - "Start of the date range (inclusive). ISO 8601 format. If timezone is omitted, local time is assumed.", - format: .dateTime - ), - "end": .string( - description: - "End of the date range (exclusive). ISO 8601 format. If timezone is omitted, local time is assumed.", - format: .dateTime - ), - "call_type": .string( - description: "Filter by call type, or omit for all", - enum: ["incoming", "outgoing", "missed"] - ), - "limit": .integer( - description: "Maximum calls to return", - default: .int(defaultLimit) - ), - ], - additionalProperties: false - ), - annotations: .init( - title: "Fetch Call History", - readOnlyHint: true, - openWorldHint: false - ) - ) { arguments in - log.debug("Starting call history fetch with arguments: \(arguments)") - try await self.activate() - - let participant = arguments["participant"]?.stringValue - let callTypeFilter = arguments["call_type"]?.stringValue - let limit = arguments["limit"]?.intValue ?? defaultLimit - - var startDate: Date? - var endDate: Date? - if let startStr = arguments["start"]?.stringValue, - let parsedStart = ISO8601DateFormatter.parsedLenientISO8601Date( - fromISO8601String: startStr - ) - { - startDate = parsedStart.date - } - if let endStr = arguments["end"]?.stringValue, - let parsedEnd = ISO8601DateFormatter.parsedLenientISO8601Date( - fromISO8601String: endStr - ) - { - endDate = parsedEnd.date - } - - let databaseURL = try self.resolveDatabaseURL() - let calls = try self.fetchCalls( - from: databaseURL, - participant: participant, - startDate: startDate, - endDate: endDate, - callTypeFilter: callTypeFilter, - limit: limit - ) - - log.debug("Successfully fetched \(calls.count) calls") - return [ - "@context": "https://schema.org", - "@type": "ItemList", - "name": "Call History", - "numberOfItems": .int(calls.count), - "itemListElement": Value.array(calls.map({ .object($0) })), - ] - } - } - - // MARK: - Database Access - - private var canAccessDatabaseAtDefaultPath: Bool { - return FileManager.default.isReadableFile(atPath: callHistoryDatabasePath) - } - - private var canAccessDatabaseUsingBookmark: Bool { - do { - let url = try resolveBookmarkURL() - return try withSecurityScopedAccess(url) { url in - FileManager.default.isReadableFile(atPath: url.path) - } - } catch { - log.error("Error accessing database with bookmark: \(error.localizedDescription)") - return false - } - } - - private func resolveDatabaseURL() throws -> URL { - if canAccessDatabaseAtDefaultPath { - return URL(fileURLWithPath: callHistoryDatabasePath) - } - return try resolveBookmarkURL() - } - - private func resolveBookmarkURL() throws -> URL { - guard - let bookmarkData = UserDefaults.standard.data(forKey: callHistoryDatabaseBookmarkKey) - else { - throw DatabaseAccessError.noBookmarkFound - } - - var isStale = false - return try URL( - resolvingBookmarkData: bookmarkData, - options: .withSecurityScope, - relativeTo: nil, - bookmarkDataIsStale: &isStale - ) - } - - private func withSecurityScopedAccess(_ url: URL, _ operation: (URL) throws -> T) throws -> T { - guard url.startAccessingSecurityScopedResource() else { - log.error("Failed to start accessing security-scoped resource") - throw DatabaseAccessError.securityScopeAccessFailed - } - defer { url.stopAccessingSecurityScopedResource() } - return try operation(url) - } - - // MARK: - SQLite Query - - private func fetchCalls( - from databaseURL: URL, - participant: String?, - startDate: Date?, - endDate: Date?, - callTypeFilter: String?, - limit: Int - ) throws -> [[String: Value]] { - let accessBlock: (URL) throws -> [[String: Value]] = { url in - var db: OpaquePointer? - guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { - let errorMessage = String(cString: sqlite3_errmsg(db)) - sqlite3_close(db) - throw DatabaseAccessError.sqliteError(errorMessage) - } - defer { sqlite3_close(db) } - - // Build query with filters - var conditions: [String] = [] - var params: [Any] = [] - - if let participant = participant { - conditions.append("(c.ZADDRESS LIKE ? OR c.ZNAME LIKE ?)") - params.append("%\(participant)%") - params.append("%\(participant)%") - } - - if let startDate = startDate { - let coreDataTimestamp = startDate.timeIntervalSince1970 - coreDataEpoch - conditions.append("c.ZDATE >= ?") - params.append(coreDataTimestamp) - } - - if let endDate = endDate { - let coreDataTimestamp = endDate.timeIntervalSince1970 - coreDataEpoch - conditions.append("c.ZDATE < ?") - params.append(coreDataTimestamp) - } - - if let callTypeFilter = callTypeFilter { - switch callTypeFilter.lowercased() { - case "incoming": - conditions.append("c.ZORIGINATED = 0 AND c.ZANSWERED = 1") - case "outgoing": - conditions.append("c.ZORIGINATED = 1") - case "missed": - conditions.append("c.ZORIGINATED = 0 AND c.ZANSWERED = 0") - default: - break - } - } - - let whereClause = - conditions.isEmpty ? "" : "WHERE " + conditions.joined(separator: " AND ") - - let query = """ - SELECT - c.Z_PK, - c.ZADDRESS, - c.ZNAME, - c.ZDATE, - c.ZDURATION, - c.ZORIGINATED, - c.ZANSWERED, - c.ZSERVICE_PROVIDER - FROM ZCALLRECORD c - \(whereClause) - ORDER BY c.ZDATE DESC - LIMIT ? - """ - - var stmt: OpaquePointer? - guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { - let errorMessage = String(cString: sqlite3_errmsg(db)) - throw DatabaseAccessError.sqliteError(errorMessage) - } - defer { sqlite3_finalize(stmt) } - - // Bind parameters - var paramIndex: Int32 = 1 - for param in params { - if let stringParam = param as? String { - sqlite3_bind_text( - stmt, - paramIndex, - (stringParam as NSString).utf8String, - -1, - nil - ) - } else if let doubleParam = param as? Double { - sqlite3_bind_double(stmt, paramIndex, doubleParam) - } else if let timeInterval = param as? TimeInterval { - sqlite3_bind_double(stmt, paramIndex, timeInterval) - } - paramIndex += 1 - } - sqlite3_bind_int(stmt, paramIndex, Int32(limit)) - - var calls: [[String: Value]] = [] - while sqlite3_step(stmt) == SQLITE_ROW { - // Columns: 0=Z_PK, 1=ZADDRESS, 2=ZNAME, 3=ZDATE, 4=ZDURATION, - // 5=ZORIGINATED, 6=ZANSWERED, 7=ZSERVICE_PROVIDER - let id = Int(sqlite3_column_int64(stmt, 0)) - - let address: String - if let cStr = sqlite3_column_text(stmt, 1) { - address = String(cString: cStr) - } else { - address = "Unknown" - } - - let name: String? - if let cStr = sqlite3_column_text(stmt, 2) { - let n = String(cString: cStr) - name = n.isEmpty ? nil : n - } else { - name = nil - } - - let coreDataDate = sqlite3_column_double(stmt, 3) - let unixTimestamp = coreDataDate + coreDataEpoch - let date = Date(timeIntervalSince1970: unixTimestamp) - - let duration = sqlite3_column_double(stmt, 4) - let originated = sqlite3_column_int(stmt, 5) - let answered = sqlite3_column_int(stmt, 6) - - let callType: String - if originated == 1 { - callType = "outgoing" - } else if answered == 1 { - callType = "incoming" - } else { - callType = "missed" - } - - let serviceProvider: String - if let cStr = sqlite3_column_text(stmt, 7) { - serviceProvider = String(cString: cStr) - } else { - serviceProvider = "unknown" - } - - let durationMinutes = Int(duration) / 60 - let durationSeconds = Int(duration) % 60 - let durationStr = - durationMinutes > 0 - ? "\(durationMinutes)m \(durationSeconds)s" : "\(durationSeconds)s" - - var entry: [String: Value] = [ - "@id": .string(String(id)), - "phoneNumber": .string(address), - "callType": .string(callType), - "date": .string(date.formatted(.iso8601)), - "duration": .string(durationStr), - "durationSeconds": .double(duration), - "serviceProvider": .string(serviceProvider), - ] - if let name = name { - entry["name"] = .string(name) - } - - calls.append(entry) - } - - return calls - } - - // Use security-scoped access if needed - if canAccessDatabaseAtDefaultPath { - return try accessBlock(databaseURL) - } else { - return try withSecurityScopedAccess(databaseURL) { url in - try accessBlock(url) - } - } - } - - // MARK: - Errors - - private enum DatabaseAccessError: LocalizedError { - case noBookmarkFound - case securityScopeAccessFailed - case userDeclinedAccess - case invalidFileSelected - case fileNotReadable - case sqliteError(String) - - var errorDescription: String? { - switch self { - case .noBookmarkFound: - return "No stored bookmark found for call history database access" - case .securityScopeAccessFailed: - return "Failed to access security-scoped resource" - case .userDeclinedAccess: - return "User declined to grant access to the call history database" - case .invalidFileSelected: - return "Call history database access denied or invalid file selected" - case .fileNotReadable: - return "Selected database file is not readable" - case .sqliteError(let message): - return "SQLite error: \(message)" - } - } - } - - // MARK: - UI - - @MainActor - private func showDatabaseAccessAlert() async throws -> Bool { - let alert = NSAlert() - alert.messageText = "Call History Database Access Required" - alert.informativeText = """ - To read your phone call history, we need to open your database file. - - In the next screen, please select the file `CallHistory.storedata` and click "Grant Access". - """ - alert.alertStyle = .informational - alert.addButton(withTitle: "Continue") - alert.addButton(withTitle: "Cancel") - - return alert.runModal() == .alertFirstButtonReturn - } - - @MainActor - private func showFilePicker() async throws -> URL { - let openPanel = NSOpenPanel() - openPanel.delegate = self - openPanel.message = - "Please select the Call History database file (CallHistory.storedata)" - openPanel.prompt = "Grant Access" - openPanel.allowedContentTypes = [.item] - openPanel.directoryURL = URL(fileURLWithPath: callHistoryDatabasePath) - .deletingLastPathComponent() - openPanel.allowsMultipleSelection = false - openPanel.canChooseDirectories = false - openPanel.canChooseFiles = true - openPanel.showsHiddenFiles = true - - guard openPanel.runModal() == .OK, - let url = openPanel.url, - url.lastPathComponent == "CallHistory.storedata" - else { - throw DatabaseAccessError.invalidFileSelected - } - - return url - } - - private func storeBookmark(for url: URL) { - do { - let bookmarkData = try url.bookmarkData( - options: .securityScopeAllowOnlyReadAccess, - includingResourceValuesForKeys: nil, - relativeTo: nil - ) - UserDefaults.standard.set(bookmarkData, forKey: callHistoryDatabaseBookmarkKey) - log.debug("Successfully created and stored bookmark") - } catch { - log.error("Failed to create bookmark: \(error.localizedDescription)") - } - } - - func panel(_ sender: Any, shouldEnable url: URL) -> Bool { - let shouldEnable = url.lastPathComponent == "CallHistory.storedata" - log.debug( - "File selection panel: \(shouldEnable ? "enabling" : "disabling") URL: \(url.path)" - ) - return shouldEnable - } -} diff --git a/App/Services/Phone.swift b/App/Services/Phone.swift new file mode 100644 index 00000000..ea035780 --- /dev/null +++ b/App/Services/Phone.swift @@ -0,0 +1,671 @@ +import AppKit +import OSLog +import Ontology +import SQLite3 + +private let log = Logger.service("phone") +private let callHistoryDatabasePath = + "/Users/\(NSUserName())/Library/Application Support/CallHistoryDB/CallHistory.storedata" +private let callHistoryDatabaseBookmarkKey: String = "me.mattt.iMCP.callHistoryDatabaseBookmark" +private let defaultLimit = 30 + +final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { + static let shared = PhoneService() + + var tools: [Tool] { + Tool( + name: "phone_calls_fetch", + description: "Fetch phone call history from the Mac (synced from iPhone)", + inputSchema: .object( + properties: [ + "participant": .string( + description: + "Phone number or contact name to filter by (partial match supported)" + ), + "start": .string( + description: + "Start of the date range (inclusive). ISO 8601 format. If timezone is omitted, local time is assumed.", + format: .dateTime + ), + "end": .string( + description: + "End of the date range (exclusive). ISO 8601 format. If timezone is omitted, local time is assumed.", + format: .dateTime + ), + "call_type": .string( + description: "Filter by call type, or omit for all", + enum: ["incoming", "outgoing", "missed"] + ), + "limit": .integer( + description: "Maximum calls to return", + default: .int(defaultLimit), + minimum: 1 + ), + ], + additionalProperties: false + ), + annotations: .init( + title: "Fetch Call History", + readOnlyHint: true, + openWorldHint: false + ) + ) { arguments in + log.debug("Starting call history fetch with arguments: \(arguments)") + + let limit = try self.argument("limit", in: arguments, as: \.intValue) ?? defaultLimit + guard limit >= 1 else { + throw ArgumentError.invalid("limit must be a positive integer") + } + var request = CallRecord.FetchRequest(limit: limit) + request.participant = try self.argument("participant", in: arguments, as: \.stringValue) + if let callType = try self.argument("call_type", in: arguments, as: \.stringValue) { + guard let type = CallRecord.CallType(rawValue: callType.lowercased()) else { + throw ArgumentError.invalid( + "call_type must be one of: incoming, outgoing, missed" + ) + } + request.callType = type + } + if let start = try self.argument("start", in: arguments, as: \.stringValue) { + guard + let parsed = ISO8601DateFormatter.parsedLenientISO8601Date( + fromISO8601String: start + ) + else { + throw ArgumentError.invalid("start must be an ISO 8601 date") + } + request.startDate = parsed.date + } + if let end = try self.argument("end", in: arguments, as: \.stringValue) { + guard + let parsed = ISO8601DateFormatter.parsedLenientISO8601Date( + fromISO8601String: end + ) + else { + throw ArgumentError.invalid("end must be an ISO 8601 date") + } + request.endDate = parsed.date + } + + try await self.requestDatabaseAccess() + let calls = try self.withDatabase { try $0.fetch(request) } + + log.debug("Successfully fetched \(calls.count) calls") + return [ + "@context": "https://schema.org", + "@type": "ItemList", + "name": "Call History", + "numberOfItems": .int(calls.count), + "itemListElement": Value.array(calls.map(\.value)), + ] + } + + Tool( + name: "phone_call", + description: + "Start a phone call from the Mac (via iPhone). The system asks the user to confirm before dialing.", + inputSchema: .object( + properties: [ + "phoneNumber": .string( + description: + "Phone number to call. E.164 format is recommended. Digits, a leading +, and common formatting only; * and # are not supported." + ) + ], + required: ["phoneNumber"], + additionalProperties: false + ), + annotations: .init( + title: "Call Phone Number", + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true + ) + ) { arguments in + guard let phoneNumber = arguments["phoneNumber"]?.stringValue, + !phoneNumber.isEmpty + else { + throw CallError.missingPhoneNumber + } + + // Accept only digits, a leading "+", and common formatting; reject anything else + // rather than silently dialing a different number (e.g. "help911" -> "911"). + // The Phone app refuses tel: URLs containing "*" or "#", so those are rejected too. + let digits = Set("0123456789") + let dialable = digits.union("+") + let formatting = Set(" -().") + guard phoneNumber.allSatisfy({ dialable.contains($0) || formatting.contains($0) }) + else { + throw CallError.invalidPhoneNumber(phoneNumber) + } + + let dialString = phoneNumber.filter { !formatting.contains($0) } + guard dialString.contains(where: digits.contains) else { + throw CallError.invalidPhoneNumber(phoneNumber) + } + + var components = URLComponents() + components.scheme = "tel" + components.path = dialString + guard let url = components.url else { + throw CallError.invalidPhoneNumber(phoneNumber) + } + + log.debug("Requesting phone call to \(dialString)") + guard NSWorkspace.shared.open(url) else { + throw CallError.openFailed + } + + return CommunicateAction( + recipient: Person(telephone: dialString), + status: .potential, + description: "The system asked the user to confirm the call." + ) + } + } + + // MARK: - Database Access + + /// Ensures the call history database is readable, asking the user to grant access if needed. + private func requestDatabaseAccess() async throws { + if canAccessDatabaseAtDefaultPath { + log.debug("Using call history database at default path") + return + } + + if canAccessDatabaseUsingBookmark { + log.debug("Using call history database from stored bookmark") + return + } + + log.debug("Opening file picker for manual database selection") + guard try await showDatabaseAccessAlert() else { + throw DatabaseAccessError.userDeclinedAccess + } + + let selectedURL = try await showFilePicker() + + guard FileManager.default.isReadableFile(atPath: selectedURL.path) else { + throw DatabaseAccessError.fileNotReadable + } + + try storeBookmark(for: selectedURL) + log.debug("Granted access to call history database") + } + + /// Returns an optional argument, or throws if it is present with the wrong type. + private func argument( + _ name: String, + in arguments: [String: Value], + as transform: (Value) -> T? + ) throws -> T? { + guard let value = arguments[name], !value.isNull else { return nil } + guard let result = transform(value) else { + throw ArgumentError.invalid("\(name) has the wrong type") + } + return result + } + + private var canAccessDatabaseAtDefaultPath: Bool { + return FileManager.default.isReadableFile(atPath: callHistoryDatabasePath) + } + + private var canAccessDatabaseUsingBookmark: Bool { + do { + let url = try resolveBookmarkURL() + return try withSecurityScopedAccess(url) { url in + FileManager.default.isReadableFile(atPath: url.path) + } + } catch { + log.error("Error accessing database with bookmark: \(error.localizedDescription)") + return false + } + } + + private func resolveDatabaseURL() throws -> URL { + if canAccessDatabaseAtDefaultPath { + return URL(fileURLWithPath: callHistoryDatabasePath) + } + return try resolveBookmarkURL() + } + + private func resolveBookmarkURL() throws -> URL { + guard + let bookmarkData = UserDefaults.standard.data(forKey: callHistoryDatabaseBookmarkKey) + else { + throw DatabaseAccessError.noBookmarkFound + } + + var isStale = false + return try URL( + resolvingBookmarkData: bookmarkData, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + } + + private func withSecurityScopedAccess(_ url: URL, _ operation: (URL) throws -> T) throws -> T { + guard url.startAccessingSecurityScopedResource() else { + log.error("Failed to start accessing security-scoped resource") + throw DatabaseAccessError.securityScopeAccessFailed + } + defer { url.stopAccessingSecurityScopedResource() } + return try operation(url) + } + + private func withDatabase(_ operation: (CallHistoryDatabase) throws -> T) throws -> T { + let url = try resolveDatabaseURL() + if canAccessDatabaseAtDefaultPath { + return try operation(CallHistoryDatabase(path: url.path)) + } + return try withSecurityScopedAccess(url) { url in + try operation(CallHistoryDatabase(path: url.path)) + } + } + + // MARK: - Errors + + private enum DatabaseAccessError: LocalizedError { + case noBookmarkFound + case securityScopeAccessFailed + case userDeclinedAccess + case invalidFileSelected + case fileNotReadable + + var errorDescription: String? { + switch self { + case .noBookmarkFound: + return "No stored bookmark found for call history database access" + case .securityScopeAccessFailed: + return "Failed to access security-scoped resource" + case .userDeclinedAccess: + return "User declined to grant access to the call history database" + case .invalidFileSelected: + return "Call history database access denied or invalid file selected" + case .fileNotReadable: + return "Selected database file is not readable" + } + } + } + + private enum ArgumentError: LocalizedError { + case invalid(String) + + var errorDescription: String? { + switch self { + case .invalid(let message): + return "Invalid argument: \(message)" + } + } + } + + private enum CallError: LocalizedError { + case missingPhoneNumber + case invalidPhoneNumber(String) + case openFailed + + var errorDescription: String? { + switch self { + case .missingPhoneNumber: + return "A phone number is required" + case .invalidPhoneNumber(let number): + return "Invalid phone number: \(number)" + case .openFailed: + return + "Failed to start the call. Check that your iPhone is nearby and Calls on Other Devices is enabled." + } + } + } + + // MARK: - UI + + @MainActor + private func showDatabaseAccessAlert() async throws -> Bool { + let alert = NSAlert() + alert.messageText = "Call History Database Access Required" + alert.informativeText = """ + To read your phone call history, we need to open your database file. + + In the next screen, please select the file `CallHistory.storedata` and click "Grant Access". + """ + alert.alertStyle = .informational + alert.addButton(withTitle: "Continue") + alert.addButton(withTitle: "Cancel") + + return alert.runModal() == .alertFirstButtonReturn + } + + @MainActor + private func showFilePicker() async throws -> URL { + let openPanel = NSOpenPanel() + openPanel.delegate = self + openPanel.message = + "Please select the Call History database file (CallHistory.storedata)" + openPanel.prompt = "Grant Access" + openPanel.allowedContentTypes = [.item] + openPanel.directoryURL = URL(fileURLWithPath: callHistoryDatabasePath) + .deletingLastPathComponent() + openPanel.allowsMultipleSelection = false + openPanel.canChooseDirectories = false + openPanel.canChooseFiles = true + openPanel.showsHiddenFiles = true + + guard openPanel.runModal() == .OK, + let url = openPanel.url, + url.lastPathComponent == "CallHistory.storedata" + else { + throw DatabaseAccessError.invalidFileSelected + } + + return url + } + + private func storeBookmark(for url: URL) throws { + let bookmarkData = try url.bookmarkData( + options: .securityScopeAllowOnlyReadAccess, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + UserDefaults.standard.set(bookmarkData, forKey: callHistoryDatabaseBookmarkKey) + log.debug("Successfully created and stored bookmark") + } + + func panel(_ sender: Any, shouldEnable url: URL) -> Bool { + let shouldEnable = url.lastPathComponent == "CallHistory.storedata" + log.debug( + "File selection panel: \(shouldEnable ? "enabling" : "disabling") URL: \(url.path)" + ) + return shouldEnable + } +} + +// MARK: - + +/// A read-only connection to the Call History database. +private final class CallHistoryDatabase { + private let connection: OpaquePointer + + init(path: String) throws { + var connection: OpaquePointer? + guard sqlite3_open_v2(path, &connection, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + defer { sqlite3_close(connection) } + throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) + } + self.connection = connection! + } + + deinit { + sqlite3_close(connection) + } + + func fetch(_ request: CallRecord.FetchRequest) throws -> [CallRecord] { + let (sql, bindings) = request.statement + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(connection, sql, -1, &statement, nil) == SQLITE_OK else { + throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) + } + defer { sqlite3_finalize(statement) } + + for (offset, binding) in bindings.enumerated() { + binding.bind(to: statement, at: Int32(offset + 1)) + } + + var records: [CallRecord] = [] + var result = sqlite3_step(statement) + while result == SQLITE_ROW { + records.append(CallRecord(statement)) + result = sqlite3_step(statement) + } + guard result == SQLITE_DONE else { + throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) + } + return records + } +} + +private struct SQLiteError: LocalizedError { + let message: String + + var errorDescription: String? { + return "SQLite error: \(message)" + } +} + +/// A value bound to a `?` placeholder in a prepared statement. +private enum SQLiteValue { + case text(String) + case double(Double) + case int(Int) + + func bind(to statement: OpaquePointer?, at index: Int32) { + switch self { + case .text(let string): + sqlite3_bind_text(statement, index, string, -1, SQLITE_TRANSIENT) + case .double(let double): + sqlite3_bind_double(statement, index, double) + case .int(let int): + sqlite3_bind_int(statement, index, Int32(clamping: int)) + } + } +} + +private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + +/// A row of the `ZCALLRECORD` table. +private struct CallRecord { + enum CallType: String { + case incoming + case outgoing + case missed + + /// The SQL predicate that selects records of this type. + fileprivate var predicate: String { + switch self { + case .incoming: return "ZORIGINATED = 0 AND ZANSWERED = 1" + case .outgoing: return "ZORIGINATED = 1" + case .missed: return "ZORIGINATED = 0 AND ZANSWERED = 0" + } + } + } + + let id: Int + let address: String? + let name: String? + let date: Date + let duration: TimeInterval + let isOriginated: Bool + let isAnswered: Bool + let serviceProvider: String? + + var callType: CallType { + if isOriginated { + return .outgoing + } else if isAnswered { + return .incoming + } else { + return .missed + } + } + + /// Reads the current row of a statement prepared from `FetchRequest.statement`. + fileprivate init(_ statement: OpaquePointer?) { + func text(_ column: Int32) -> String? { + guard let cString = sqlite3_column_text(statement, column) else { return nil } + let string = String(cString: cString) + return string.isEmpty ? nil : string + } + + id = Int(sqlite3_column_int64(statement, 0)) + address = text(1) + name = text(2) + // ZDATE is stored as seconds since the Core Data reference date (2001-01-01) + date = Date(timeIntervalSinceReferenceDate: sqlite3_column_double(statement, 3)) + duration = sqlite3_column_double(statement, 4) + isOriginated = sqlite3_column_int(statement, 5) == 1 + isAnswered = sqlite3_column_int(statement, 6) == 1 + serviceProvider = text(7) + } +} + +extension CallRecord { + struct FetchRequest { + /// Phone number or contact name to match (partial, case-insensitive). + var participant: String? + /// Start of the date range (inclusive). + var startDate: Date? + /// End of the date range (exclusive). + var endDate: Date? + var callType: CallType? + var limit: Int + + /// The SQL and its bound values, in placeholder order. + fileprivate var statement: (sql: String, bindings: [SQLiteValue]) { + var conditions: [String] = [] + var bindings: [SQLiteValue] = [] + + if let participant { + conditions.append("(ZADDRESS LIKE ? OR ZNAME LIKE ?)") + bindings += [.text("%\(participant)%"), .text("%\(participant)%")] + } + if let startDate { + conditions.append("ZDATE >= ?") + bindings.append(.double(startDate.timeIntervalSinceReferenceDate)) + } + if let endDate { + conditions.append("ZDATE < ?") + bindings.append(.double(endDate.timeIntervalSinceReferenceDate)) + } + if let callType { + conditions.append(callType.predicate) + } + bindings.append(.int(limit)) + + let whereClause = + conditions.isEmpty ? "" : "WHERE " + conditions.joined(separator: " AND ") + let sql = """ + SELECT Z_PK, ZADDRESS, ZNAME, ZDATE, ZDURATION, ZORIGINATED, ZANSWERED, ZSERVICE_PROVIDER + FROM ZCALLRECORD + \(whereClause) + ORDER BY ZDATE DESC + LIMIT ? + """ + return (sql, bindings) + } + } + + /// The record as a JSON object for tool output. + var value: Value { + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 + var object: [String: Value] = [ + "@id": .string(String(id)), + "phoneNumber": .string(address ?? "Unknown"), + "callType": .string(callType.rawValue), + "date": .string(date.formatted(.iso8601)), + "duration": .string(minutes > 0 ? "\(minutes)m \(seconds)s" : "\(seconds)s"), + "durationSeconds": .double(duration), + "serviceProvider": .string(serviceProvider ?? "unknown"), + ] + if let name { + object["name"] = .string(name) + } + return .object(object) + } +} + +// MARK: - + +/// A CommunicateAction model following Schema.org ontology (https://schema.org/CommunicateAction) +private struct CommunicateAction: Hashable, Sendable { + /// Unique identifier for the action + var identifier: String? + + /// Description of the action + var description: String? + + /// Action status values based on Schema.org ActionStatusType + enum Status: String, Codable, Hashable, Sendable { + case active = "ActiveActionStatus" + case completed = "CompletedActionStatus" + case failed = "FailedActionStatus" + case potential = "PotentialActionStatus" + } + + /// Status of the action + var status: Status? + + /// The participant who receives the communication + var recipient: Person? + + init(recipient: Person? = nil, status: Status? = nil, description: String? = nil) { + self.recipient = recipient + self.status = status + self.description = description + } +} + +extension CommunicateAction: Codable { + private enum CodingKeys: String, CodingKey { + case description + case status = "actionStatus" + case recipient + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: JSONLDCodingKey.self) + + // Encode @context if we're at the root level + if encoder.codingPath.isEmpty { + try container.encode("https://schema.org", forKey: .context) + } + + // Encode @type + try container.encode(String(describing: Self.self), forKey: .type) + + // Encode @id + try container.encodeIfPresent(identifier, forKey: .id) + + // Encode properties + try container.encodeIfPresent(description, forKey: .attribute(.description)) + try container.encodeIfPresent(status?.rawValue, forKey: .attribute(.status)) + try container.encodeIfPresent(recipient, forKey: .attribute(.recipient)) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: JSONLDCodingKey.self) + + // Verify type is correct + let describedType = String(describing: Self.self) + let decodedType = try container.decode(String.self, forKey: .type) + guard decodedType == describedType else { + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Expected type to be '\(describedType)', but found \(decodedType)" + ) + } + + // Decode @id + identifier = try container.decodeIfPresent(String.self, forKey: .id) + + // Decode properties + description = try container.decodeIfPresent(String.self, forKey: .attribute(.description)) + if let statusString = try container.decodeIfPresent( + String.self, + forKey: .attribute(.status) + ) { + status = Status(rawValue: statusString) + } + recipient = try container.decodeIfPresent(Person.self, forKey: .attribute(.recipient)) + } +} + +extension Person { + /// Initialize a Person known only by a telephone number + fileprivate init(telephone: String) { + self.init(name: "") + self.givenName = nil + self.telephone = [telephone] + } +} From 77b50569dc2c3a615a9b2635ce9164222cf7c306 Mon Sep 17 00:00:00 2001 From: Mattt Date: Wed, 16 Sep 2026 05:46:45 -0700 Subject: [PATCH 30/56] Fix task cancellation and captures for Xcode 27 (#216) --- App/Controllers/ServerController.swift | 2 +- App/Services/Capture.swift | 18 ++++++++++++------ CLI/StdioProxy.swift | 4 ++-- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 9f33527a..aba496d7 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -322,7 +322,7 @@ final class ServerController: ObservableObject { } init() { - Task { + Task { [self] in // Initialize bindings from AppStorage before the server starts. await networkManager.updateServiceBindings(self.currentServiceBindings) await networkManager.updateDisabledTools(self.disabledTools, generation: 0) diff --git a/App/Services/Capture.swift b/App/Services/Capture.swift index 55b2456b..eeef1439 100644 --- a/App/Services/Capture.swift +++ b/App/Services/Capture.swift @@ -279,8 +279,14 @@ final class CaptureService: NSObject, Service { captureSession.startRunning() Task { @MainActor in - if delay > 0 { - try await Task.sleep(for: .seconds(delay)) + do { + if delay > 0 { try await Task.sleep(for: .seconds(delay)) } + } catch { + timeoutTask.cancel() + captureSession.stopRunning() + self.currentPhotoDelegate = nil + await resumeOnce(.failure(error), nil) + return } let settings = AVCapturePhotoSettings() @@ -291,7 +297,7 @@ final class CaptureService: NSObject, Service { let delegate = PhotoCaptureDelegate( format: format, quality: quality, - completion: { [weak self] result in + completion: { [weak self = self] result in Task { @MainActor in timeoutTask.cancel() captureSession.stopRunning() @@ -384,10 +390,10 @@ final class CaptureService: NSObject, Service { return try await withCheckedThrowingContinuation { continuation in Task { - try await Task.sleep(for: .seconds(duration + 0.5)) - recorder.stop() - + defer { recorder.stop() } do { + try await Task.sleep(for: .seconds(duration + 0.5)) + recorder.stop() let audioData = try Data(contentsOf: tempURL) try FileManager.default.removeItem(at: tempURL) let audioValue = Value.data(mimeType: format.mimeType, audioData) diff --git a/CLI/StdioProxy.swift b/CLI/StdioProxy.swift index a2c5b20b..4da36553 100644 --- a/CLI/StdioProxy.swift +++ b/CLI/StdioProxy.swift @@ -46,7 +46,7 @@ actor StdioProxy { connection.start(queue: .main) - connection.stateUpdateHandler = { state in + connection.stateUpdateHandler = { [weak self] state in Task { [weak self] in await self?.handleConnectionState(state, continuation: nil, connectionState: nil) } @@ -55,7 +55,7 @@ actor StdioProxy { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in let connectionState = ConnectionState() - connection.stateUpdateHandler = { state in + connection.stateUpdateHandler = { [weak self] state in Task { [weak self] in await self?.handleConnectionState( state, From 51883dd7bef26e2b1380131c8d20fcf433ed690a Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 17 Sep 2026 10:00:32 -0700 Subject: [PATCH 31/56] Track one approval window per client so concurrent requests close correctly (#219) A second client's request replaced the first window's reference, leaving the first dialog stuck on screen and its client marked pending forever. --- App/Controllers/ServerController.swift | 25 +++-------- App/Views/ConnectionApprovalView.swift | 59 +++++++++++++++++++------- 2 files changed, 50 insertions(+), 34 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index aba496d7..1ac279b5 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -163,12 +163,12 @@ enum ServiceRegistry { @MainActor final class ServerController: ObservableObject { @Published var serverStatus: String = "Starting..." - @Published var pendingConnectionID: String? - @Published var pendingClientName: String = "" + /// Clients with an approval window on screen. + /// Keyed by client name so that concurrent requests + /// from different clients each keep their own state. private var activeApprovalDialogs: Set = [] private var pendingApprovals: [(String, () -> Void, () -> Void)] = [] - private var currentApprovalHandlers: (approve: () -> Void, deny: () -> Void)? private let approvalWindowController = ConnectionApprovalWindowController() private let networkManager = ServerNetworkManager() @@ -298,14 +298,8 @@ final class ServerController: ObservableObject { } // MARK: - Connection Approval Methods - private func cleanupApprovalState() { - pendingClientName = "" - currentApprovalHandlers = nil - - if let clientID = pendingConnectionID { - activeApprovalDialogs.remove(clientID) - pendingConnectionID = nil - } + private func cleanupApprovalState(for clientID: String) { + activeApprovalDialogs.remove(clientID) } private func handlePendingApprovals(for clientID: String, approved: Bool) { @@ -429,8 +423,6 @@ final class ServerController: ObservableObject { return } - self.pendingConnectionID = clientID - // Coalesce concurrent approvals for the same client. guard !activeApprovalDialogs.contains(clientID) else { log.info("Adding to pending approvals for client: \(clientID)") @@ -441,9 +433,6 @@ final class ServerController: ObservableObject { activeApprovalDialogs.insert(clientID) // Present the approval window and wire callbacks. - pendingClientName = clientID - currentApprovalHandlers = (approve: approve, deny: deny) - approvalWindowController.showApprovalWindow( clientName: clientID, enabledServiceNames: @@ -469,12 +458,12 @@ final class ServerController: ObservableObject { } approve() - self.cleanupApprovalState() + self.cleanupApprovalState(for: clientID) self.handlePendingApprovals(for: clientID, approved: true) }, onDeny: { deny() - self.cleanupApprovalState() + self.cleanupApprovalState(for: clientID) self.handlePendingApprovals(for: clientID, approved: false) } ) diff --git a/App/Views/ConnectionApprovalView.swift b/App/Views/ConnectionApprovalView.swift index 8edd5d28..e0885c55 100644 --- a/App/Views/ConnectionApprovalView.swift +++ b/App/Views/ConnectionApprovalView.swift @@ -93,9 +93,14 @@ struct CheckboxToggleStyle: ToggleStyle { } @MainActor -class ConnectionApprovalWindowController: NSObject { - private var window: NSWindow? - private var approvalView: ConnectionApprovalView? +class ConnectionApprovalWindowController: NSObject, NSWindowDelegate { + /// Open approval windows, keyed by client name. + /// Several clients can ask for approval at once, + /// so each one gets its own window and its own close path. + private var windows: [String: NSWindow] = [:] + /// Deny handlers for windows the user closes with the title bar button + /// instead of answering. Removed once a window has been answered. + private var denyHandlers: [String: () -> Void] = [:] func showApprovalWindow( clientName: String, @@ -103,17 +108,22 @@ class ConnectionApprovalWindowController: NSObject { onApprove: @escaping (Bool) -> Void, onDeny: @escaping () -> Void ) { + // Replace any stale window for the same client. + closeWindow(for: clientName) + // Create the SwiftUI view let approvalView = ConnectionApprovalView( clientName: clientName, enabledServiceNames: enabledServiceNames, - onApprove: { alwaysTrust in + onApprove: { [weak self] alwaysTrust in + self?.denyHandlers.removeValue(forKey: clientName) onApprove(alwaysTrust) - self.closeWindow() + self?.closeWindow(for: clientName) }, - onDeny: { + onDeny: { [weak self] in + self?.denyHandlers.removeValue(forKey: clientName) onDeny() - self.closeWindow() + self?.closeWindow(for: clientName) } ) @@ -134,13 +144,15 @@ class ConnectionApprovalWindowController: NSObject { window.level = .floating window.isMovableByWindowBackground = false window.titlebarAppearsTransparent = false + window.delegate = self // Initial centering window.center() // Store references - self.window = window - self.approvalView = approvalView + let cascadeIndex = windows.count + windows[clientName] = window + denyHandlers[clientName] = onDeny // Activate the app first NSApp.activate(ignoringOtherApps: true) @@ -148,22 +160,37 @@ class ConnectionApprovalWindowController: NSObject { // Show the window window.makeKeyAndOrderFront(nil) - // Center again after showing to ensure proper positioning + // Center again after showing to ensure proper positioning. + // Offset each additional window so concurrent requests + // do not hide each other. Task { @MainActor in if let screen = NSScreen.main { let screenRect = screen.visibleFrame let windowRect = window.frame - let x = (screenRect.width - windowRect.width) / 2 + screenRect.origin.x - let y = (screenRect.height - windowRect.height) / 2 + screenRect.origin.y + let offset = CGFloat(cascadeIndex) * 24 + let x = (screenRect.width - windowRect.width) / 2 + screenRect.origin.x + offset + let y = (screenRect.height - windowRect.height) / 2 + screenRect.origin.y - offset window.setFrameOrigin(NSPoint(x: x, y: y)) } } } - private func closeWindow() { - window?.close() - window = nil - approvalView = nil + private func closeWindow(for clientName: String) { + guard let window = windows.removeValue(forKey: clientName) else { return } + window.delegate = nil + window.close() + } + + /// Treat closing a window with the title bar button as a denial, + /// so the waiting connection is released instead of hanging forever. + func windowWillClose(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + let clientName = windows.first(where: { $0.value === window })?.key + else { return } + + windows.removeValue(forKey: clientName) + window.delegate = nil + denyHandlers.removeValue(forKey: clientName)?() } } From 98b5d4756b7593eba0a3a7473131ef37e0ba3318 Mon Sep 17 00:00:00 2001 From: patp Date: Thu, 17 Sep 2026 13:17:50 -0400 Subject: [PATCH 32/56] Messages: grant the Messages folder so the write-ahead log is readable (#198) The service asked for chat.db alone. Messages keeps that database in WAL mode: every new message is committed to chat.db-wal first and only reaches chat.db when SQLite checkpoints the log, every few MB of writes. With a grant on the single file the log was unreadable, so Madrid opened the database with immutable=1 and messages_fetch could not see anything newer than the last checkpoint, often hours behind, then everything at once. The file picker now asks for ~/Library/Messages (directories only, constrained to a folder named Messages) and the bookmark covers the folder, which lets SQLite read chat.db together with its -wal and -shm companions (Madrid's Database.AccessMode.live). The security scope now stays open until the fetch is done, since SQLite opens the log lazily. A bookmark stored by an earlier version (chat.db alone) keeps working in immutable mode; the first fetch of a launch offers to re-select the folder, and declining is remembered for that launch. Claude-Session: https://claude.ai/code/session_0187kUP2mjhwMfJkwRbP5DG7 Co-authored-by: Claude Fable 5.1 --- App/Services/Messages.swift | 190 +++++++++++++++++++++++++++++------- README.md | 12 ++- 2 files changed, 165 insertions(+), 37 deletions(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index d53bdf52..f1b4dbcc 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -1,18 +1,32 @@ import AppKit import OSLog import SQLite3 -import UniformTypeIdentifiers import iMessage private let log = Logger.service("messages") -private let messagesDatabasePath = "/Users/\(NSUserName())/Library/Messages/chat.db" +private let messagesDirectoryPath = "/Users/\(NSUserName())/Library/Messages" +private let messagesDatabasePath = messagesDirectoryPath + "/chat.db" private let messagesDatabaseBookmarkKey: String = "me.mattt.iMCP.messagesDatabaseBookmark" private let defaultLimit = 30 final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { static let shared = MessageService() + /// Logged once per launch when the stored grant covers `chat.db` alone. + private var warnedAboutFileGrant = false + func activate() async throws { + try await activate(offeringUpgrade: true) + } + + /// Makes sure the database is reachable, asking for the Messages folder when nothing is + /// granted yet. `offeringUpgrade` decides what happens with a grant on `chat.db` alone, as + /// earlier versions requested: that grant cannot reach the write-ahead log next to it, + /// where Messages keeps everything since its last checkpoint, so the newest messages stay + /// invisible for hours. The menu-bar toggle passes `true` and offers the folder; tool calls + /// pass `false` and keep working with the old grant instead of parking the whole server + /// behind a modal alert that nobody may be there to answer. + private func activate(offeringUpgrade: Bool) async throws { log.debug("Starting message service activation") if canAccessDatabaseAtDefaultPath { @@ -20,19 +34,40 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { return } + let grant = try? resolveBookmarkedGrant() + var upgrading = false if canAccessDatabaseUsingBookmark { - log.debug("Successfully activated using stored bookmark") + switch grant { + case .directory: + log.debug("Successfully activated using stored bookmark") + return + case .file: + upgrading = true + case nil: + break + } + } + + if upgrading, !offeringUpgrade { + if !warnedAboutFileGrant { + warnedAboutFileGrant = true + log.warning( + "The Messages grant covers chat.db alone, so messages since its last checkpoint are not visible. Switch Messages off and on in the iMCP menu to grant the Messages folder." + ) + } return } - log.debug("Opening file picker for manual database selection") - guard try await showDatabaseAccessAlert() else { + log.debug("Opening folder picker for manual database selection") + guard try await showDatabaseAccessAlert(upgrading: upgrading) else { + // Keeping the old grant is a valid answer; the service stays on. + if upgrading { return } throw DatabaseAccessError.userDeclinedAccess } - let selectedURL = try await showFilePicker() + let selectedURL = try await showFolderPicker() - guard FileManager.default.isReadableFile(atPath: selectedURL.path) else { + guard FileManager.default.isReadableFile(atPath: databaseURL(in: selectedURL).path) else { throw DatabaseAccessError.fileNotReadable } @@ -42,7 +77,12 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { var isActivated: Bool { get async { - let isActivated = canAccessDatabaseAtDefaultPath || canAccessDatabaseUsingBookmark + // A grant on chat.db alone still serves tool calls, but the service is only fully set + // up once the Messages folder is granted; until then the toggle offers the upgrade. + var isActivated = canAccessDatabaseAtDefaultPath + if case .directory = try? resolveBookmarkedGrant() { + isActivated = isActivated || canAccessDatabaseUsingBookmark + } log.debug("Message service activation status: \(isActivated)") return isActivated } @@ -89,7 +129,7 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { ) ) { arguments in log.debug("Starting message fetch with arguments: \(arguments)") - try await self.activate() + try await self.activate(offeringUpgrade: false) let participants = arguments["participants"]?.arrayValue?.compactMap({ @@ -123,7 +163,10 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { let isReadFilter = arguments["isRead"]?.boolValue let limit = arguments["limit"]?.intValue - let db = try self.createDatabaseConnection() + // The grant must stay open until the last read: SQLite opens the write-ahead log lazily. + let access = try self.openDatabase() + defer { access.stop() } + let db = access.database var messages: [[String: Value]] = [] log.debug("Fetching handles for participants: \(participants)") @@ -220,9 +263,9 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { case .userDeclinedAccess: return "User declined to grant access to the messages database" case .invalidFileSelected: - return "Messages database access denied or invalid file selected" + return "Messages database access denied or the selection is not the Messages folder" case .fileNotReadable: - return "Selected database file is not readable" + return "The selected folder has no readable chat.db" } } } @@ -251,22 +294,86 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { ) } - private func createDatabaseConnection() throws -> iMessage.Database { + /// What the stored bookmark grants access to. + private enum BookmarkedGrant { + /// The Messages folder: `chat.db` together with its write-ahead log. + case directory(URL) + /// `chat.db` alone, as stored by earlier versions: the log next to it is unreadable, + /// so SQLite has to ignore it and messages since the last checkpoint are missing. + case file(URL) + + var url: URL { + switch self { + case .directory(let url), .file(let url): + return url + } + } + + var databaseURL: URL { + switch self { + case .directory(let url): + return url.appendingPathComponent("chat.db") + case .file(let url): + return url + } + } + } + + private func databaseURL(in directory: URL) -> URL { + return directory.appendingPathComponent("chat.db") + } + + private func resolveBookmarkedGrant() throws -> BookmarkedGrant { + let url = try resolveBookmarkURL() + let isDirectory = try withSecurityScopedAccess(url) { url in + (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? url.hasDirectoryPath + } + return isDirectory ? .directory(url) : .file(url) + } + + /// An open connection and the security scope it reads through. + private struct DatabaseAccess { + let database: iMessage.Database + fileprivate let scopedURL: URL? + + /// Ends the security scope. Call it after the last read on `database`. + func stop() { + scopedURL?.stopAccessingSecurityScopedResource() + } + } + + private func openDatabase() throws -> DatabaseAccess { if canAccessDatabaseAtDefaultPath { - return try iMessage.Database() + return DatabaseAccess(database: try iMessage.Database(), scopedURL: nil) } - let databaseURL = try resolveBookmarkURL() - return try withSecurityScopedAccess(databaseURL) { url in - try iMessage.Database(path: url.path) + let grant = try resolveBookmarkedGrant() + guard grant.url.startAccessingSecurityScopedResource() else { + log.error("Failed to start accessing security-scoped resource") + throw DatabaseAccessError.securityScopeAccessFailed + } + + do { + let database: iMessage.Database + switch grant { + case .directory: + database = try iMessage.Database(path: grant.databaseURL.path, mode: .live) + case .file: + // Warned about once, in activate(offeringUpgrade:). + database = try iMessage.Database(path: grant.databaseURL.path, mode: .immutable) + } + return DatabaseAccess(database: database, scopedURL: grant.url) + } catch { + grant.url.stopAccessingSecurityScopedResource() + throw error } } private var canAccessDatabaseUsingBookmark: Bool { do { - let url = try resolveBookmarkURL() - return try withSecurityScopedAccess(url) { url in - FileManager.default.isReadableFile(atPath: url.path) + let grant = try resolveBookmarkedGrant() + return try withSecurityScopedAccess(grant.url) { _ in + FileManager.default.isReadableFile(atPath: grant.databaseURL.path) } } catch { log.error("Error accessing database with bookmark: \(error.localizedDescription)") @@ -275,14 +382,24 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } @MainActor - private func showDatabaseAccessAlert() async throws -> Bool { + private func showDatabaseAccessAlert(upgrading: Bool) async throws -> Bool { let alert = NSAlert() alert.messageText = "Messages Database Access Required" - alert.informativeText = """ - To read your Messages history, we need to open your database file. - - In the next screen, please select the file `chat.db` and click "Grant Access". - """ + if upgrading { + alert.informativeText = """ + iMCP can currently read `chat.db` alone. Messages writes new messages to a log \ + next to it first, so the latest ones stay invisible for hours. + + In the next screen, please select the `Messages` folder and click "Grant Access". + """ + } else { + alert.informativeText = """ + To read your Messages history, we need access to your Messages folder: the \ + database and the log Messages writes new messages to. + + In the next screen, please select the `Messages` folder and click "Grant Access". + """ + } alert.alertStyle = .informational alert.addButton(withTitle: "Continue") alert.addButton(withTitle: "Cancel") @@ -291,22 +408,21 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } @MainActor - private func showFilePicker() async throws -> URL { + private func showFolderPicker() async throws -> URL { let openPanel = NSOpenPanel() openPanel.delegate = self - openPanel.message = "Please select the Messages database file (chat.db)" + openPanel.message = "Please select your Messages folder (~/Library/Messages)" openPanel.prompt = "Grant Access" - openPanel.allowedContentTypes = [UTType.item] - openPanel.directoryURL = URL(fileURLWithPath: messagesDatabasePath) + openPanel.directoryURL = URL(fileURLWithPath: messagesDirectoryPath) .deletingLastPathComponent() openPanel.allowsMultipleSelection = false - openPanel.canChooseDirectories = false - openPanel.canChooseFiles = true + openPanel.canChooseDirectories = true + openPanel.canChooseFiles = false openPanel.showsHiddenFiles = true guard openPanel.runModal() == .OK, let url = openPanel.url, - url.lastPathComponent == "chat.db" + isMessagesDirectory(url) else { throw DatabaseAccessError.invalidFileSelected } @@ -328,9 +444,13 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } } - // NSOpenSavePanelDelegate method to constrain file selection + private func isMessagesDirectory(_ url: URL) -> Bool { + return url.lastPathComponent == "Messages" + } + + // NSOpenSavePanelDelegate method to constrain the selection to the Messages folder func panel(_ sender: Any, shouldEnable url: URL) -> Bool { - let shouldEnable = url.lastPathComponent == "chat.db" + let shouldEnable = isMessagesDirectory(url) log.debug( "File selection panel: \(shouldEnable ? "enabling" : "disabling") URL: \(url.path)" ) diff --git a/README.md b/README.md index cc2ec01e..70d6a5bf 100644 --- a/README.md +++ b/README.md @@ -277,9 +277,17 @@ However, the Messages app on macOS stores data in a SQLite database located at iMCP runs in [App Sandbox][app-sandbox], which limits its access to user data and system resources. When you go to enable the Messages service, -you'll be prompted to open the `chat.db` file through the standard file picker. -When you do, macOS adds that file to the app's sandbox. +you'll be prompted to select your `~/Library/Messages` folder through the standard file picker. +When you do, macOS adds that folder to the app's sandbox. [`NSOpenPanel`][nsopenpanel] is magic like that. +The folder matters: `chat.db` is a WAL-mode database, +and Messages writes every new message to `chat.db-wal` next to it first, +only folding the log into `chat.db` at a checkpoint every few MB — +often hours later. +A grant on `chat.db` alone (what earlier versions asked for) +leaves that log unreadable, so the newest messages were missing until then. +If you granted `chat.db` with an earlier version, Messages keeps working from the last checkpoint; +switch Messages off and on in the iMCP menu to grant the folder instead. But opening the iMessage database is just half the battle. Over the past few years, From 5bf6fdc9c283112760e3e945fb97be62e34277cc Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 17 Sep 2026 10:19:42 -0700 Subject: [PATCH 33/56] Grant the CallHistoryDB folder so phone_calls_fetch can read the write-ahead log (#220) * Grant the CallHistoryDB folder so phone_calls_fetch can read the write-ahead log The store is a WAL-mode database, and a grant on the file alone leaves its -wal and -shm companions unreadable, so every read failed with "authorization denied". * Trim the call history README note --- App/Services/Phone.swift | 155 +++++++++++++++++++++++++++++++-------- README.md | 8 ++ 2 files changed, 132 insertions(+), 31 deletions(-) diff --git a/App/Services/Phone.swift b/App/Services/Phone.swift index ea035780..33630898 100644 --- a/App/Services/Phone.swift +++ b/App/Services/Phone.swift @@ -4,8 +4,10 @@ import Ontology import SQLite3 private let log = Logger.service("phone") -private let callHistoryDatabasePath = - "/Users/\(NSUserName())/Library/Application Support/CallHistoryDB/CallHistory.storedata" +private let callHistoryDirectoryPath = + "/Users/\(NSUserName())/Library/Application Support/CallHistoryDB" +private let callHistoryDatabaseName = "CallHistory.storedata" +private let callHistoryDatabasePath = callHistoryDirectoryPath + "/" + callHistoryDatabaseName private let callHistoryDatabaseBookmarkKey: String = "me.mattt.iMCP.callHistoryDatabaseBookmark" private let defaultLimit = 30 @@ -166,25 +168,37 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { // MARK: - Database Access /// Ensures the call history database is readable, asking the user to grant access if needed. + /// + /// The grant has to cover the `CallHistoryDB` folder, not just the store file. + /// The store is a WAL-mode SQLite database, so SQLite also opens the `-wal` and `-shm` + /// files next to it. A bookmark on the file alone, as earlier versions stored, + /// leaves those unreadable and every read fails with "authorization denied". private func requestDatabaseAccess() async throws { if canAccessDatabaseAtDefaultPath { log.debug("Using call history database at default path") return } - if canAccessDatabaseUsingBookmark { + switch try? resolveBookmarkedGrant() { + case .directory where canAccessDatabaseUsingBookmark: log.debug("Using call history database from stored bookmark") return + case .file: + log.warning( + "The stored grant covers \(callHistoryDatabaseName) alone and cannot reach its write-ahead log; asking for the CallHistoryDB folder instead" + ) + default: + break } - log.debug("Opening file picker for manual database selection") + log.debug("Opening folder picker for manual database selection") guard try await showDatabaseAccessAlert() else { throw DatabaseAccessError.userDeclinedAccess } - let selectedURL = try await showFilePicker() + let selectedURL = try await showFolderPicker() - guard FileManager.default.isReadableFile(atPath: selectedURL.path) else { + guard FileManager.default.isReadableFile(atPath: databaseURL(in: selectedURL).path) else { throw DatabaseAccessError.fileNotReadable } @@ -211,9 +225,9 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { private var canAccessDatabaseUsingBookmark: Bool { do { - let url = try resolveBookmarkURL() - return try withSecurityScopedAccess(url) { url in - FileManager.default.isReadableFile(atPath: url.path) + let grant = try resolveBookmarkedGrant() + return try withSecurityScopedAccess(grant.url) { _ in + FileManager.default.isReadableFile(atPath: grant.databaseURL.path) } } catch { log.error("Error accessing database with bookmark: \(error.localizedDescription)") @@ -221,11 +235,41 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { } } - private func resolveDatabaseURL() throws -> URL { - if canAccessDatabaseAtDefaultPath { - return URL(fileURLWithPath: callHistoryDatabasePath) + /// What the stored bookmark grants access to. + private enum BookmarkedGrant { + /// The `CallHistoryDB` folder: the store together with its write-ahead log. + case directory(URL) + /// The store file alone, as stored by earlier versions. + /// The log next to it is unreadable, so reads through this grant fail. + case file(URL) + + var url: URL { + switch self { + case .directory(let url), .file(let url): + return url + } } - return try resolveBookmarkURL() + + var databaseURL: URL { + switch self { + case .directory(let url): + return url.appendingPathComponent(callHistoryDatabaseName) + case .file(let url): + return url + } + } + } + + private func databaseURL(in directory: URL) -> URL { + return directory.appendingPathComponent(callHistoryDatabaseName) + } + + private func resolveBookmarkedGrant() throws -> BookmarkedGrant { + let url = try resolveBookmarkURL() + let isDirectory = try withSecurityScopedAccess(url) { url in + (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? url.hasDirectoryPath + } + return isDirectory ? .directory(url) : .file(url) } private func resolveBookmarkURL() throws -> URL { @@ -254,12 +298,34 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { } private func withDatabase(_ operation: (CallHistoryDatabase) throws -> T) throws -> T { - let url = try resolveDatabaseURL() if canAccessDatabaseAtDefaultPath { - return try operation(CallHistoryDatabase(path: url.path)) + return try read(at: callHistoryDatabasePath, operation) + } + + let grant = try resolveBookmarkedGrant() + guard case .directory = grant else { + throw DatabaseAccessError.insufficientGrant + } + + // The grant must stay open until the last read: + // SQLite opens the write-ahead log lazily on the first statement. + return try withSecurityScopedAccess(grant.url) { _ in + try read(at: grant.databaseURL.path, operation) } - return try withSecurityScopedAccess(url) { url in - try operation(CallHistoryDatabase(path: url.path)) + } + + /// Reads the store live, honoring its write-ahead log. + /// If SQLite cannot open the log's companion files, for example because they are absent + /// and the folder is read-only, it reads the main file alone instead. + /// Nothing is lost then: without a log, every record is already in the main file. + private func read(at path: String, _ operation: (CallHistoryDatabase) throws -> T) throws -> T { + do { + return try operation(CallHistoryDatabase(path: path, immutable: false)) + } catch let error as SQLiteError { + log.warning( + "Live read of the call history database failed (\(error.message)); retrying without its write-ahead log" + ) + return try operation(CallHistoryDatabase(path: path, immutable: true)) } } @@ -268,6 +334,7 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { private enum DatabaseAccessError: LocalizedError { case noBookmarkFound case securityScopeAccessFailed + case insufficientGrant case userDeclinedAccess case invalidFileSelected case fileNotReadable @@ -278,12 +345,16 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { return "No stored bookmark found for call history database access" case .securityScopeAccessFailed: return "Failed to access security-scoped resource" + case .insufficientGrant: + return + "The stored grant covers the call history database file alone; grant the CallHistoryDB folder instead" case .userDeclinedAccess: return "User declined to grant access to the call history database" case .invalidFileSelected: - return "Call history database access denied or invalid file selected" + return + "Call history database access denied or the selection is not the CallHistoryDB folder" case .fileNotReadable: - return "Selected database file is not readable" + return "The selected folder has no readable \(callHistoryDatabaseName)" } } } @@ -324,9 +395,10 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { let alert = NSAlert() alert.messageText = "Call History Database Access Required" alert.informativeText = """ - To read your phone call history, we need to open your database file. + To read your phone call history, we need access to your CallHistoryDB folder: \ + the database and the log new calls are written to first. - In the next screen, please select the file `CallHistory.storedata` and click "Grant Access". + In the next screen, please select the `CallHistoryDB` folder and click "Grant Access". """ alert.alertStyle = .informational alert.addButton(withTitle: "Continue") @@ -336,23 +408,22 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { } @MainActor - private func showFilePicker() async throws -> URL { + private func showFolderPicker() async throws -> URL { let openPanel = NSOpenPanel() openPanel.delegate = self openPanel.message = - "Please select the Call History database file (CallHistory.storedata)" + "Please select your call history folder (~/Library/Application Support/CallHistoryDB)" openPanel.prompt = "Grant Access" - openPanel.allowedContentTypes = [.item] - openPanel.directoryURL = URL(fileURLWithPath: callHistoryDatabasePath) + openPanel.directoryURL = URL(fileURLWithPath: callHistoryDirectoryPath) .deletingLastPathComponent() openPanel.allowsMultipleSelection = false - openPanel.canChooseDirectories = false - openPanel.canChooseFiles = true + openPanel.canChooseDirectories = true + openPanel.canChooseFiles = false openPanel.showsHiddenFiles = true guard openPanel.runModal() == .OK, let url = openPanel.url, - url.lastPathComponent == "CallHistory.storedata" + isCallHistoryDirectory(url) else { throw DatabaseAccessError.invalidFileSelected } @@ -370,8 +441,13 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { log.debug("Successfully created and stored bookmark") } + private func isCallHistoryDirectory(_ url: URL) -> Bool { + return url.lastPathComponent == "CallHistoryDB" + } + + // NSOpenSavePanelDelegate method to constrain the selection to the CallHistoryDB folder func panel(_ sender: Any, shouldEnable url: URL) -> Bool { - let shouldEnable = url.lastPathComponent == "CallHistory.storedata" + let shouldEnable = isCallHistoryDirectory(url) log.debug( "File selection panel: \(shouldEnable ? "enabling" : "disabling") URL: \(url.path)" ) @@ -385,9 +461,26 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { private final class CallHistoryDatabase { private let connection: OpaquePointer - init(path: String) throws { + /// Opens the store read-only. + /// With `immutable`, SQLite ignores the write-ahead log and never touches the `-wal` + /// and `-shm` files, so records since the last checkpoint are not visible. + init(path: String, immutable: Bool) throws { + var filename = path + var flags = SQLITE_OPEN_READONLY + if immutable { + var components = URLComponents() + components.scheme = "file" + components.path = path + components.queryItems = [URLQueryItem(name: "immutable", value: "1")] + guard let uri = components.string else { + throw SQLiteError(message: "cannot build a URI for \(path)") + } + filename = uri + flags |= SQLITE_OPEN_URI + } + var connection: OpaquePointer? - guard sqlite3_open_v2(path, &connection, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + guard sqlite3_open_v2(filename, &connection, flags, nil) == SQLITE_OK else { defer { sqlite3_close(connection) } throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) } diff --git a/README.md b/README.md index 70d6a5bf..80a31955 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,14 @@ It includes a Swift implementation for decoding Apple's `typedstream` format, adapted from Christopher Sardegna's [imessage-exporter] project and [blog post about reverse-engineering `typedstream`][typedstream-blog-post]. +### Call History Database Access + +Call history synced from your iPhone lives in a SQLite database at +`~/Library/Application Support/CallHistoryDB/CallHistory.storedata`. +Recent calls sit in its write-ahead log next to the store file, +so the Phone service asks you to open the `CallHistoryDB` folder +rather than the file alone. + ### JSON-LD for Tool Results The tools provided by iMCP return results as From d0fa365427340bb23eabb56445990d2067a99d79 Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 17 Sep 2026 10:58:29 -0700 Subject: [PATCH 34/56] Keep the existing grant when the folder picker is dismissed (#221) Dismissing the folder picker threw invalidFileSelected. During a Messages upgrade from a file grant, that error reached the service toggle and turned Messages off, although the old grant still worked and the alert's Cancel button already kept it. The picker now returns nil on dismissal; Messages keeps the old grant during an upgrade and otherwise reports userDeclinedAccess, and Phone reports the same instead of a wrong selection. The once-per-launch warning about a file-only Messages grant was a plain check-and-set on a shared flag that concurrent tool calls could race. It is now guarded by a lock, with the log call outside it. --- App/Services/Messages.swift | 30 ++++++++++++++++++++---------- App/Services/Phone.swift | 16 ++++++++++------ 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index f1b4dbcc..a532d74d 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -1,5 +1,6 @@ import AppKit import OSLog +import os import SQLite3 import iMessage @@ -12,8 +13,9 @@ private let defaultLimit = 30 final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { static let shared = MessageService() - /// Logged once per launch when the stored grant covers `chat.db` alone. - private var warnedAboutFileGrant = false + /// Whether the once-per-launch warning about a grant on `chat.db` alone has been logged. + /// Tool calls run concurrently, so the check-and-set is guarded. + private let fileGrantWarningLogged = OSAllocatedUnfairLock(initialState: false) func activate() async throws { try await activate(offeringUpgrade: true) @@ -49,8 +51,11 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } if upgrading, !offeringUpgrade { - if !warnedAboutFileGrant { - warnedAboutFileGrant = true + let isFirstWarning = fileGrantWarningLogged.withLock { logged in + defer { logged = true } + return !logged + } + if isFirstWarning { log.warning( "The Messages grant covers chat.db alone, so messages since its last checkpoint are not visible. Switch Messages off and on in the iMCP menu to grant the Messages folder." ) @@ -65,7 +70,11 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { throw DatabaseAccessError.userDeclinedAccess } - let selectedURL = try await showFolderPicker() + guard let selectedURL = try await showFolderPicker() else { + // Dismissing the picker is the same answer as Cancel on the alert. + if upgrading { return } + throw DatabaseAccessError.userDeclinedAccess + } guard FileManager.default.isReadableFile(atPath: databaseURL(in: selectedURL).path) else { throw DatabaseAccessError.fileNotReadable @@ -407,8 +416,9 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { return alert.runModal() == .alertFirstButtonReturn } + /// Returns the selected folder, or nil when the user dismisses the panel. @MainActor - private func showFolderPicker() async throws -> URL { + private func showFolderPicker() async throws -> URL? { let openPanel = NSOpenPanel() openPanel.delegate = self openPanel.message = "Please select your Messages folder (~/Library/Messages)" @@ -420,10 +430,10 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { openPanel.canChooseFiles = false openPanel.showsHiddenFiles = true - guard openPanel.runModal() == .OK, - let url = openPanel.url, - isMessagesDirectory(url) - else { + guard openPanel.runModal() == .OK, let url = openPanel.url else { + return nil + } + guard isMessagesDirectory(url) else { throw DatabaseAccessError.invalidFileSelected } diff --git a/App/Services/Phone.swift b/App/Services/Phone.swift index 33630898..c906b6a8 100644 --- a/App/Services/Phone.swift +++ b/App/Services/Phone.swift @@ -196,7 +196,10 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { throw DatabaseAccessError.userDeclinedAccess } - let selectedURL = try await showFolderPicker() + guard let selectedURL = try await showFolderPicker() else { + // Dismissing the picker is the same answer as Cancel on the alert. + throw DatabaseAccessError.userDeclinedAccess + } guard FileManager.default.isReadableFile(atPath: databaseURL(in: selectedURL).path) else { throw DatabaseAccessError.fileNotReadable @@ -407,8 +410,9 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { return alert.runModal() == .alertFirstButtonReturn } + /// Returns the selected folder, or nil when the user dismisses the panel. @MainActor - private func showFolderPicker() async throws -> URL { + private func showFolderPicker() async throws -> URL? { let openPanel = NSOpenPanel() openPanel.delegate = self openPanel.message = @@ -421,10 +425,10 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { openPanel.canChooseFiles = false openPanel.showsHiddenFiles = true - guard openPanel.runModal() == .OK, - let url = openPanel.url, - isCallHistoryDirectory(url) - else { + guard openPanel.runModal() == .OK, let url = openPanel.url else { + return nil + } + guard isCallHistoryDirectory(url) else { throw DatabaseAccessError.invalidFileSelected } From 03e29d64bfe40fb2b98ace6e079852285df9e774 Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 17 Sep 2026 11:23:12 -0700 Subject: [PATCH 35/56] Reject invalid events_fetch start dates (#223) --- App/Services/Calendar.swift | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/App/Services/Calendar.swift b/App/Services/Calendar.swift index e248055e..cebf37b7 100644 --- a/App/Services/Calendar.swift +++ b/App/Services/Calendar.swift @@ -131,11 +131,21 @@ final class CalendarService: Service { var startIsDateOnly = false var endIsDateOnly = false - if case .string(let start) = arguments["start"], - let parsedStart = ISO8601DateFormatter.parsedLenientISO8601Date( - fromISO8601String: start - ) - { + if case .string(let start) = arguments["start"] { + guard + let parsedStart = ISO8601DateFormatter.parsedLenientISO8601Date( + fromISO8601String: start + ) + else { + throw NSError( + domain: "CalendarError", + code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "Invalid start date format. Expected ISO 8601 format." + ] + ) + } hasStart = true startDate = parsedStart.date startIsDateOnly = parsedStart.isDateOnly From a48258bf5cbbc2e9237fcbe00e726ccb6f00c82c Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 17 Sep 2026 11:23:18 -0700 Subject: [PATCH 36/56] Close sockets when client message loops end (#225) The SDK ends its message loop when a client disconnects but leaves the socket open until the health poll detects it. Await loop completion to release the connection immediately, and guard against repeated stops during app shutdown. --- App/Controllers/ServerController.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 1ac279b5..bfadfd56 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -481,6 +481,7 @@ actor MCPConnectionManager { private let server: MCP.Server private var transport: NetworkTransport private let parentManager: ServerNetworkManager + private var isStopping = false init(connectionID: UUID, connection: NWConnection, parentManager: ServerNetworkManager) { self.connectionID = connectionID @@ -532,6 +533,14 @@ actor MCPConnectionManager { // Monitor connection health for early disconnects. await startHealthMonitoring() + + // The SDK ends the message loop when the client disconnects, + // but leaves the socket open. Close it without waiting for the health poll. + Task { [weak self] in + guard let self else { return } + await self.server.waitUntilCompleted() + await self.parentManager.removeConnection(self.connectionID) + } } catch { log.error("Failed to start MCP server: \(error.localizedDescription)") throw error @@ -586,6 +595,9 @@ actor MCPConnectionManager { } func stop() async { + // App shutdown and message-loop completion can both stop this connection. + guard !isStopping else { return } + isStopping = true await server.stop() connection.cancel() } From 27f13fd47eaa2057b5920d27c8f220ca79435799 Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 17 Sep 2026 11:23:34 -0700 Subject: [PATCH 37/56] Direct place-name queries to maps_search (#224) --- App/Services/Location.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/App/Services/Location.swift b/App/Services/Location.swift index 4912fc52..39e513f2 100644 --- a/App/Services/Location.swift +++ b/App/Services/Location.swift @@ -180,11 +180,15 @@ final class LocationService: NSObject, Service, CLLocationManagerDelegate { Tool( name: "location_geocode", - description: "Convert an address to geographic coordinates", + description: """ + Convert a postal address to geographic coordinates. + Place names can resolve to unrelated street addresses. + Use maps_search for place names, businesses, and landmarks. + """, inputSchema: .object( properties: [ "address": .string( - description: "Address to geocode" + description: "Postal address to geocode" ) ], required: ["address"], From 036dd5b4a12a917e637db105d17cca57f87bb438 Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 17 Sep 2026 11:23:38 -0700 Subject: [PATCH 38/56] Remove unsupported transit directions (#222) --- App/Services/Maps.swift | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/App/Services/Maps.swift b/App/Services/Maps.swift index 33d82c3c..e1446af6 100644 --- a/App/Services/Maps.swift +++ b/App/Services/Maps.swift @@ -127,7 +127,10 @@ final class MapsService: NSObject, Service { Tool( name: "maps_directions", - description: "Get directions between two locations with optional transport type", + description: """ + Get automobile or walking directions between two locations. + Use maps_eta for a transit travel-time estimate. + """, inputSchema: .object( properties: [ "originAddress": .string( @@ -155,9 +158,9 @@ final class MapsService: NSObject, Service { additionalProperties: false ), "transportType": .string( - description: "Transport type", + description: "Transport type for the route", default: "automobile", - enum: ["automobile", "walking", "transit", "any"] + enum: ["automobile", "walking", "any"] ), ], additionalProperties: false @@ -199,6 +202,25 @@ final class MapsService: NSObject, Service { ) } + let transportType: MKDirectionsTransportType + switch arguments["transportType"]?.stringValue { + case "automobile": + transportType = .automobile + case "walking": + transportType = .walking + case "transit": + throw NSError( + domain: "MapsServiceError", + code: 16, + userInfo: [ + NSLocalizedDescriptionKey: + "Transit directions are unavailable. Use maps_eta for a travel-time estimate." + ] + ) + default: + transportType = .any + } + // Get origin and destination let originItem = try await self.getMapItem( address: arguments["originAddress"]?.stringValue, @@ -214,18 +236,7 @@ final class MapsService: NSObject, Service { let directionsRequest = MKDirections.Request() directionsRequest.source = originItem directionsRequest.destination = destinationItem - - // Set transport type - switch arguments["transportType"]?.stringValue { - case "automobile": - directionsRequest.transportType = .automobile - case "walking": - directionsRequest.transportType = .walking - case "transit": - directionsRequest.transportType = .transit - default: - directionsRequest.transportType = .any - } + directionsRequest.transportType = transportType return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in From dfcfdb7653513a65ac4dddef14a12662e5feb5bc Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 17 Sep 2026 11:45:19 -0700 Subject: [PATCH 39/56] Update README services and generate hero images (#226) --- Assets/README.md | 29 ++++++++ Assets/hero-dark.svg | 141 ++++++++++++++++++++------------------- Assets/hero-light.svg | 140 +++++++++++++++++++------------------- Assets/phone.svg | 7 ++ Assets/shortcuts.svg | 8 +++ Assets/wordmark.svg | 13 ++++ README.md | 16 ++++- Scripts/generate-hero.py | 81 ++++++++++++++++++++++ 8 files changed, 296 insertions(+), 139 deletions(-) create mode 100644 Assets/README.md create mode 100644 Assets/phone.svg create mode 100644 Assets/shortcuts.svg create mode 100644 Assets/wordmark.svg create mode 100755 Scripts/generate-hero.py diff --git a/Assets/README.md b/Assets/README.md new file mode 100644 index 00000000..2ec8e3c5 --- /dev/null +++ b/Assets/README.md @@ -0,0 +1,29 @@ +# README images + +Run this command from the repository root to regenerate both hero images: + +```sh +uv run Scripts/generate-hero.py +``` + +`wordmark.svg` contains the lettering paths from the original Illustrator export, +with a corrected curve on the P to remove a spur. +The service icons are the same SVG files used in the README table. +The generated images embed these paths and need no fonts or external images. + +The icon centers follow a circular arc. +On a circle, arc length equals radius times angle in radians, +so equal angle steps give equal spacing along the curve. +The script calculates each center with `x = width / 2 + radius * sin(angle)` +and `y = arc_top + radius * (1 - cos(angle))`. +The icons stay upright. + +The hero icons are ordered by hue, from red through green and blue to purple. +The README table stays in alphabetical order. +To add or reorder icons, edit `SERVICES` in the script. +Each entry contains the asset name and its background color for dark mode. +The light mode color comes from the asset. +Adjust `ICON_SIZE`, `RADIUS`, `ARC_TOP`, and `SWEEP` to change the layout. +If more icons make the arc too crowded, reduce `ICON_SIZE` or increase `SWEEP`. +Regenerate and check both images after changes. + diff --git a/Assets/hero-dark.svg b/Assets/hero-dark.svg index 8686461c..7cfda98e 100644 --- a/Assets/hero-dark.svg +++ b/Assets/hero-dark.svg @@ -1,78 +1,81 @@ - - - - - - - - - + + + iMCP + + + + + + + + + + - - - - - + Calendar + + + + + - - - - - + Contacts + + + + + + + + + + - - - - - + Reminders + + + + + - - - - - - + Messages + + + + + - - - - - + Phone + + + + + + - - - - - - - - - - + Weather + + + + + - - - - - - + Location + + + + + - \ No newline at end of file + Shortcuts + + + + + + + + Maps + + diff --git a/Assets/hero-light.svg b/Assets/hero-light.svg index e6603ce6..d037aae8 100644 --- a/Assets/hero-light.svg +++ b/Assets/hero-light.svg @@ -1,79 +1,81 @@ - - - - + + + iMCP - - - - + + + + - - - - + + + + - - - - - + Calendar + + + + + - - - - - + Contacts + + + + + + + + + + - - - - - - + Reminders + + + + + - - - - - + Messages + + + + + - - - - - - - - - - + Phone + + + + + + - - - - - - + Weather + + + + + - - - \ No newline at end of file + Location + + + + + + + Shortcuts + + + + + + + + Maps + + diff --git a/Assets/phone.svg b/Assets/phone.svg new file mode 100644 index 00000000..9c19ac05 --- /dev/null +++ b/Assets/phone.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Assets/shortcuts.svg b/Assets/shortcuts.svg new file mode 100644 index 00000000..d3c34e70 --- /dev/null +++ b/Assets/shortcuts.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Assets/wordmark.svg b/Assets/wordmark.svg new file mode 100644 index 00000000..ac4e3a44 --- /dev/null +++ b/Assets/wordmark.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/README.md b/README.md index 80a31955..51c8a449 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ - iMCP + iMCP iMCP is a macOS app for connecting your digital life with AI. @@ -47,6 +47,13 @@ and a [growing list of clients][mcp-clients] that support the Messages Access message history with specific participants within customizable date ranges. + + + + + Phone + View call history synced from your iPhone, filter calls by participant, date, or call type, and start calls with system confirmation. + @@ -54,6 +61,13 @@ and a [growing list of clients][mcp-clients] that support the Reminders View and create reminders with customizable due dates, priorities, and alerts across different reminder lists. + + + + + Shortcuts + List and run shortcuts on your Mac, with optional text input. + diff --git a/Scripts/generate-hero.py b/Scripts/generate-hero.py new file mode 100755 index 00000000..0688b051 --- /dev/null +++ b/Scripts/generate-hero.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""Generate both README hero images from the SVG assets.""" + +import math +from copy import deepcopy +from pathlib import Path +from xml.etree import ElementTree as ET + +ASSETS = Path(__file__).resolve().parents[1] / "Assets" +SVG = "http://www.w3.org/2000/svg" +ET.register_namespace("", SVG) + +# Arrange icons by hue, from red through green and blue to purple. +# Each entry pairs an asset name with its dark appearance background color. +SERVICES = ( + ("calendar", "#ff453a"), + ("contacts", "#ac8e68"), + ("reminders", "#ff9f0a"), + ("messages", "#28d74b"), + ("phone", "#28d74b"), + ("weather", "#5ac8f5"), + ("location", "#0a84ff"), + ("shortcuts", "#5e5ce6"), + ("maps", "#bf5af2"), +) + +WIDTH, HEIGHT = 1012, 600 +ICON_SIZE = 100 +RADIUS = 600 +ARC_TOP = 108 +SWEEP = math.radians(90) + + +def icon_centers(count: int) -> list[tuple[float, float]]: + """Space centers equally along a circular arc, from left to right.""" + centers = [] + for index in range(count): + angle = -SWEEP / 2 + SWEEP * index / (count - 1) if count > 1 else 0 + # Arc length is radius * angle, so equal angles give equal spacing. + x = WIDTH / 2 + RADIUS * math.sin(angle) + y = ARC_TOP + RADIUS * (1 - math.cos(angle)) + centers.append((x, y)) + return centers + + +def generate(theme: str) -> None: + root = ET.Element(f"{{{SVG}}}svg", {"viewBox": f"0 0 {WIDTH} {HEIGHT}"}) + root.append(ET.Comment(" Generated by Scripts/generate-hero.py. Do not edit. ")) + ET.SubElement(root, f"{{{SVG}}}title").text = "iMCP" + + wordmark = ET.parse(ASSETS / "wordmark.svg").getroot() + lettering = deepcopy(wordmark.find(f"{{{SVG}}}g")) + lettering.set("fill", "#fff" if theme == "dark" else "#000") + root.append(lettering) + + for (name, dark_color), (x, y) in zip(SERVICES, icon_centers(len(SERVICES))): + icon = ET.parse(ASSETS / f"{name}.svg").getroot() + icon.set("x", f"{x - ICON_SIZE / 2:.3f}") + icon.set("y", f"{y - ICON_SIZE / 2:.3f}") + icon.set("width", str(ICON_SIZE)) + icon.set("height", str(ICON_SIZE)) + ET.SubElement(icon, f"{{{SVG}}}title").text = name.capitalize() + if theme == "dark": + icon.find(f"{{{SVG}}}circle").set("fill", dark_color) + root.append(icon) + + ET.indent(root, space=" ") + output = ET.tostring(root, encoding="unicode") + (ASSETS / f"hero-{theme}.svg").write_text( + '\n' + output + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + for theme in ("light", "dark"): + generate(theme) From ad164a9cadee6e374f756b40a1f82e36353fe75d Mon Sep 17 00:00:00 2001 From: mattt Date: Thu, 17 Sep 2026 11:46:25 -0700 Subject: [PATCH 40/56] Release 1.5.0 --- iMCP.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index efbed0aa..b8aac11c 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -532,7 +532,7 @@ CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = YES; @@ -574,7 +574,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.4.1; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -596,7 +596,7 @@ CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = TTY35UM57S; ENABLE_APP_SANDBOX = YES; @@ -642,7 +642,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.4.1; + MARKETING_VERSION = 1.5.0; PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 9547f12e1d0c6f3511727280d8708c3cba724084 Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 17 Sep 2026 12:07:27 -0700 Subject: [PATCH 41/56] Verify existing release assets after upload failures (#227) --- Scripts/release.sh | 55 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/Scripts/release.sh b/Scripts/release.sh index f3efe595..64d1f34f 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -87,7 +87,8 @@ Environment: EOF } -# If APP_BUNDLE isn't explicit, derive the built app path from Xcode settings. +# If APP_BUNDLE isn't explicit, +# derive the built app path from Xcode settings. resolve_app_bundle() { resolve_exported_app || true if [[ -d "${APP_BUNDLE}" ]]; then @@ -302,9 +303,11 @@ build_check() { resolve_app_bundle } -# The project's Release configuration signs the app with an Apple Development identity, +# The project's Release configuration signs the app +# with an Apple Development identity, # which a release machine may not have. -# When a Developer ID certificate is configured, archive with it directly; +# When a Developer ID certificate is configured, +# archive with it directly; # export re-signs with the provisioning profile afterwards. archive_signing_args() { if [[ -z "${TEAM_ID}" ]]; then @@ -314,7 +317,8 @@ archive_signing_args() { "CODE_SIGN_STYLE=Manual" \ "CODE_SIGN_IDENTITY=${SIGNING_CERTIFICATE}" \ "DEVELOPMENT_TEAM=${TEAM_ID}" - # The app's WeatherKit entitlement needs its provisioning profile at archive time, + # The app's WeatherKit entitlement needs its provisioning profile + # at archive time, # but a command-line override applies to every target, # and the embedded CLI has a different bundle identifier. # Route the profile through a setting keyed by product name @@ -472,7 +476,8 @@ push_tags() { echo "Tag push cancelled." exit 1 fi - # Push only this release's tag: every pushed version tag starts a release. + # Push only this release's tag: + # every pushed version tag starts a release. git push origin "refs/tags/${VERSION}" } @@ -491,7 +496,8 @@ require_release_tag() { echo "Tag ${VERSION} points at ${tag_commit}, but HEAD is ${head_commit}." >&2 exit 1 fi - # The remote tag is what the release records, so it must exist and match too. + # The remote tag is what the release records, + # so it must exist and match too. local remote_commit remote_commit="$(git ls-remote --tags origin "refs/tags/${VERSION}^{}" "refs/tags/${VERSION}" 2>/dev/null | awk '{print $1}' | tail -n 1 || true)" if [[ -z "${remote_commit}" ]]; then @@ -504,8 +510,9 @@ require_release_tag() { fi } -# The release starts as a draft so a failure partway through -# never leaves a half-populated release as the latest one. +# The release starts as a draft +# so a failure partway through never leaves a half-populated release +# as the latest one. create_release() { require_version if [[ -n "${DRY_RUN}" ]]; then @@ -541,6 +548,31 @@ publish_release() { fi } +# An upload can reach GitHub +# even if gh times out and its retry fails with "already exists". +# Accept that failure only if the asset matches. +upload_release_file() { + local upload_path="$1" + local upload_status + if gh release upload "${VERSION}" "${upload_path}" --clobber; then + return 0 + else + upload_status=$? + fi + + local verify_dir + verify_dir="$(mktemp -d)" + if gh release download "${VERSION}" --pattern "${upload_path##*/}" \ + --output "${verify_dir}/asset" && cmp -s "${upload_path}" "${verify_dir}/asset"; then + rm -rf "${verify_dir}" + echo "Release asset already matches ${upload_path}" + return 0 + fi + rm -rf "${verify_dir}" + echo "Could not verify release asset after upload failed: ${upload_path}" >&2 + return "${upload_status}" +} + upload_asset() { require_version local release_zip_path @@ -558,7 +590,7 @@ upload_asset() { return 0 fi echo "Uploading release asset ${upload_path}" - gh release upload "${VERSION}" "${upload_path}" --clobber + upload_release_file "${upload_path}" } # Sparkle ships generate_appcast inside its SwiftPM artifact, @@ -627,7 +659,8 @@ build_appcast() { key_args=(--ed-key-file "${SPARKLE_PRIVATE_KEY_FILE}") fi echo "Generating appcast in ${APPCAST_DIR}" - # macOS ships bash 3.2, where an empty array trips set -u. + # macOS ships bash 3.2, + # where an empty array trips set -u. "${SPARKLE_BIN}/generate_appcast" \ ${key_args[@]+"${key_args[@]}"} \ --download-url-prefix "${RELEASE_DOWNLOAD_BASE}/${VERSION}/" \ @@ -659,7 +692,7 @@ upload_appcast() { return 0 fi echo "Uploading appcast to release ${VERSION}" - gh release upload "${VERSION}" "${appcast_path}" --clobber + upload_release_file "${appcast_path}" } all() { From b4fdc6694eb8c386de9e763ec3f4a474867c1732 Mon Sep 17 00:00:00 2001 From: Mattt Date: Fri, 18 Sep 2026 05:49:26 -0700 Subject: [PATCH 42/56] Name the Bonjour service iMCP (#228) --- App/Controllers/ServerController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index bfadfd56..b7ebf05a 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -627,7 +627,7 @@ actor NetworkDiscoveryManager { // Listen and advertise via Bonjour. self.listener = try NWListener(using: parameters) - self.listener.service = NWListener.Service(type: serviceType, domain: serviceDomain) + self.listener.service = NWListener.Service(name: "iMCP", type: serviceType, domain: serviceDomain) // Browser is used for monitoring and diagnostics. self.browser = NWBrowser( @@ -673,7 +673,7 @@ actor NetworkDiscoveryManager { } let newListener: NWListener = try NWListener(using: parameters) - let service = NWListener.Service(type: self.serviceType, domain: self.serviceDomain) + let service = NWListener.Service(name: "iMCP", type: self.serviceType, domain: self.serviceDomain) newListener.service = service if let currentStateHandler = listener.stateUpdateHandler { From 8bce43ad0fbdb57fc049222f79d50abf9003ca27 Mon Sep 17 00:00:00 2001 From: Mattt Date: Fri, 18 Sep 2026 05:49:33 -0700 Subject: [PATCH 43/56] Fetch editable contacts and serialize contact updates (#231) --- App/Services/Contacts.swift | 111 +++++++++++++++++------------------- 1 file changed, 51 insertions(+), 60 deletions(-) diff --git a/App/Services/Contacts.swift b/App/Services/Contacts.swift index 48938e11..72e76191 100644 --- a/App/Services/Contacts.swift +++ b/App/Services/Contacts.swift @@ -79,13 +79,16 @@ private let contactProperties: OrderedDictionary = [ final class ContactsService: Service { private let contactStore = CNContactStore() + private let contactStoreQueue = DispatchQueue(label: "iMCP.contacts", qos: .utility) static let shared = ContactsService() private func runContactStore(_ operation: @escaping () throws -> T) async throws -> T { - try await Task(priority: .utility) { - try operation() - }.value + try await withCheckedThrowingContinuation { continuation in + contactStoreQueue.async { + continuation.resume(with: Result { try operation() }) + } + } } var isActivated: Bool { @@ -189,7 +192,6 @@ final class ContactsService: Service { } if case let .string(email) = arguments["email"] { - // Normalize email to lowercase let normalizedEmail = email.trimmingCharacters(in: .whitespaces).lowercased() if !normalizedEmail.isEmpty { predicates.append( @@ -208,7 +210,6 @@ final class ContactsService: Service { ) } - // Combine predicates with AND if multiple criteria are provided let finalPredicate = predicates.count == 1 ? predicates[0] @@ -268,10 +269,7 @@ final class ContactsService: Service { return results } - // Map then page. `Person(CNContact)` is nil for `.organization` cards, - // so paging the raw store and compactMapping afterward can return fewer - // than `limit` items for a full page — callers treat that as EOF and - // never request the next offset. + // Filter before paging so skipped organizations do not shorten pages. let people = contacts.compactMap { listedPerson(from: $0) } return Array(people.dropFirst(offset).prefix(limit)) } @@ -279,7 +277,7 @@ final class ContactsService: Service { Tool( name: "contacts_update", description: - "Update an existing contact's information. Only provide values for properties that need to be changed; omit any properties that should remain unchanged.", + "Update an existing contact's information. Only provide values for properties that need to be changed; omit any properties that should remain unchanged. Reading or changing contact notes is not supported.", inputSchema: .object( properties: ([ "identifier": .string( @@ -306,39 +304,40 @@ final class ContactsService: Service { ) } - // Fetch the mutable copy of the contact - let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier]) - let contact = - try await self.runContactStore { - try self.contactStore.unifiedContacts(matching: predicate, keysToFetch: contactKeys) + // Serialize the fetch, edit, and save together + // so later updates use the latest contact. + return try await self.runContactStore { + // Preserve the stored identity and leave unfetched fields untouched. + let request = CNContactFetchRequest(keysToFetch: contactKeys) + request.predicate = CNContact.predicateForContacts(withIdentifiers: [identifier]) + request.mutableObjects = true + request.unifyResults = true + + var contact: CNMutableContact? + try self.contactStore.enumerateContacts(with: request) { fetchedContact, stop in + contact = fetchedContact as? CNMutableContact + stop.pointee = true } - .first? - .mutableCopy() as? CNMutableContact - guard let updatedContact = contact else { - throw NSError( - domain: "ContactsService", - code: 2, - userInfo: [ - NSLocalizedDescriptionKey: - "Contact not found with identifier: \(identifier)" - ] - ) - } + guard let updatedContact = contact else { + throw NSError( + domain: "ContactsService", + code: 2, + userInfo: [ + NSLocalizedDescriptionKey: + "Contact not found with identifier: \(identifier)" + ] + ) + } - // Update all properties - updatedContact.populate(from: arguments) + updatedContact.populate(from: arguments) - // Create a save request - let saveRequest = CNSaveRequest() - saveRequest.update(updatedContact) + let saveRequest = CNSaveRequest() + saveRequest.update(updatedContact) - // Save the changes - try await self.runContactStore { try self.contactStore.execute(saveRequest) + return Person(updatedContact) } - - return Person(updatedContact) } Tool( @@ -355,38 +354,30 @@ final class ContactsService: Service { openWorldHint: false ) ) { arguments in - // Create and populate a new contact - let newContact = CNMutableContact() - newContact.populate(from: arguments) - - // Validate that given name is provided and not empty - if newContact.givenName.isEmpty { - throw NSError( - domain: "ContactsService", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Given name is required"] - ) - } + return try await self.runContactStore { + let newContact = CNMutableContact() + newContact.populate(from: arguments) + + if newContact.givenName.isEmpty { + throw NSError( + domain: "ContactsService", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Given name is required"] + ) + } - // Create a save request - let saveRequest = CNSaveRequest() - saveRequest.add(newContact, toContainerWithIdentifier: nil) + let saveRequest = CNSaveRequest() + saveRequest.add(newContact, toContainerWithIdentifier: nil) - // Execute the save request - try await self.runContactStore { try self.contactStore.execute(saveRequest) + return Person(newContact) } - - return Person(newContact) } } } -/// `Person(CNContact)` returns nil when `contactType == .organization`. -/// Contacts.app still stores people that way (the "Company" toggle) -/// with given and family names filled in. -/// Treat those cards as people so search and list don't omit them; -/// cards that carry only an organization name stay excluded. +/// Includes people marked as companies in Contacts. +/// Excludes organization cards without a given or family name. private func listedPerson(from contact: CNContact) -> Person? { if let person = Person(contact) { return person From 6c0dbfa97632c2175daa684d94178717a37dffe8 Mon Sep 17 00:00:00 2001 From: Mattt Date: Fri, 18 Sep 2026 05:52:04 -0700 Subject: [PATCH 44/56] Request service permissions from tool handlers (#230) * Request service permissions from tool handlers * Share location authorization across concurrent requests --- App/Services/Calendar.swift | 10 +++--- App/Services/Capture.swift | 18 ++-------- App/Services/Location.swift | 64 ++++++++++++++++++++++++------------ App/Services/Reminders.swift | 10 ++---- 4 files changed, 53 insertions(+), 49 deletions(-) diff --git a/App/Services/Calendar.swift b/App/Services/Calendar.swift index cebf37b7..0a486b7d 100644 --- a/App/Services/Calendar.swift +++ b/App/Services/Calendar.swift @@ -36,7 +36,7 @@ final class CalendarService: Service { openWorldHint: false ) ) { arguments in - guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { + guard try await self.eventStore.requestFullAccessToEvents() else { log.error("Calendar access not authorized") throw NSError( domain: "CalendarError", @@ -103,7 +103,7 @@ final class CalendarService: Service { openWorldHint: false ) ) { arguments in - guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { + guard try await self.eventStore.requestFullAccessToEvents() else { log.error("Calendar access not authorized") throw NSError( domain: "CalendarError", @@ -355,9 +355,7 @@ final class CalendarService: Service { openWorldHint: false ) ) { arguments in - try await self.activate() - - guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { + guard try await self.eventStore.requestFullAccessToEvents() else { log.error("Calendar access not authorized") throw NSError( domain: "CalendarError", @@ -581,7 +579,7 @@ final class CalendarService: Service { openWorldHint: false ) ) { arguments in - guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { + guard try await self.eventStore.requestFullAccessToEvents() else { log.error("Calendar access not authorized") throw NSError( domain: "CalendarError", diff --git a/App/Services/Capture.swift b/App/Services/Capture.swift index eeef1439..a4c2095e 100644 --- a/App/Services/Capture.swift +++ b/App/Services/Capture.swift @@ -156,13 +156,7 @@ final class CaptureService: NSObject, Service { openWorldHint: false ) ) { arguments in - guard await self.isActivated else { - throw NSError( - domain: "CaptureServiceError", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Camera access not authorized"] - ) - } + try await self.requestPermission(for: .video) let format = ImageFormat( @@ -342,10 +336,7 @@ final class CaptureService: NSObject, Service { openWorldHint: false ) ) { arguments in - if AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { - // Try to request permission if not authorized - try await self.requestPermission(for: .audio) - } + try await self.requestPermission(for: .audio) let format = AudioFormat( @@ -448,10 +439,7 @@ final class CaptureService: NSObject, Service { openWorldHint: false ) ) { arguments in - if !CGPreflightScreenCaptureAccess() { - // Try to request permission if not authorized - try await self.requestScreenRecordingPermission() - } + try await self.requestScreenRecordingPermission() let contentType = ScreenCaptureContentType( diff --git a/App/Services/Location.swift b/App/Services/Location.swift index 39e513f2..7dd15649 100644 --- a/App/Services/Location.swift +++ b/App/Services/Location.swift @@ -15,7 +15,8 @@ final class LocationService: NSObject, Service, CLLocationManagerDelegate { return manager }() private var latestLocation: CLLocation? - private var authorizationContinuation: CheckedContinuation? + @MainActor private var authorizationTask: Task? + @MainActor private var authorizationContinuation: CheckedContinuation? static let shared = LocationService() @@ -26,8 +27,7 @@ final class LocationService: NSObject, Service, CLLocationManagerDelegate { locationManager.delegate = self // Check authorization status first to avoid any permission prompts - let status = locationManager.authorizationStatus - if (status == .authorizedAlways) && CLLocationManager.locationServicesEnabled() { + if isAuthorized && CLLocationManager.locationServicesEnabled() { log.debug("Starting location updates with existing authorization...") locationManager.startUpdatingLocation() } @@ -40,11 +40,33 @@ final class LocationService: NSObject, Service, CLLocationManagerDelegate { var isActivated: Bool { get async { - return locationManager.authorizationStatus == .authorizedAlways + return isAuthorized } } + private var isAuthorized: Bool { + switch locationManager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + return true + default: + return false + } + } + + @MainActor func activate() async throws { + if let authorizationTask { + return try await authorizationTask.value + } + + let task = Task { try await self.requestAuthorization() } + authorizationTask = task + defer { authorizationTask = nil } + try await task.value + } + + @MainActor + private func requestAuthorization() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in self.authorizationContinuation = continuation @@ -102,25 +124,11 @@ final class LocationService: NSObject, Service, CLLocationManagerDelegate { openWorldHint: false ) ) { _ in + try await self.activate() + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in Task { - let status = self.locationManager.authorizationStatus - - guard status == .authorizedAlways else { - log.error("Location access not authorized") - continuation.resume( - throwing: NSError( - domain: "LocationServiceError", - code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Location access not authorized" - ] - ) - ) - return - } - // If we already have a recent location, use it if let location = self.latestLocation { continuation.resume( @@ -397,6 +405,13 @@ final class LocationService: NSObject, Service, CLLocationManagerDelegate { _ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus ) { + Task { @MainActor in + self.completeAuthorization(status) + } + } + + @MainActor + private func completeAuthorization(_ status: CLAuthorizationStatus) { switch status { case .authorizedWhenInUse, .authorizedAlways: log.debug("Location access authorized") @@ -418,7 +433,14 @@ final class LocationService: NSObject, Service, CLLocationManagerDelegate { break @unknown default: log.error("Unknown location authorization status") - break + authorizationContinuation?.resume( + throwing: NSError( + domain: "LocationServiceError", + code: 8, + userInfo: [NSLocalizedDescriptionKey: "Unknown authorization status"] + ) + ) + authorizationContinuation = nil } } } diff --git a/App/Services/Reminders.swift b/App/Services/Reminders.swift index 43f957d7..030dc575 100644 --- a/App/Services/Reminders.swift +++ b/App/Services/Reminders.swift @@ -34,7 +34,7 @@ final class RemindersService: Service { openWorldHint: false ) ) { arguments in - guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else { + guard try await self.eventStore.requestFullAccessToReminders() else { log.error("Reminders access not authorized") throw NSError( domain: "RemindersError", @@ -92,9 +92,7 @@ final class RemindersService: Service { openWorldHint: false ) ) { arguments in - try await self.activate() - - guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else { + guard try await self.eventStore.requestFullAccessToReminders() else { log.error("Reminders access not authorized") throw NSError( domain: "RemindersError", @@ -226,9 +224,7 @@ final class RemindersService: Service { openWorldHint: false ) ) { arguments in - try await self.activate() - - guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else { + guard try await self.eventStore.requestFullAccessToReminders() else { log.error("Reminders access not authorized") throw NSError( domain: "RemindersError", From 17dccd28a16208d9289939b45e44b36a0764009d Mon Sep 17 00:00:00 2001 From: Mattt Date: Fri, 18 Sep 2026 05:58:27 -0700 Subject: [PATCH 45/56] Connect over loopback after Bonjour discovery (#229) --- App/Controllers/ServerController.swift | 32 +++++++++++++++------ CLI/BonjourDiscovery.swift | 21 +++++++++++++- CLI/MCPService.swift | 2 +- CLITests/BonjourDiscoveryTests.swift | 39 ++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 11 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index b7ebf05a..2385e523 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -609,6 +609,7 @@ actor NetworkDiscoveryManager { private let serviceDomain: String var listener: NWListener private let browser: NWBrowser + private var listenerStateHandler: (@Sendable (NWListener.State) -> Void)? init(serviceType: String, serviceDomain: String) throws { self.serviceType = serviceType @@ -625,9 +626,8 @@ actor NetworkDiscoveryManager { tcpOptions.version = .v4 } - // Listen and advertise via Bonjour. + // Advertise once the listener has an assigned port. self.listener = try NWListener(using: parameters) - self.listener.service = NWListener.Service(name: "iMCP", type: serviceType, domain: serviceDomain) // Browser is used for monitoring and diagnostics. self.browser = NWBrowser( @@ -642,7 +642,8 @@ actor NetworkDiscoveryManager { stateHandler: @escaping @Sendable (NWListener.State) -> Void, connectionHandler: @escaping @Sendable (NWConnection) -> Void ) { - listener.stateUpdateHandler = stateHandler + listenerStateHandler = stateHandler + configureStateHandler(for: listener) listener.newConnectionHandler = connectionHandler @@ -652,6 +653,24 @@ actor NetworkDiscoveryManager { log.info("Started network discovery and advertisement") } + private func configureStateHandler(for listener: NWListener) { + let serviceType = self.serviceType + let serviceDomain = self.serviceDomain + let stateHandler = listenerStateHandler + listener.stateUpdateHandler = { [weak listener] state in + if case .ready = state, let listener, let port = listener.port { + // Publish the assigned port before clients can discover the service. + listener.service = NWListener.Service( + name: "iMCP", + type: serviceType, + domain: serviceDomain, + txtRecord: NWTXTRecord(["port": String(port.rawValue)]) + ) + } + stateHandler?(state) + } + } + func stop() { listener.cancel() browser.cancel() @@ -673,12 +692,7 @@ actor NetworkDiscoveryManager { } let newListener: NWListener = try NWListener(using: parameters) - let service = NWListener.Service(name: "iMCP", type: self.serviceType, domain: self.serviceDomain) - newListener.service = service - - if let currentStateHandler = listener.stateUpdateHandler { - newListener.stateUpdateHandler = currentStateHandler - } + configureStateHandler(for: newListener) if let currentConnectionHandler = listener.newConnectionHandler { newListener.newConnectionHandler = currentConnectionHandler diff --git a/CLI/BonjourDiscovery.swift b/CLI/BonjourDiscovery.swift index b3cf0fea..2a32739b 100644 --- a/CLI/BonjourDiscovery.swift +++ b/CLI/BonjourDiscovery.swift @@ -62,7 +62,9 @@ enum BonjourDiscovery { Task { if await state.checkAndSetResumed() { timeoutTask.cancel() - continuation.resume(returning: selected.endpoint) + continuation.resume( + returning: connectionEndpoint(for: selected.endpoint, metadata: selected.metadata) + ) } } } @@ -70,4 +72,21 @@ enum BonjourDiscovery { browser.start(queue: .main) } } + + static func connectionEndpoint( + for serviceEndpoint: NWEndpoint, + metadata: NWBrowser.Result.Metadata + ) -> NWEndpoint { + guard case .bonjour(let record) = metadata, + let value = record["port"], + let number = UInt16(value), + number > 0, + let port = NWEndpoint.Port(rawValue: number) + else { + return serviceEndpoint + } + + // Avoid service resolution selecting a Docker bridge address (#142). + return .hostPort(host: .ipv4(.loopback), port: port) + } } diff --git a/CLI/MCPService.swift b/CLI/MCPService.swift index d9c455ce..f4acbb16 100644 --- a/CLI/MCPService.swift +++ b/CLI/MCPService.swift @@ -27,7 +27,7 @@ actor MCPService: Service { await log.info("Starting Bonjour service discovery...") let browser = NWBrowser( - for: .bonjour(type: serviceType, domain: nil), + for: .bonjourWithTXTRecord(type: serviceType, domain: nil), using: parameters ) diff --git a/CLITests/BonjourDiscoveryTests.swift b/CLITests/BonjourDiscoveryTests.swift index 0f748294..356df8c2 100644 --- a/CLITests/BonjourDiscoveryTests.swift +++ b/CLITests/BonjourDiscoveryTests.swift @@ -9,6 +9,45 @@ import XCTest /// Leaking one per attempt eventually exhausts the daemon's descriptors /// and breaks DNS for every process on the machine (#192). final class BonjourDiscoveryTests: XCTestCase { + private let serviceEndpoint = NWEndpoint.service( + name: "iMCP", + type: "_mcp._tcp", + domain: "local.", + interface: nil + ) + + func testAdvertisedPortUsesIPv4Loopback() { + for number: UInt16 in [1, 54321, 65535] { + let endpoint = BonjourDiscovery.connectionEndpoint( + for: serviceEndpoint, + metadata: .bonjour(NWTXTRecord(["port": String(number)])) + ) + + XCTAssertEqual(endpoint, .hostPort(host: "127.0.0.1", port: NWEndpoint.Port(rawValue: number)!)) + } + } + + func testMissingPortUsesServiceEndpoint() { + for metadata: NWBrowser.Result.Metadata in [.none, .bonjour(NWTXTRecord(["version": "1"]))] { + XCTAssertEqual( + BonjourDiscovery.connectionEndpoint(for: serviceEndpoint, metadata: metadata), + serviceEndpoint + ) + } + } + + func testInvalidPortUsesServiceEndpoint() { + for value in ["", "0", "-1", "65536", "abc", "http", "12.5"] { + XCTAssertEqual( + BonjourDiscovery.connectionEndpoint( + for: serviceEndpoint, + metadata: .bonjour(NWTXTRecord(["port": value])) + ), + serviceEndpoint, + "Expected fallback for port value: \(value)" + ) + } + } /// A browser that times out without finding anything must be cancelled, /// not left running. From acaa8c1e1887d8dcad135ce458f2f7d5cb5ed70e Mon Sep 17 00:00:00 2001 From: Mattt Date: Sun, 20 Sep 2026 08:34:26 -0700 Subject: [PATCH 46/56] Replace MenuBarExtraAccess with a local menu panel controller (#233) * Replace MenuBarExtraAccess with a local menu panel controller The app used the package only to dismiss the menu and to resize its panel. A small internal controller now gets the panel window from an embedded NSView, orders it out for menu commands, and resizes it to fit its content. * Dismiss the menu with SwiftUI's dismiss action Ordering the panel window out hides it while MenuBarExtra still considers the status item presented, so the next click on the icon does nothing. The menu buttons now call the dismiss action from the environment. * Shorten the menu dismissal comment --- App/App.swift | 4 - App/Views/ContentView.swift | 112 +++++++++++++----- iMCP.xcodeproj/project.pbxproj | 17 --- .../xcshareddata/swiftpm/Package.resolved | 9 -- 4 files changed, 85 insertions(+), 57 deletions(-) diff --git a/App/App.swift b/App/App.swift index e97b9b63..8cfcfe48 100644 --- a/App/App.swift +++ b/App/App.swift @@ -1,4 +1,3 @@ -import MenuBarExtraAccess import Sparkle import SwiftUI @@ -6,7 +5,6 @@ import SwiftUI struct App: SwiftUI.App { @StateObject private var serverController = ServerController() @AppStorage("isEnabled") private var isEnabled = true - @State private var isMenuPresented = false // `startingUpdater: true` makes this the sole owner of update checking for // the app's lifetime. Without it (or without ever constructing an updater @@ -23,12 +21,10 @@ struct App: SwiftUI.App { ContentView( serverManager: serverController, isEnabled: $isEnabled, - isMenuPresented: $isMenuPresented, updater: updaterController.updater ) } .menuBarExtraStyle(.window) - .menuBarExtraAccess(isPresented: $isMenuPresented) Settings { SettingsView(serverController: serverController) diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index 69b56ff9..30e9e5a7 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -1,17 +1,15 @@ import AppKit -import MenuBarExtraAccess import Sparkle import SwiftUI struct ContentView: View { @ObservedObject var serverController: ServerController @Binding var isEnabled: Bool - @Binding var isMenuPresented: Bool @Environment(\.openSettings) private var openSettings private let aboutWindowController: AboutWindowController private let updater: SPUUpdater - @State private var contentHeight: CGFloat = 0 + @State private var menuPanel = MenuPanelController() private var serviceConfigs: [ServiceConfig] { serverController.computedServiceConfigs @@ -28,12 +26,10 @@ struct ContentView: View { init( serverManager: ServerController, isEnabled: Binding, - isMenuPresented: Binding, updater: SPUUpdater ) { self.serverController = serverManager self._isEnabled = isEnabled - self._isMenuPresented = isMenuPresented self.aboutWindowController = AboutWindowController() self.updater = updater } @@ -83,11 +79,11 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 2) { Divider() - MenuButton("Configure Claude Desktop", isMenuPresented: $isMenuPresented) { + MenuButton("Configure Claude Desktop") { ClaudeDesktop.showConfigurationPanel() } - MenuButton("Copy server command to clipboard", isMenuPresented: $isMenuPresented) { + MenuButton("Copy server command to clipboard") { let command = Bundle.main.bundleURL .appendingPathComponent("Contents/MacOS/imcp-server") .path @@ -106,23 +102,23 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 2) { Divider() - MenuButton("Settings...", isMenuPresented: $isMenuPresented) { + MenuButton("Settings...") { // openSettings() alone is unreliable for this accessory (LSUIElement) app. NSApp.activate(ignoringOtherApps: true) openSettings() } - MenuButton("Check for Updates...", isMenuPresented: $isMenuPresented) { + MenuButton("Check for Updates...") { updater.checkForUpdates() } .disabled(!updater.canCheckForUpdates) - MenuButton("About iMCP", isMenuPresented: $isMenuPresented) { + MenuButton("About iMCP") { aboutWindowController.showWindow(nil) NSApp.activate(ignoringOtherApps: true) } - MenuButton("Quit", isMenuPresented: $isMenuPresented) { + MenuButton("Quit") { NSApplication.shared.terminate(nil) } } @@ -133,51 +129,110 @@ struct ContentView: View { .onGeometryChange(for: CGFloat.self) { proxy in proxy.size.height } action: { height in - contentHeight = height - resizeMenuBarExtraWindow() + menuPanel.contentHeight = height + menuPanel.resizeToFitContent() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Material.thick) - .onChange(of: isMenuPresented) { _, presented in - if presented { - resizeMenuBarExtraWindow() + .background(MenuPanelWindowReader(controller: menuPanel)) + } +} + +/// Resizes the `MenuBarExtra` panel that shows ``ContentView`` to fit its content. +@MainActor +final class MenuPanelController { + var contentHeight: CGFloat = 0 + + private weak var window: NSWindow? + private var windowObserver: NSObjectProtocol? + + func attach(to newWindow: NSWindow?) { + guard newWindow !== window else { return } + + if let windowObserver { + NotificationCenter.default.removeObserver(windowObserver) + self.windowObserver = nil + } + + window = newWindow + guard let newWindow else { return } + + windowObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: newWindow, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.resizeToFitContent() } } + resizeToFitContent() } // MenuBarExtra's window keeps a stale taller frame when its content shrinks, // leaving the content vertically centered in an oversized panel. Resize it to fit. - private func resizeMenuBarExtraWindow() { - DispatchQueue.main.async { - guard contentHeight > 0, - let window = NSApp.windows.first(where: { $0.className.contains("MenuBarExtraWindow") }) - else { return } + func resizeToFitContent() { + DispatchQueue.main.async { [weak self] in + guard let self, let window = self.window, self.contentHeight > 0 else { return } let frame = window.frame - guard abs(frame.height - contentHeight) > 0.5 else { return } + guard abs(frame.height - self.contentHeight) > 0.5 else { return } window.setFrame( - NSRect(x: frame.minX, y: frame.maxY - contentHeight, width: frame.width, height: contentHeight), + NSRect( + x: frame.minX, + y: frame.maxY - self.contentHeight, + width: frame.width, + height: self.contentHeight + ), display: true ) } } } +/// Gives ``MenuPanelController`` the window that contains this view. +private struct MenuPanelWindowReader: NSViewRepresentable { + let controller: MenuPanelController + + func makeNSView(context: Context) -> WindowReaderView { + WindowReaderView(controller: controller) + } + + func updateNSView(_ nsView: WindowReaderView, context: Context) {} + + final class WindowReaderView: NSView { + private let controller: MenuPanelController + + init(controller: MenuPanelController) { + self.controller = controller + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + controller.attach(to: window) + } + } +} + private struct MenuButton: View { @Environment(\.isEnabled) private var isEnabled private let title: String private let action: () -> Void - @Binding private var isMenuPresented: Bool + @Environment(\.dismiss) private var dismiss @State private var isHighlighted: Bool = false @State private var isPressed: Bool = false init( _ title: S, - isMenuPresented: Binding, action: @escaping () -> Void ) where S: StringProtocol { self.title = String(title) - self._isMenuPresented = isMenuPresented self.action = action } @@ -208,7 +263,10 @@ private struct MenuButton: View { } action() - isMenuPresented = false + + // Hiding the panel window directly leaves the status item presented, + // so the next click on it does nothing. + dismiss() } } .frame(height: 18) diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index b8aac11c..dbea19e2 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -20,7 +20,6 @@ F825BDBF2D9AD00E0063ADD7 /* ServiceLifecycle in Frameworks */ = {isa = PBXBuildFile; productRef = F825BDBE2D9AD00E0063ADD7 /* ServiceLifecycle */; }; F825BDC12D9AD00E0063ADD7 /* UnixSignals in Frameworks */ = {isa = PBXBuildFile; productRef = F825BDC02D9AD00E0063ADD7 /* UnixSignals */; }; F873F48D2D712BCF0035CD0A /* Ontology in Frameworks */ = {isa = PBXBuildFile; productRef = F873F48C2D712BCF0035CD0A /* Ontology */; }; - F87796FC2E0764AE00328CC6 /* MenuBarExtraAccess in Frameworks */ = {isa = PBXBuildFile; productRef = F87796FB2E0764AE00328CC6 /* MenuBarExtraAccess */; }; 158948D22D0E610E83A4433C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B75D49165A453E912E36DBFE /* Sparkle */; }; F88358372D64A085000317CD /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = F88358362D64A085000317CD /* Logging */; }; F885C6B82D66079100963B25 /* imcp-server in Copy Executables */ = {isa = PBXBuildFile; fileRef = F8F44EB62D5908D00075D79C /* imcp-server */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; @@ -111,7 +110,6 @@ buildActionMask = 2147483647; files = ( F809556E2D7888920055B911 /* TypedStream in Frameworks */, - F87796FC2E0764AE00328CC6 /* MenuBarExtraAccess in Frameworks */, 158948D22D0E610E83A4433C /* Sparkle in Frameworks */, F873F48D2D712BCF0035CD0A /* Ontology in Frameworks */, F8D7C31A2DCBD32100A4775F /* MCP in Frameworks */, @@ -237,7 +235,6 @@ F8D7C3162DCBD30500A4775F /* MCP */, F8D7C3192DCBD32100A4775F /* MCP */, F8D8C48D2DCE0E6800369E5C /* JSONSchema */, - F87796FB2E0764AE00328CC6 /* MenuBarExtraAccess */, B75D49165A453E912E36DBFE /* Sparkle */, ); productName = iMCP; @@ -304,7 +301,6 @@ F825BDBD2D9AD00E0063ADD7 /* XCRemoteSwiftPackageReference "swift-service-lifecycle" */, F8D7C3182DCBD32100A4775F /* XCRemoteSwiftPackageReference "swift-sdk" */, F8D8C48C2DCE0E6800369E5C /* XCRemoteSwiftPackageReference "JSONSchema" */, - F87796FA2E0764AE00328CC6 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */, 26120478122FBFCB2B073DE5 /* XCRemoteSwiftPackageReference "Sparkle" */, ); preferredProjectObjectVersion = 77; @@ -756,14 +752,6 @@ minimumVersion = 0.8.0; }; }; - F87796FA2E0764AE00328CC6 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/orchetect/MenuBarExtraAccess"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.2.1; - }; - }; F88358352D64A085000317CD /* XCRemoteSwiftPackageReference "swift-log" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-log"; @@ -839,11 +827,6 @@ package = F873F48B2D712BCF0035CD0A /* XCRemoteSwiftPackageReference "Ontology" */; productName = Ontology; }; - F87796FB2E0764AE00328CC6 /* MenuBarExtraAccess */ = { - isa = XCSwiftPackageProductDependency; - package = F87796FA2E0764AE00328CC6 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */; - productName = MenuBarExtraAccess; - }; F88358362D64A085000317CD /* Logging */ = { isa = XCSwiftPackageProductDependency; package = F88358352D64A085000317CD /* XCRemoteSwiftPackageReference "swift-log" */; diff --git a/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 588b382c..7e68531f 100644 --- a/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -28,15 +28,6 @@ "version" : "0.5.0" } }, - { - "identity" : "menubarextraaccess", - "kind" : "remoteSourceControl", - "location" : "https://github.com/orchetect/MenuBarExtraAccess", - "state" : { - "revision" : "e911e6454f8cbfe34a52136fc48e1ceb989a60e7", - "version" : "1.2.1" - } - }, { "identity" : "ontology", "kind" : "remoteSourceControl", From a4096d0e87b526861d40d4348b41eb362eaa4b1d Mon Sep 17 00:00:00 2001 From: Mattt Date: Sun, 20 Sep 2026 09:12:20 -0700 Subject: [PATCH 47/56] Keep iMCP running when its menu bar item is removed (#234) * Keep iMCP running when its menu bar item is removed * Reflow updater comment with semantic line breaks * Open Settings on launch when the menu bar item is hidden --- App/App.swift | 44 ++++++++++++++++++++++++++++++++---- App/Views/SettingsView.swift | 9 ++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/App/App.swift b/App/App.swift index 8cfcfe48..4192e8d6 100644 --- a/App/App.swift +++ b/App/App.swift @@ -3,13 +3,18 @@ import SwiftUI @main struct App: SwiftUI.App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @Environment(\.openSettings) private var openSettings @StateObject private var serverController = ServerController() @AppStorage("isEnabled") private var isEnabled = true + @AppStorage("showMenuBarExtra") private var showMenuBarExtra = true - // `startingUpdater: true` makes this the sole owner of update checking for - // the app's lifetime. Without it (or without ever constructing an updater - // at all), the SUFeedURL / SUPublicEDKey keys in Info.plist are inert and - // installs never learn about newer releases, no matter how long they run. + // `startingUpdater: true` makes this the sole owner of update checking + // for the app's lifetime. + // Without it (or without ever constructing an updater at all), + // the SUFeedURL / SUPublicEDKey keys in Info.plist are inert + // and installs never learn about newer releases, + // no matter how long they run. private let updaterController = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: nil, @@ -17,7 +22,12 @@ struct App: SwiftUI.App { ) var body: some Scene { - MenuBarExtra("iMCP", image: #"MenuIcon-\#(isEnabled ? "On" : "Off")"#) { + // The binding lets removal hide the item without terminating the server. + MenuBarExtra( + "iMCP", + image: #"MenuIcon-\#(isEnabled ? "On" : "Off")"#, + isInserted: $showMenuBarExtra + ) { ContentView( serverManager: serverController, isEnabled: $isEnabled, @@ -25,6 +35,12 @@ struct App: SwiftUI.App { ) } .menuBarExtraStyle(.window) + .onChange(of: appDelegate.shouldOpenSettings, initial: true) { _, shouldOpenSettings in + guard shouldOpenSettings else { return } + appDelegate.shouldOpenSettings = false + NSApp.activate(ignoringOtherApps: true) + openSettings() + } Settings { SettingsView(serverController: serverController) @@ -40,3 +56,21 @@ struct App: SwiftUI.App { } } } + +@Observable +final class AppDelegate: NSObject, NSApplicationDelegate { + var shouldOpenSettings = false + + func applicationDidFinishLaunching(_ notification: Notification) { + // An unset preference defaults to showing the menu bar item. + if UserDefaults.standard.object(forKey: "showMenuBarExtra") as? Bool == false { + shouldOpenSettings = true + } + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { + // Reopening the app must work even when the menu bar item is absent. + shouldOpenSettings = true + return false + } +} diff --git a/App/Views/SettingsView.swift b/App/Views/SettingsView.swift index df2c2d94..d10f8a7a 100644 --- a/App/Views/SettingsView.swift +++ b/App/Views/SettingsView.swift @@ -71,6 +71,7 @@ struct SettingsView: View { } struct GeneralSettingsView: View { + @AppStorage("showMenuBarExtra") private var showMenuBarExtra = true @ObservedObject var serverController: ServerController @State private var showingResetAlert = false @State private var selectedClients = Set() @@ -83,6 +84,14 @@ struct GeneralSettingsView: View { var body: some View { Form { Section { + VStack(alignment: .leading, spacing: 4) { + Toggle("Show in Menu Bar", isOn: $showMenuBarExtra) + + Text("If the icon is hidden, open iMCP again to return to Settings.") + .font(.caption) + .foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 4) { Toggle("Launch at Login", isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, enabled in From 8cfd596f7078e07e081e21e2d5424d326ea51a21 Mon Sep 17 00:00:00 2001 From: Mattt Date: Sun, 20 Sep 2026 14:30:24 -0700 Subject: [PATCH 48/56] Release 1.5.1 --- iMCP.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index dbea19e2..64c6b1b0 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -528,7 +528,7 @@ CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = YES; @@ -570,7 +570,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -592,7 +592,7 @@ CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = TTY35UM57S; ENABLE_APP_SANDBOX = YES; @@ -638,7 +638,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.5.0; + MARKETING_VERSION = 1.5.1; PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From dfc5c8f339dc98fc4e63235a8e62df1853b09af9 Mon Sep 17 00:00:00 2001 From: Mattt Date: Mon, 21 Sep 2026 06:24:25 -0700 Subject: [PATCH 49/56] Preserve the menu bar preference when macOS removes the icon (#236) --- App/App.swift | 15 +++++++++++++-- App/Views/SettingsView.swift | 22 +++++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/App/App.swift b/App/App.swift index 4192e8d6..dec6bb4f 100644 --- a/App/App.swift +++ b/App/App.swift @@ -22,11 +22,13 @@ struct App: SwiftUI.App { ) var body: some Scene { + @Bindable var appDelegate = appDelegate + // The binding lets removal hide the item without terminating the server. MenuBarExtra( "iMCP", image: #"MenuIcon-\#(isEnabled ? "On" : "Off")"#, - isInserted: $showMenuBarExtra + isInserted: $appDelegate.isMenuBarExtraInserted ) { ContentView( serverManager: serverController, @@ -35,6 +37,9 @@ struct App: SwiftUI.App { ) } .menuBarExtraStyle(.window) + .onChange(of: showMenuBarExtra) { _, showMenuBarExtra in + appDelegate.isMenuBarExtraInserted = showMenuBarExtra + } .onChange(of: appDelegate.shouldOpenSettings, initial: true) { _, shouldOpenSettings in guard shouldOpenSettings else { return } appDelegate.shouldOpenSettings = false @@ -43,7 +48,10 @@ struct App: SwiftUI.App { } Settings { - SettingsView(serverController: serverController) + SettingsView( + serverController: serverController, + isMenuBarExtraInserted: appDelegate.isMenuBarExtraInserted + ) } .commands { @@ -60,6 +68,9 @@ struct App: SwiftUI.App { @Observable final class AppDelegate: NSObject, NSApplicationDelegate { var shouldOpenSettings = false + // System removal must not overwrite the user's saved preference. + var isMenuBarExtraInserted = + UserDefaults.standard.object(forKey: "showMenuBarExtra") as? Bool ?? true func applicationDidFinishLaunching(_ notification: Notification) { // An unset preference defaults to showing the menu bar item. diff --git a/App/Views/SettingsView.swift b/App/Views/SettingsView.swift index d10f8a7a..da690560 100644 --- a/App/Views/SettingsView.swift +++ b/App/Views/SettingsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct SettingsView: View { @ObservedObject var serverController: ServerController + var isMenuBarExtraInserted: Bool @State private var selectedSection: SettingsSection? = .general enum SettingsSection: String, CaseIterable, Identifiable { @@ -40,9 +41,12 @@ struct SettingsView: View { if let selectedSection { switch selectedSection { case .general: - GeneralSettingsView(serverController: serverController) - .navigationTitle("General") - .formStyle(.grouped) + GeneralSettingsView( + serverController: serverController, + isMenuBarExtraInserted: isMenuBarExtraInserted + ) + .navigationTitle("General") + .formStyle(.grouped) case .services: ServicesSettingsView(serverController: serverController) .navigationTitle("Services") @@ -73,6 +77,7 @@ struct SettingsView: View { struct GeneralSettingsView: View { @AppStorage("showMenuBarExtra") private var showMenuBarExtra = true @ObservedObject var serverController: ServerController + var isMenuBarExtraInserted: Bool @State private var showingResetAlert = false @State private var selectedClients = Set() @State private var launchAtLogin = SMAppService.mainApp.status == .enabled @@ -87,6 +92,17 @@ struct GeneralSettingsView: View { VStack(alignment: .leading, spacing: 4) { Toggle("Show in Menu Bar", isOn: $showMenuBarExtra) + if showMenuBarExtra && !isMenuBarExtraInserted { + Group { + Text("macOS hid the menu bar icon.") + if #available(macOS 26.0, *) { + Text("Check System Settings > Menu Bar.") + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + Text("If the icon is hidden, open iMCP again to return to Settings.") .font(.caption) .foregroundStyle(.secondary) From 5b71979832301f4bcbed3783920e0e5883fd1067 Mon Sep 17 00:00:00 2001 From: Mattt Date: Mon, 21 Sep 2026 06:26:46 -0700 Subject: [PATCH 50/56] Release 1.5.2 (#237) --- iMCP.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index 64c6b1b0..7f043043 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -528,7 +528,7 @@ CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = YES; @@ -570,7 +570,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.5.1; + MARKETING_VERSION = 1.5.2; PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -592,7 +592,7 @@ CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 14; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = TTY35UM57S; ENABLE_APP_SANDBOX = YES; @@ -638,7 +638,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.5.1; + MARKETING_VERSION = 1.5.2; PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 9afde85e725e91113e1dc3bfac1cb6c25eec33a2 Mon Sep 17 00:00:00 2001 From: Mattt Date: Mon, 21 Sep 2026 09:58:51 -0700 Subject: [PATCH 51/56] Prevent repeated NECP listener errors (#238) --- App/Controllers/ServerController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 2385e523..5eb8beea 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -619,6 +619,8 @@ actor NetworkDiscoveryManager { let parameters = NWParameters.tcp parameters.acceptLocalOnly = true parameters.includePeerToPeer = false + // Match the IPv4 protocol below to avoid NECP errors for an IPv6 wildcard. + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.any), port: .any) if let tcpOptions = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options @@ -684,6 +686,8 @@ actor NetworkDiscoveryManager { let parameters: NWParameters = NWParameters.tcp // Explicit type parameters.acceptLocalOnly = true parameters.includePeerToPeer = false + // Match the IPv4 protocol below to avoid NECP errors for an IPv6 wildcard. + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.any), port: .any) if let tcpOptions = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options From cc66b2e28bc84c3ec5218c46ef0802ec97d68f74 Mon Sep 17 00:00:00 2001 From: Mattt Date: Tue, 22 Sep 2026 12:53:08 -0700 Subject: [PATCH 52/56] Update Ontology to 0.10.0 (#240) Also points the Ontology, Madrid, and JSONSchema dependencies at their mattt/ repositories instead of the loopwork-ai redirects. Re-resolving JSONSchema at its new location picks up 1.3.1, a patch release within the existing range. --- iMCP.xcodeproj/project.pbxproj | 8 ++++---- .../xcshareddata/swiftpm/Package.resolved | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index 7f043043..ca73d22e 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -730,7 +730,7 @@ /* Begin XCRemoteSwiftPackageReference section */ F809556C2D7888920055B911 /* XCRemoteSwiftPackageReference "madrid" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/loopwork-ai/madrid"; + repositoryURL = "https://github.com/mattt/Madrid"; requirement = { kind = upToNextMajorVersion; minimumVersion = 0.5.0; @@ -746,10 +746,10 @@ }; F873F48B2D712BCF0035CD0A /* XCRemoteSwiftPackageReference "Ontology" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/loopwork-ai/Ontology"; + repositoryURL = "https://github.com/mattt/Ontology"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.8.0; + minimumVersion = 0.10.0; }; }; F88358352D64A085000317CD /* XCRemoteSwiftPackageReference "swift-log" */ = { @@ -770,7 +770,7 @@ }; F8D8C48C2DCE0E6800369E5C /* XCRemoteSwiftPackageReference "JSONSchema" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/loopwork-ai/JSONSchema.git"; + repositoryURL = "https://github.com/mattt/JSONSchema.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.3.0; diff --git a/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7e68531f..1adb2627 100644 --- a/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ef84066429fbd998da047140f11ad84f410b82811512bb502cfd69ad588f22d1", + "originHash" : "648cead66f491dae7b6c7cadbde59aaf2c01ecc423f1f63bd2781736a561f2a3", "pins" : [ { "identity" : "eventsource", @@ -13,16 +13,16 @@ { "identity" : "jsonschema", "kind" : "remoteSourceControl", - "location" : "https://github.com/loopwork-ai/JSONSchema.git", + "location" : "https://github.com/mattt/JSONSchema.git", "state" : { - "revision" : "e17c9e1fb6afbad656824d03f996cf8621f9db83", - "version" : "1.3.0" + "revision" : "4c6f2467d5bc72b062c7a9b7e4cd77a09635ea8b", + "version" : "1.3.1" } }, { "identity" : "madrid", "kind" : "remoteSourceControl", - "location" : "https://github.com/loopwork-ai/madrid", + "location" : "https://github.com/mattt/Madrid", "state" : { "revision" : "11ec80cb67700e47948f90cef6de38bce4dbfb12", "version" : "0.5.0" @@ -31,10 +31,10 @@ { "identity" : "ontology", "kind" : "remoteSourceControl", - "location" : "https://github.com/loopwork-ai/Ontology", + "location" : "https://github.com/mattt/Ontology", "state" : { - "revision" : "3d1d2545b826909a3f6499f2ef661429bf9c10d5", - "version" : "0.8.0" + "revision" : "8c57a9162cbe0a27d84d63428fcb697e2fa22a3a", + "version" : "0.10.0" } }, { From 296a708b1968e982c4a0ead1dc335ef701e85019 Mon Sep 17 00:00:00 2001 From: Mattt Date: Tue, 22 Sep 2026 13:30:26 -0700 Subject: [PATCH 53/56] Bump version to 1.5.3 (#241) --- iMCP.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index ca73d22e..971610ad 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -528,7 +528,7 @@ CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = YES; @@ -570,7 +570,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.5.2; + MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -592,7 +592,7 @@ CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = TTY35UM57S; ENABLE_APP_SANDBOX = YES; @@ -638,7 +638,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.5.2; + MARKETING_VERSION = 1.5.3; PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 0a469688a7a2e301c8e2696dc41c275526b65f17 Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 24 Sep 2026 06:16:57 -0700 Subject: [PATCH 54/56] Accept connections only over loopback (#242) --- App/Controllers/ServerController.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 5eb8beea..e2eeef00 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -619,8 +619,9 @@ actor NetworkDiscoveryManager { let parameters = NWParameters.tcp parameters.acceptLocalOnly = true parameters.includePeerToPeer = false - // Match the IPv4 protocol below to avoid NECP errors for an IPv6 wildcard. - parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.any), port: .any) + // Accept connections from this Mac only; the CLI connects over loopback (#229). + // IPv4 matches the protocol below and avoids NECP errors for an IPv6 wildcard. + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any) if let tcpOptions = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options @@ -686,8 +687,9 @@ actor NetworkDiscoveryManager { let parameters: NWParameters = NWParameters.tcp // Explicit type parameters.acceptLocalOnly = true parameters.includePeerToPeer = false - // Match the IPv4 protocol below to avoid NECP errors for an IPv6 wildcard. - parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.any), port: .any) + // Accept connections from this Mac only; the CLI connects over loopback (#229). + // IPv4 matches the protocol below and avoids NECP errors for an IPv6 wildcard. + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any) if let tcpOptions = parameters.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options From 6cb3c826514bdb4ec1cfd48466c2820e06655e53 Mon Sep 17 00:00:00 2001 From: Mattt Date: Thu, 24 Sep 2026 06:19:36 -0700 Subject: [PATCH 55/56] Bump version to 1.5.4 (#243) --- iMCP.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index 971610ad..afcf264d 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -528,7 +528,7 @@ CODE_SIGN_IDENTITY = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_APP_SANDBOX = YES; @@ -570,7 +570,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.5.3; + MARKETING_VERSION = 1.5.4; PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -592,7 +592,7 @@ CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = TTY35UM57S; ENABLE_APP_SANDBOX = YES; @@ -638,7 +638,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 1.5.3; + MARKETING_VERSION = 1.5.4; PRODUCT_BUNDLE_IDENTIFIER = co.dododo.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 190a3af85a378d2510be75bdfb8d2168828b299e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 19:28:11 +0000 Subject: [PATCH 56/56] Format LAN toggle for swift-format lint Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GAFz1WodA51dwyzn4eyjbc --- App/Views/SettingsView.swift | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/App/Views/SettingsView.swift b/App/Views/SettingsView.swift index 167dc091..cc442724 100644 --- a/App/Views/SettingsView.swift +++ b/App/Views/SettingsView.swift @@ -104,15 +104,17 @@ struct GeneralSettingsView: View { } .padding(.bottom, 4) - Toggle(isOn: Binding( - get: { self.allowLANConnections }, - set: { newValue in - self.allowLANConnections = newValue - Task { - await self.serverController.setAllowLANConnections(newValue) + Toggle( + isOn: Binding( + get: { self.allowLANConnections }, + set: { newValue in + self.allowLANConnections = newValue + Task { + await self.serverController.setAllowLANConnections(newValue) + } } - } - )) { + ) + ) { VStack(alignment: .leading, spacing: 4) { Text("Allow connections from other devices on this network") .font(.body)