diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac501cc7..540d5bba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,18 +27,34 @@ 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: 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') }} + 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 . - 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 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/.gitignore b/.gitignore index f0b756b0..4bf67fd8 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 diff --git a/App/App.swift b/App/App.swift index e395e546..dec6bb4f 100644 --- a/App/App.swift +++ b/App/App.swift @@ -1,25 +1,57 @@ -import MenuBarExtraAccess +import Sparkle 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 - @State private var isMenuPresented = false + @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. + private let updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) var body: some Scene { - MenuBarExtra("iMCP", image: #"MenuIcon-\#(isEnabled ? "On" : "Off")"#) { + @Bindable var appDelegate = appDelegate + + // The binding lets removal hide the item without terminating the server. + MenuBarExtra( + "iMCP", + image: #"MenuIcon-\#(isEnabled ? "On" : "Off")"#, + isInserted: $appDelegate.isMenuBarExtraInserted + ) { ContentView( serverManager: serverController, isEnabled: $isEnabled, - isMenuPresented: $isMenuPresented + updater: updaterController.updater ) } .menuBarExtraStyle(.window) - .menuBarExtraAccess(isPresented: $isMenuPresented) + .onChange(of: showMenuBarExtra) { _, showMenuBarExtra in + appDelegate.isMenuBarExtraInserted = showMenuBarExtra + } + .onChange(of: appDelegate.shouldOpenSettings, initial: true) { _, shouldOpenSettings in + guard shouldOpenSettings else { return } + appDelegate.shouldOpenSettings = false + NSApp.activate(ignoringOtherApps: true) + openSettings() + } Settings { - SettingsView(serverController: serverController) + SettingsView( + serverController: serverController, + isMenuBarExtraInserted: appDelegate.isMenuBarExtraInserted + ) } .commands { @@ -32,3 +64,24 @@ 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. + 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/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 5c497203..d7855f07 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -56,6 +56,7 @@ enum ServiceRegistry { LocationService.shared, MapsService.shared, MessageService.shared, + PhoneService.shared, RemindersService.shared, ShortcutsService.shared, UtilitiesService.shared, @@ -73,6 +74,7 @@ enum ServiceRegistry { locationEnabled: Binding, mapsEnabled: Binding, messagesEnabled: Binding, + phoneEnabled: Binding, remindersEnabled: Binding, shortcutsEnabled: Binding, utilitiesEnabled: Binding, @@ -121,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", @@ -154,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() @@ -171,6 +180,7 @@ final class ServerController: ObservableObject { @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 @@ -182,6 +192,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( @@ -191,6 +205,7 @@ final class ServerController: ObservableObject { locationEnabled: $locationEnabled, mapsEnabled: $mapsEnabled, messagesEnabled: $messagesEnabled, + phoneEnabled: $phoneEnabled, remindersEnabled: $remindersEnabled, shortcutsEnabled: $shortcutsEnabled, utilitiesEnabled: $utilitiesEnabled, @@ -245,15 +260,54 @@ final class ServerController: ObservableObject { await networkManager.setAllowLANConnections(allowed) } - // MARK: - Connection Approval Methods - private func cleanupApprovalState() { - pendingClientName = "" - currentApprovalHandlers = nil + // 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) } + } + } - if let clientID = pendingConnectionID { - activeApprovalDialogs.remove(clientID) - pendingConnectionID = nil + 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(for clientID: String) { + activeApprovalDialogs.remove(clientID) } private func handlePendingApprovals(for clientID: String, approved: Bool) { @@ -270,13 +324,15 @@ 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) await networkManager.setAllowLANConnections(self.allowLANConnections) - 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 { @@ -306,6 +362,9 @@ final class ServerController: ObservableObject { } } } + + await self.networkManager.start() + self.updateServerStatus("Running") } } @@ -373,8 +432,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)") @@ -385,11 +442,12 @@ 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: + computedServiceConfigs + .filter { $0.binding.wrappedValue } + .map { $0.name }, onApprove: { alwaysTrust in if alwaysTrust { self.addTrustedClient(clientID) @@ -409,12 +467,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) } ) @@ -432,6 +490,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 @@ -483,6 +542,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 @@ -537,6 +604,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() } @@ -549,17 +619,17 @@ actor NetworkDiscoveryManager { var listener: NWListener private let browser: NWBrowser private var allowLANConnections: Bool = false + private var listenerStateHandler: (@Sendable (NWListener.State) -> Void)? init(serviceType: String, serviceDomain: String) throws { self.serviceType = serviceType self.serviceDomain = serviceDomain - // Start with loopback-only by default + // Start with loopback-only by default. let parameters = Self.createParameters(allowLAN: false) - // Listen and advertise via Bonjour. + // Advertise once the listener has an assigned port. self.listener = try NWListener(using: parameters) - self.listener.service = NWListener.Service(type: serviceType, domain: serviceDomain) // Browser is used for monitoring and diagnostics. self.browser = NWBrowser( @@ -572,15 +642,16 @@ actor NetworkDiscoveryManager { private static func createParameters(allowLAN: Bool) -> NWParameters { let parameters = NWParameters.tcp - + parameters.includePeerToPeer = false + if allowLAN { - // Allow connections from all interfaces (LAN) + // Allow connections from all interfaces (LAN). parameters.acceptLocalOnly = false - parameters.includePeerToPeer = false } else { - // Restrict to loopback-only (same machine) + // 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.acceptLocalOnly = true - parameters.includePeerToPeer = false + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any) } if let tcpOptions = parameters.defaultProtocolStack.internetProtocol @@ -596,7 +667,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 @@ -606,6 +678,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() @@ -619,12 +709,7 @@ actor NetworkDiscoveryManager { let parameters: NWParameters = Self.createParameters(allowLAN: allowLANConnections) let newListener: NWListener = try NWListener(using: parameters) - let service = NWListener.Service(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 @@ -634,7 +719,7 @@ actor NetworkDiscoveryManager { self.listener = newListener - log.notice("Restarted listener with a dynamic port (allowLAN: \(self.allowLANConnections))") + log.notice("Restarted listener with a dynamic port (allowLAN: \(self.allowLANConnections))") } func setAllowLANConnections(_ allowed: Bool) async throws { @@ -646,7 +731,13 @@ actor NetworkDiscoveryManager { allowLANConnections = allowed log.info("Updating LAN connection setting to: \(allowed)") - // Restart the listener with the new parameters + // Before start(), replace the idle listener instead of starting it early. + guard listenerStateHandler != nil else { + listener = try NWListener(using: Self.createParameters(allowLAN: allowed)) + return + } + + // Restart the listener with the new parameters. try await restartWithRandomPort() } } @@ -657,6 +748,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 = [] @@ -665,6 +761,8 @@ actor ServerNetworkManager { private let services = ServiceRegistry.services private var serviceBindings: [String: Binding] = [:] + private var disabledTools: Set = [] + private var disabledToolsLastGeneration = -1 init() { do { @@ -843,6 +941,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 { @@ -853,7 +952,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") @@ -866,8 +973,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, @@ -881,6 +997,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)") @@ -909,6 +1029,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( @@ -951,6 +1075,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)) @@ -1058,6 +1196,22 @@ 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() + } + } + } + // Update LAN connection setting and restart the listener. func setAllowLANConnections(_ allowed: Bool) async { guard let discoveryManager = discoveryManager else { 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/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= diff --git a/App/Services/Calendar.swift b/App/Services/Calendar.swift index 1a58890d..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", @@ -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 @@ -216,7 +226,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", @@ -341,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", @@ -533,7 +545,149 @@ 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 + } + + Tool( + name: "events_delete", + 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", + 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 try await self.eventStore.requestFullAccessToEvents() 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"] + ) + } + + 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, + userInfo: [ + NSLocalizedDescriptionKey: "No event found with identifier \(identifier)" + ] + ) + } + + 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) + + return Value.object([ + "deleted": .bool(true), + "identifier": .string(identifier), + ]) } } } diff --git a/App/Services/Capture.swift b/App/Services/Capture.swift index 55b2456b..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( @@ -279,8 +273,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 +291,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() @@ -336,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( @@ -384,10 +381,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) @@ -442,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/Contacts.swift b/App/Services/Contacts.swift index 76940970..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 { @@ -147,7 +150,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( @@ -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] @@ -221,13 +222,62 @@ 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 + request.sortOrder = .userDefault + try self.contactStore.enumerateContacts(with: request) { contact, _ in + results.append(contact) + } + return results + } + + // Filter before paging so skipped organizations do not shorten pages. + let people = contacts.compactMap { listedPerson(from: $0) } + return Array(people.dropFirst(offset).prefix(limit)) } 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( @@ -254,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( @@ -303,29 +354,39 @@ final class ContactsService: Service { openWorldHint: false ) ) { arguments in - // Create and populate a new contact - let newContact = CNMutableContact() - newContact.populate(from: arguments) + return try await self.runContactStore { + 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"] - ) - } + 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) } } } + +/// 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 + } + 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) +} diff --git a/App/Services/Location.swift b/App/Services/Location.swift index 4912fc52..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( @@ -180,11 +188,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"], @@ -393,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") @@ -414,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/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 diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index 7a517bef..a532d74d 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -1,18 +1,34 @@ import AppKit import OSLog +import os 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() + /// 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) + } + + /// 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 +36,47 @@ 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 { + 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." + ) + } 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() + 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: selectedURL.path) else { + guard FileManager.default.isReadableFile(atPath: databaseURL(in: selectedURL).path) else { throw DatabaseAccessError.fileNotReadable } @@ -42,7 +86,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 } @@ -72,6 +121,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) @@ -86,7 +138,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({ @@ -117,9 +169,13 @@ 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() + // 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)") @@ -128,14 +184,31 @@ 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 } + 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" @@ -151,14 +224,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") @@ -193,9 +272,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" } } } @@ -224,22 +303,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)") @@ -248,14 +391,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") @@ -263,24 +416,24 @@ 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 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" - else { + guard openPanel.runModal() == .OK, let url = openPanel.url else { + return nil + } + guard isMessagesDirectory(url) else { throw DatabaseAccessError.invalidFileSelected } @@ -301,9 +454,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/App/Services/Phone.swift b/App/Services/Phone.swift new file mode 100644 index 00000000..c906b6a8 --- /dev/null +++ b/App/Services/Phone.swift @@ -0,0 +1,768 @@ +import AppKit +import OSLog +import Ontology +import SQLite3 + +private let log = Logger.service("phone") +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 + +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. + /// + /// 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 + } + + 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 folder picker for manual database selection") + guard try await showDatabaseAccessAlert() else { + throw DatabaseAccessError.userDeclinedAccess + } + + 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 + } + + 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 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)") + return false + } + } + + /// 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 + } + } + + 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 { + 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 { + if canAccessDatabaseAtDefaultPath { + 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) + } + } + + /// 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)) + } + } + + // MARK: - Errors + + private enum DatabaseAccessError: LocalizedError { + case noBookmarkFound + case securityScopeAccessFailed + case insufficientGrant + 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 .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 the selection is not the CallHistoryDB folder" + case .fileNotReadable: + return "The selected folder has no readable \(callHistoryDatabaseName)" + } + } + } + + 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 access to your CallHistoryDB folder: \ + the database and the log new calls are written to first. + + In the next screen, please select the `CallHistoryDB` folder and click "Grant Access". + """ + alert.alertStyle = .informational + alert.addButton(withTitle: "Continue") + alert.addButton(withTitle: "Cancel") + + return alert.runModal() == .alertFirstButtonReturn + } + + /// Returns the selected folder, or nil when the user dismisses the panel. + @MainActor + private func showFolderPicker() async throws -> URL? { + let openPanel = NSOpenPanel() + openPanel.delegate = self + openPanel.message = + "Please select your call history folder (~/Library/Application Support/CallHistoryDB)" + openPanel.prompt = "Grant Access" + openPanel.directoryURL = URL(fileURLWithPath: callHistoryDirectoryPath) + .deletingLastPathComponent() + openPanel.allowsMultipleSelection = false + openPanel.canChooseDirectories = true + openPanel.canChooseFiles = false + openPanel.showsHiddenFiles = true + + guard openPanel.runModal() == .OK, let url = openPanel.url else { + return nil + } + guard isCallHistoryDirectory(url) 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") + } + + 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 = isCallHistoryDirectory(url) + 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 + + /// 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(filename, &connection, flags, 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] + } +} 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", diff --git a/App/Views/ConnectionApprovalView.swift b/App/Views/ConnectionApprovalView.swift index 1df36145..e0885c55 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 @@ -85,25 +93,37 @@ 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, + enabledServiceNames: [String], 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, - onApprove: { alwaysTrust in + enabledServiceNames: enabledServiceNames, + 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) } ) @@ -124,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) @@ -138,28 +160,44 @@ 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)?() } } #Preview { ConnectionApprovalView( clientName: "Claude Desktop", + enabledServiceNames: ["Calendar", "Contacts", "Location"], onApprove: { alwaysTrust in print("Approved with always trust: \(alwaysTrust)") }, diff --git a/App/Views/ContentView.swift b/App/Views/ContentView.swift index 8b6aa6ee..30e9e5a7 100644 --- a/App/Views/ContentView.swift +++ b/App/Views/ContentView.swift @@ -1,14 +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 menuPanel = MenuPanelController() private var serviceConfigs: [ServiceConfig] { serverController.computedServiceConfigs @@ -25,12 +26,12 @@ 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 } var body: some View { @@ -72,18 +73,17 @@ 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() - 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 @@ -91,6 +91,8 @@ struct ContentView: View { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(command, forType: .string) + + _ = NSSound.play(.pop) } } .padding(.top, 8) @@ -100,16 +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("About iMCP", isMenuPresented: $isMenuPresented) { + MenuButton("Check for Updates...") { + updater.checkForUpdates() + } + .disabled(!updater.canCheckForUpdates) + + MenuButton("About iMCP") { aboutWindowController.showWindow(nil) NSApp.activate(ignoringOtherApps: true) } - MenuButton("Quit", isMenuPresented: $isMenuPresented) { + MenuButton("Quit") { NSApplication.shared.terminate(nil) } } @@ -117,7 +126,96 @@ struct ContentView: View { .padding(.horizontal, 2) } .padding(.vertical, 6) + .onGeometryChange(for: CGFloat.self) { proxy in + proxy.size.height + } action: { height in + menuPanel.contentHeight = height + menuPanel.resizeToFitContent() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Material.thick) + .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. + 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 - self.contentHeight) > 0.5 else { return } + window.setFrame( + 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) + } } } @@ -126,17 +224,15 @@ private struct MenuButton: View { 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 } @@ -167,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/App/Views/ServiceToggleView.swift b/App/Views/ServiceToggleView.swift index a0ca7429..53a83d44 100644 --- a/App/Views/ServiceToggleView.swift +++ b/App/Views/ServiceToggleView.swift @@ -41,6 +41,10 @@ struct ServiceToggleView: View { .buttonStyle(PlainButtonStyle()) .disabled(!isEnabled) .frame(width: buttonSize, height: buttonSize) + .accessibilityLabel(config.name) + .accessibilityValue(config.binding.wrappedValue ? "Enabled" : "Disabled") + .accessibilityAddTraits(.isButton) + .help("\(config.name) tools: \(toolSummary)") Text(config.name) .frame(maxWidth: .infinity, alignment: .leading) @@ -69,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 e5bd3f26..cc442724 100644 --- a/App/Views/SettingsView.swift +++ b/App/Views/SettingsView.swift @@ -1,17 +1,21 @@ +import ServiceManagement import SwiftUI struct SettingsView: View { @ObservedObject var serverController: ServerController + var isMenuBarExtraInserted: Bool @State private var selectedSection: SettingsSection? = .general 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" } } } @@ -37,9 +41,15 @@ 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") } } else { Text("Select a category") @@ -65,10 +75,13 @@ 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() @AppStorage("allowLANConnections") private var allowLANConnections = false + @State private var launchAtLogin = SMAppService.mainApp.status == .enabled private var trustedClients: [String] { serverController.getTrustedClients() @@ -91,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) @@ -116,6 +131,49 @@ struct GeneralSettingsView: View { } // Trusted Clients Section + Section { + 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) + } + + 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 { @@ -131,18 +189,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 { @@ -151,6 +215,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) { @@ -165,6 +237,10 @@ struct GeneralSettingsView: View { } selectedClients.removeAll() } + + Text("Clients are identified by the name they report, not by a verified identity.") + .font(.caption) + .foregroundStyle(.secondary) } } } 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/CLI/BonjourDiscovery.swift b/CLI/BonjourDiscovery.swift new file mode 100644 index 00000000..2a32739b --- /dev/null +++ b/CLI/BonjourDiscovery.swift @@ -0,0 +1,92 @@ +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: connectionEndpoint(for: selected.endpoint, metadata: selected.metadata) + ) + } + } + } + + 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 new file mode 100644 index 00000000..f4acbb16 --- /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: .bonjourWithTXTRecord(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..4da36553 --- /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 = { [weak self] 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 = { [weak self] 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[.. 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 - private let endpoint: NWEndpoint - private let parameters: NWParameters - private let stdinBufferSize: Int - private let networkBufferSize: Int - - // Connection state - private var connection: NWConnection? - private var isRunning = false - - // Message buffering for proper JSON-RPC message boundaries - private var networkToStdoutBuffer = Data() - - /// Creates a new StdioProxy with the specified network configuration - /// - Parameters: - /// - endpoint: The network endpoint to connect to - /// - parameters: Network connection parameters - /// - stdinBufferSize: Buffer size for reading from stdin (default: 4096) - /// - networkBufferSize: Buffer size for reading from network (default: 4096) - 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 - - // Create the connection - let connection = NWConnection(to: endpoint, using: parameters) - self.connection = connection - - // Start the connection - connection.start(queue: .main) - - // Set up state monitoring for the entire lifetime of the connection - connection.stateUpdateHandler = { state in - Task { [weak self] in - await self?.handleConnectionState(state, continuation: nil, connectionState: nil) - } - } - - // Wait for the connection to become ready - 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 - ) - } - } - } - - // 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[.. Bool { + for _ in 0 ..< attempts { + if case .cancelled = browser.state { return true } + try? await Task.sleep(for: .milliseconds(50)) + } + return false + } +} diff --git a/CLITests/MCPServiceLoopTests.swift b/CLITests/MCPServiceLoopTests.swift new file mode 100644 index 00000000..b49de22e --- /dev/null +++ b/CLITests/MCPServiceLoopTests.swift @@ -0,0 +1,40 @@ +import XCTest + +final class MCPServiceLoopTests: XCTestCase { + + /// Stdin EOF used to complete `proxy.start()` without throwing, and the + /// service loop treated that as a reason to discover Bonjour again. + /// With stdin already closed, that reconnects until crash. + func testSuccessfulProxyReturnTerminates() { + XCTAssertEqual(MCPServiceLoop.decision(error: nil), .terminate) + } + + func testStdinOrNetworkCloseTerminates() { + XCTAssertEqual( + MCPServiceLoop.decision(error: StdioProxyError.connectionClosed), + .terminate + ) + } + + func testCancellationTerminates() { + XCTAssertEqual( + MCPServiceLoop.decision(error: CancellationError()), + .terminate + ) + } + + func testNetworkTimeoutReconnects() { + XCTAssertEqual( + MCPServiceLoop.decision(error: StdioProxyError.networkTimeout), + .reconnect(seconds: 1) + ) + } + + func testUnexpectedErrorReconnectsAfterBackoff() { + struct Unexpected: Error {} + XCTAssertEqual( + MCPServiceLoop.decision(error: Unexpected()), + .reconnect(seconds: 5) + ) + } +} diff --git a/README.md b/README.md index cc2ec01e..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. + @@ -277,9 +291,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, @@ -292,6 +314,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 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) diff --git a/Scripts/release.sh b/Scripts/release.sh index 40c64ae0..3dc5a0f4 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,15 +48,21 @@ 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 install Install locally 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) @@ -62,10 +78,18 @@ 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 } -# 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 @@ -94,11 +118,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() { @@ -264,10 +304,47 @@ 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() { @@ -327,12 +404,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() { @@ -388,18 +472,106 @@ 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 +} + +# 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() { @@ -414,9 +586,114 @@ 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}" + upload_release_file "${upload_path}" +} + +# 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}" + upload_release_file "${appcast_path}" } install() { @@ -431,13 +708,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() { @@ -489,9 +772,18 @@ case "${COMMAND}" in upload) upload ;; + appcast) + appcast + ;; + upload-appcast) + upload_appcast + ;; + publish) + publish_release + ;; install) install - ;; + ;; help|-h|--help) print_usage ;; diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index d11f0f2e..4d270b41 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -11,12 +11,16 @@ 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 */; }; + B0D1DEADBEEF000000000006 /* MCPServiceLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF000000000005 /* MCPServiceLoop.swift */; }; + B0D1DEADBEEF000000000008 /* MCPServiceLoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF000000000007 /* MCPServiceLoopTests.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 */; }; 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 */; }; @@ -54,6 +58,10 @@ /* 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 = ""; }; + B0D1DEADBEEF000000000005 /* MCPServiceLoop.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MCPServiceLoop.swift; path = ../CLI/MCPServiceLoop.swift; sourceTree = ""; }; + B0D1DEADBEEF000000000007 /* MCPServiceLoopTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MCPServiceLoopTests.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; }; @@ -102,7 +110,7 @@ buildActionMask = 2147483647; 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 */, @@ -139,6 +147,10 @@ isa = PBXGroup; children = ( 75E148EAC5AC8B3263C5AA13 /* ServiceGroupConfigurationTests.swift */, + B0D1DEADBEEF000000000003 /* BonjourDiscoveryTests.swift */, + B0D1DEADBEEF000000000001 /* BonjourDiscovery.swift */, + B0D1DEADBEEF000000000007 /* MCPServiceLoopTests.swift */, + B0D1DEADBEEF000000000005 /* MCPServiceLoop.swift */, ); path = CLITests; sourceTree = ""; @@ -223,7 +235,7 @@ F8D7C3162DCBD30500A4775F /* MCP */, F8D7C3192DCBD32100A4775F /* MCP */, F8D8C48D2DCE0E6800369E5C /* JSONSchema */, - F87796FB2E0764AE00328CC6 /* MenuBarExtraAccess */, + B75D49165A453E912E36DBFE /* Sparkle */, ); productName = iMCP; productReference = F8F44E6D2D59038D0075D79C /* iMCP.app */; @@ -289,7 +301,7 @@ F825BDBD2D9AD00E0063ADD7 /* XCRemoteSwiftPackageReference "swift-service-lifecycle" */, F8D7C3182DCBD32100A4775F /* XCRemoteSwiftPackageReference "swift-sdk" */, F8D8C48C2DCE0E6800369E5C /* XCRemoteSwiftPackageReference "JSONSchema" */, - F87796FA2E0764AE00328CC6 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */, + 26120478122FBFCB2B073DE5 /* XCRemoteSwiftPackageReference "Sparkle" */, ); preferredProjectObjectVersion = 77; productRefGroup = F8F44E6E2D59038D0075D79C /* Products */; @@ -326,6 +338,10 @@ buildActionMask = 2147483647; files = ( 930214FCD680D7D74158F215 /* ServiceGroupConfigurationTests.swift in Sources */, + B0D1DEADBEEF000000000004 /* BonjourDiscoveryTests.swift in Sources */, + B0D1DEADBEEF000000000002 /* BonjourDiscovery.swift in Sources */, + B0D1DEADBEEF000000000008 /* MCPServiceLoopTests.swift in Sources */, + B0D1DEADBEEF000000000006 /* MCPServiceLoop.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -512,7 +528,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 16; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = N4S3J237EK; ENABLE_APP_SANDBOX = YES; @@ -554,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.4.1; + MARKETING_VERSION = 1.5.4; PRODUCT_BUNDLE_IDENTIFIER = com.primadeluxe.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -576,7 +592,7 @@ CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 16; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = N4S3J237EK; ENABLE_APP_SANDBOX = YES; @@ -622,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.4.1; + MARKETING_VERSION = 1.5.4; PRODUCT_BUNDLE_IDENTIFIER = com.primadeluxe.iMCP; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -713,10 +729,10 @@ /* 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.1.0; + minimumVersion = 0.5.0; }; }; F825BDBD2D9AD00E0063ADD7 /* XCRemoteSwiftPackageReference "swift-service-lifecycle" */ = { @@ -729,18 +745,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; - }; - }; - F87796FA2E0764AE00328CC6 /* XCRemoteSwiftPackageReference "MenuBarExtraAccess" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/orchetect/MenuBarExtraAccess"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.2.1; + minimumVersion = 0.10.0; }; }; F88358352D64A085000317CD /* XCRemoteSwiftPackageReference "swift-log" */ = { @@ -761,12 +769,20 @@ }; 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; }; }; + 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 */ @@ -810,11 +826,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" */; @@ -846,6 +857,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..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" : "0604c8a8c049fb6e696fcd8af68cdc36f2f23f938eec962e31f24cc19d550d45", + "originHash" : "648cead66f491dae7b6c7cadbde59aaf2c01ecc423f1f63bd2781736a561f2a3", "pins" : [ { "identity" : "eventsource", @@ -13,37 +13,37 @@ { "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" : "9d9fdb20424483fb592e5a026d9d0816cd5c43eb", - "version" : "0.1.0" + "revision" : "11ec80cb67700e47948f90cef6de38bce4dbfb12", + "version" : "0.5.0" } }, { - "identity" : "menubarextraaccess", + "identity" : "ontology", "kind" : "remoteSourceControl", - "location" : "https://github.com/orchetect/MenuBarExtraAccess", + "location" : "https://github.com/mattt/Ontology", "state" : { - "revision" : "e911e6454f8cbfe34a52136fc48e1ceb989a60e7", - "version" : "1.2.1" + "revision" : "8c57a9162cbe0a27d84d63428fcb697e2fa22a3a", + "version" : "0.10.0" } }, { - "identity" : "ontology", + "identity" : "sparkle", "kind" : "remoteSourceControl", - "location" : "https://github.com/loopwork-ai/Ontology", + "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "3d1d2545b826909a3f6499f2ef661429bf9c10d5", - "version" : "0.8.0" + "revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a", + "version" : "2.9.6" } }, {