From d38655369cbe8f20720889c6d0e9b90bf78b77a7 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 8 Sep 2026 05:47:48 -0700 Subject: [PATCH 1/8] Rename Call History service to Phone --- App/Controllers/ServerController.swift | 22 +++++++++---------- .../{CallHistory.swift => Phone.swift} | 14 ++++++------ 2 files changed, 18 insertions(+), 18 deletions(-) rename App/Services/{CallHistory.swift => Phone.swift} (97%) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 4755634c..889a6846 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -51,12 +51,12 @@ enum ServiceRegistry { static let services: [any Service] = { var services: [any Service] = [ CalendarService.shared, - CallHistoryService.shared, CaptureService.shared, ContactsService.shared, LocationService.shared, MapsService.shared, MessageService.shared, + PhoneService.shared, RemindersService.shared, ShortcutsService.shared, UtilitiesService.shared, @@ -69,12 +69,12 @@ enum ServiceRegistry { static func configureServices( calendarEnabled: Binding, - callHistoryEnabled: Binding, captureEnabled: Binding, contactsEnabled: Binding, locationEnabled: Binding, mapsEnabled: Binding, messagesEnabled: Binding, + phoneEnabled: Binding, remindersEnabled: Binding, shortcutsEnabled: Binding, utilitiesEnabled: Binding, @@ -88,13 +88,6 @@ enum ServiceRegistry { service: CalendarService.shared, binding: calendarEnabled ), - ServiceConfig( - name: "Call History", - iconName: "phone.fill", - color: .green.mix(with: .blue, by: 0.3), - service: CallHistoryService.shared, - binding: callHistoryEnabled - ), ServiceConfig( name: "Capture", iconName: "camera.on.rectangle.fill", @@ -130,6 +123,13 @@ enum ServiceRegistry { service: MessageService.shared, binding: messagesEnabled ), + ServiceConfig( + name: "Phone", + iconName: "phone.fill", + color: .green.mix(with: .blue, by: 0.3), + service: PhoneService.shared, + binding: phoneEnabled + ), ServiceConfig( name: "Reminders", iconName: "list.bullet", @@ -175,12 +175,12 @@ final class ServerController: ObservableObject { // MARK: - AppStorage for Service Enablement States @AppStorage("calendarEnabled") private var calendarEnabled = false - @AppStorage("callHistoryEnabled") private var callHistoryEnabled = false @AppStorage("captureEnabled") private var captureEnabled = false @AppStorage("contactsEnabled") private var contactsEnabled = false @AppStorage("locationEnabled") private var locationEnabled = false @AppStorage("mapsEnabled") private var mapsEnabled = true // Default enabled @AppStorage("messagesEnabled") private var messagesEnabled = false + @AppStorage("phoneEnabled") private var phoneEnabled = false @AppStorage("remindersEnabled") private var remindersEnabled = false @AppStorage("shortcutsEnabled") private var shortcutsEnabled = false @AppStorage("utilitiesEnabled") private var utilitiesEnabled = true // Default enabled @@ -197,12 +197,12 @@ final class ServerController: ObservableObject { var computedServiceConfigs: [ServiceConfig] { ServiceRegistry.configureServices( calendarEnabled: $calendarEnabled, - callHistoryEnabled: $callHistoryEnabled, captureEnabled: $captureEnabled, contactsEnabled: $contactsEnabled, locationEnabled: $locationEnabled, mapsEnabled: $mapsEnabled, messagesEnabled: $messagesEnabled, + phoneEnabled: $phoneEnabled, remindersEnabled: $remindersEnabled, shortcutsEnabled: $shortcutsEnabled, utilitiesEnabled: $utilitiesEnabled, diff --git a/App/Services/CallHistory.swift b/App/Services/Phone.swift similarity index 97% rename from App/Services/CallHistory.swift rename to App/Services/Phone.swift index cda077da..fbe23a8e 100644 --- a/App/Services/CallHistory.swift +++ b/App/Services/Phone.swift @@ -2,7 +2,7 @@ import AppKit import OSLog import SQLite3 -private let log = Logger.service("callhistory") +private let log = Logger.service("phone") private let callHistoryDatabasePath = "/Users/\(NSUserName())/Library/Application Support/CallHistoryDB/CallHistory.storedata" private let callHistoryDatabaseBookmarkKey: String = "me.mattt.iMCP.callHistoryDatabaseBookmark" @@ -11,11 +11,11 @@ private let defaultLimit = 30 // Apple's Core Data epoch: 2001-01-01 00:00:00 UTC private let coreDataEpoch: TimeInterval = 978_307_200 -final class CallHistoryService: NSObject, Service, NSOpenSavePanelDelegate { - static let shared = CallHistoryService() +final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { + static let shared = PhoneService() func activate() async throws { - log.debug("Starting call history service activation") + log.debug("Starting phone service activation") if canAccessDatabaseAtDefaultPath { log.debug("Successfully activated using default database path") @@ -39,20 +39,20 @@ final class CallHistoryService: NSObject, Service, NSOpenSavePanelDelegate { } storeBookmark(for: selectedURL) - log.debug("Successfully activated call history service") + log.debug("Successfully activated phone service") } var isActivated: Bool { get async { let isActivated = canAccessDatabaseAtDefaultPath || canAccessDatabaseUsingBookmark - log.debug("Call history service activation status: \(isActivated)") + log.debug("Phone service activation status: \(isActivated)") return isActivated } } var tools: [Tool] { Tool( - name: "callhistory_fetch", + name: "phone_calls_fetch", description: "Fetch phone call history from the Mac (synced from iPhone)", inputSchema: .object( properties: [ From fd39e604bc4115b56b255febce55cf12f1c982b2 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 8 Sep 2026 05:48:01 -0700 Subject: [PATCH 2/8] Use green for the Phone service to match the app icon --- App/Controllers/ServerController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 889a6846..9f33527a 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -126,7 +126,7 @@ enum ServiceRegistry { ServiceConfig( name: "Phone", iconName: "phone.fill", - color: .green.mix(with: .blue, by: 0.3), + color: .green, service: PhoneService.shared, binding: phoneEnabled ), From d99a3468463c843173fe7edab2f5bd50dc756f59 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 8 Sep 2026 05:48:01 -0700 Subject: [PATCH 3/8] Add a phone_call tool that dials a number via tel: URL --- App/Services/Phone.swift | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/App/Services/Phone.swift b/App/Services/Phone.swift index fbe23a8e..4809a0fe 100644 --- a/App/Services/Phone.swift +++ b/App/Services/Phone.swift @@ -130,6 +130,54 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { "itemListElement": Value.array(calls.map({ .object($0) })), ] } + + 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." + ) + ], + 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 + } + + let dialString = phoneNumber.filter { $0.isNumber || $0 == "+" || $0 == "*" || $0 == "#" } + guard !dialString.isEmpty, let url = URL(string: "tel:\(dialString)") else { + throw CallError.invalidPhoneNumber(phoneNumber) + } + + log.debug("Requesting phone call to \(dialString)") + guard NSWorkspace.shared.open(url) else { + throw CallError.openFailed + } + + return [ + "@context": "https://schema.org", + "@type": "CommunicateAction", + "actionStatus": "PotentialActionStatus", + "recipient": Value.object([ + "@type": "Person", + "telephone": .string(dialString), + ]), + "description": "The system asked the user to confirm the call.", + ] + } } // MARK: - Database Access @@ -390,6 +438,24 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { } } + 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 From de2b24a870e1b39f07a7c96e3af1fde5d7ae6de5 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 8 Sep 2026 05:52:01 -0700 Subject: [PATCH 4/8] Model the phone_call result as a CommunicateAction type --- App/Services/Phone.swift | 112 +++++++++++++++++++++++++++++++++++---- 1 file changed, 102 insertions(+), 10 deletions(-) diff --git a/App/Services/Phone.swift b/App/Services/Phone.swift index 4809a0fe..53447b9b 100644 --- a/App/Services/Phone.swift +++ b/App/Services/Phone.swift @@ -1,5 +1,6 @@ import AppKit import OSLog +import Ontology import SQLite3 private let log = Logger.service("phone") @@ -167,16 +168,11 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { throw CallError.openFailed } - return [ - "@context": "https://schema.org", - "@type": "CommunicateAction", - "actionStatus": "PotentialActionStatus", - "recipient": Value.object([ - "@type": "Person", - "telephone": .string(dialString), - ]), - "description": "The system asked the user to confirm the call.", - ] + return CommunicateAction( + recipient: Person(telephone: dialString), + status: .potential, + description: "The system asked the user to confirm the call." + ) } } @@ -521,3 +517,99 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { return shouldEnable } } + +// MARK: - + +/// A CommunicateAction model following Schema.org ontology (https://schema.org/CommunicateAction) +private struct CommunicateAction: Hashable, Sendable { + /// Unique identifier for the action + var identifier: String? + + /// Description of the action + var description: String? + + /// Action status values based on Schema.org ActionStatusType + enum Status: String, Codable, Hashable, Sendable { + case active = "ActiveActionStatus" + case completed = "CompletedActionStatus" + case failed = "FailedActionStatus" + case potential = "PotentialActionStatus" + } + + /// Status of the action + var status: Status? + + /// The participant who receives the communication + var recipient: Person? + + init(recipient: Person? = nil, status: Status? = nil, description: String? = nil) { + self.recipient = recipient + self.status = status + self.description = description + } +} + +extension CommunicateAction: Codable { + private enum CodingKeys: String, CodingKey { + case description + case status = "actionStatus" + case recipient + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: JSONLDCodingKey.self) + + // Encode @context if we're at the root level + if encoder.codingPath.isEmpty { + try container.encode("https://schema.org", forKey: .context) + } + + // Encode @type + try container.encode(String(describing: Self.self), forKey: .type) + + // Encode @id + try container.encodeIfPresent(identifier, forKey: .id) + + // Encode properties + try container.encodeIfPresent(description, forKey: .attribute(.description)) + try container.encodeIfPresent(status?.rawValue, forKey: .attribute(.status)) + try container.encodeIfPresent(recipient, forKey: .attribute(.recipient)) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: JSONLDCodingKey.self) + + // Verify type is correct + let describedType = String(describing: Self.self) + let decodedType = try container.decode(String.self, forKey: .type) + guard decodedType == describedType else { + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Expected type to be '\(describedType)', but found \(decodedType)" + ) + } + + // Decode @id + identifier = try container.decodeIfPresent(String.self, forKey: .id) + + // Decode properties + description = try container.decodeIfPresent(String.self, forKey: .attribute(.description)) + if let statusString = try container.decodeIfPresent( + String.self, + forKey: .attribute(.status) + ) { + status = Status(rawValue: statusString) + } + recipient = try container.decodeIfPresent(Person.self, forKey: .attribute(.recipient)) + } +} + +extension Person { + /// Initialize a Person known only by a telephone number + fileprivate init(telephone: String) { + self.init(name: "") + self.givenName = nil + self.telephone = [telephone] + } +} From bf44827337ea4ba864025aa24820b74636742a0c Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 8 Sep 2026 05:57:07 -0700 Subject: [PATCH 5/8] Move call history SQLite access into CallHistoryDatabase and CallRecord types --- App/Services/Phone.swift | 407 ++++++++++++++++++++------------------- 1 file changed, 207 insertions(+), 200 deletions(-) diff --git a/App/Services/Phone.swift b/App/Services/Phone.swift index 53447b9b..b09065d6 100644 --- a/App/Services/Phone.swift +++ b/App/Services/Phone.swift @@ -9,9 +9,6 @@ private let callHistoryDatabasePath = private let callHistoryDatabaseBookmarkKey: String = "me.mattt.iMCP.callHistoryDatabaseBookmark" private let defaultLimit = 30 -// Apple's Core Data epoch: 2001-01-01 00:00:00 UTC -private let coreDataEpoch: TimeInterval = 978_307_200 - final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { static let shared = PhoneService() @@ -91,36 +88,29 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { log.debug("Starting call history fetch with arguments: \(arguments)") try await self.activate() - let participant = arguments["participant"]?.stringValue - let callTypeFilter = arguments["call_type"]?.stringValue - let limit = arguments["limit"]?.intValue ?? defaultLimit - - var startDate: Date? - var endDate: Date? + var request = CallRecord.FetchRequest( + limit: arguments["limit"]?.intValue ?? defaultLimit + ) + request.participant = arguments["participant"]?.stringValue + request.callType = arguments["call_type"]?.stringValue.flatMap { + CallRecord.CallType(rawValue: $0.lowercased()) + } if let startStr = arguments["start"]?.stringValue, let parsedStart = ISO8601DateFormatter.parsedLenientISO8601Date( fromISO8601String: startStr ) { - startDate = parsedStart.date + request.startDate = parsedStart.date } if let endStr = arguments["end"]?.stringValue, let parsedEnd = ISO8601DateFormatter.parsedLenientISO8601Date( fromISO8601String: endStr ) { - endDate = parsedEnd.date + request.endDate = parsedEnd.date } - let databaseURL = try self.resolveDatabaseURL() - let calls = try self.fetchCalls( - from: databaseURL, - participant: participant, - startDate: startDate, - endDate: endDate, - callTypeFilter: callTypeFilter, - limit: limit - ) + let calls = try self.withDatabase { try $0.fetch(request) } log.debug("Successfully fetched \(calls.count) calls") return [ @@ -128,7 +118,7 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { "@type": "ItemList", "name": "Call History", "numberOfItems": .int(calls.count), - "itemListElement": Value.array(calls.map({ .object($0) })), + "itemListElement": Value.array(calls.map(\.value)), ] } @@ -226,183 +216,13 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { return try operation(url) } - // MARK: - SQLite Query - - private func fetchCalls( - from databaseURL: URL, - participant: String?, - startDate: Date?, - endDate: Date?, - callTypeFilter: String?, - limit: Int - ) throws -> [[String: Value]] { - let accessBlock: (URL) throws -> [[String: Value]] = { url in - var db: OpaquePointer? - guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { - let errorMessage = String(cString: sqlite3_errmsg(db)) - sqlite3_close(db) - throw DatabaseAccessError.sqliteError(errorMessage) - } - defer { sqlite3_close(db) } - - // Build query with filters - var conditions: [String] = [] - var params: [Any] = [] - - if let participant = participant { - conditions.append("(c.ZADDRESS LIKE ? OR c.ZNAME LIKE ?)") - params.append("%\(participant)%") - params.append("%\(participant)%") - } - - if let startDate = startDate { - let coreDataTimestamp = startDate.timeIntervalSince1970 - coreDataEpoch - conditions.append("c.ZDATE >= ?") - params.append(coreDataTimestamp) - } - - if let endDate = endDate { - let coreDataTimestamp = endDate.timeIntervalSince1970 - coreDataEpoch - conditions.append("c.ZDATE < ?") - params.append(coreDataTimestamp) - } - - if let callTypeFilter = callTypeFilter { - switch callTypeFilter.lowercased() { - case "incoming": - conditions.append("c.ZORIGINATED = 0 AND c.ZANSWERED = 1") - case "outgoing": - conditions.append("c.ZORIGINATED = 1") - case "missed": - conditions.append("c.ZORIGINATED = 0 AND c.ZANSWERED = 0") - default: - break - } - } - - let whereClause = - conditions.isEmpty ? "" : "WHERE " + conditions.joined(separator: " AND ") - - let query = """ - SELECT - c.Z_PK, - c.ZADDRESS, - c.ZNAME, - c.ZDATE, - c.ZDURATION, - c.ZORIGINATED, - c.ZANSWERED, - c.ZSERVICE_PROVIDER - FROM ZCALLRECORD c - \(whereClause) - ORDER BY c.ZDATE DESC - LIMIT ? - """ - - var stmt: OpaquePointer? - guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { - let errorMessage = String(cString: sqlite3_errmsg(db)) - throw DatabaseAccessError.sqliteError(errorMessage) - } - defer { sqlite3_finalize(stmt) } - - // Bind parameters - var paramIndex: Int32 = 1 - for param in params { - if let stringParam = param as? String { - sqlite3_bind_text( - stmt, - paramIndex, - (stringParam as NSString).utf8String, - -1, - nil - ) - } else if let doubleParam = param as? Double { - sqlite3_bind_double(stmt, paramIndex, doubleParam) - } else if let timeInterval = param as? TimeInterval { - sqlite3_bind_double(stmt, paramIndex, timeInterval) - } - paramIndex += 1 - } - sqlite3_bind_int(stmt, paramIndex, Int32(limit)) - - var calls: [[String: Value]] = [] - while sqlite3_step(stmt) == SQLITE_ROW { - // Columns: 0=Z_PK, 1=ZADDRESS, 2=ZNAME, 3=ZDATE, 4=ZDURATION, - // 5=ZORIGINATED, 6=ZANSWERED, 7=ZSERVICE_PROVIDER - let id = Int(sqlite3_column_int64(stmt, 0)) - - let address: String - if let cStr = sqlite3_column_text(stmt, 1) { - address = String(cString: cStr) - } else { - address = "Unknown" - } - - let name: String? - if let cStr = sqlite3_column_text(stmt, 2) { - let n = String(cString: cStr) - name = n.isEmpty ? nil : n - } else { - name = nil - } - - let coreDataDate = sqlite3_column_double(stmt, 3) - let unixTimestamp = coreDataDate + coreDataEpoch - let date = Date(timeIntervalSince1970: unixTimestamp) - - let duration = sqlite3_column_double(stmt, 4) - let originated = sqlite3_column_int(stmt, 5) - let answered = sqlite3_column_int(stmt, 6) - - let callType: String - if originated == 1 { - callType = "outgoing" - } else if answered == 1 { - callType = "incoming" - } else { - callType = "missed" - } - - let serviceProvider: String - if let cStr = sqlite3_column_text(stmt, 7) { - serviceProvider = String(cString: cStr) - } else { - serviceProvider = "unknown" - } - - let durationMinutes = Int(duration) / 60 - let durationSeconds = Int(duration) % 60 - let durationStr = - durationMinutes > 0 - ? "\(durationMinutes)m \(durationSeconds)s" : "\(durationSeconds)s" - - var entry: [String: Value] = [ - "@id": .string(String(id)), - "phoneNumber": .string(address), - "callType": .string(callType), - "date": .string(date.formatted(.iso8601)), - "duration": .string(durationStr), - "durationSeconds": .double(duration), - "serviceProvider": .string(serviceProvider), - ] - if let name = name { - entry["name"] = .string(name) - } - - calls.append(entry) - } - - return calls - } - - // Use security-scoped access if needed + private func withDatabase(_ operation: (CallHistoryDatabase) throws -> T) throws -> T { + let url = try resolveDatabaseURL() if canAccessDatabaseAtDefaultPath { - return try accessBlock(databaseURL) - } else { - return try withSecurityScopedAccess(databaseURL) { url in - try accessBlock(url) - } + return try operation(CallHistoryDatabase(path: url.path)) + } + return try withSecurityScopedAccess(url) { url in + try operation(CallHistoryDatabase(path: url.path)) } } @@ -414,7 +234,6 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { case userDeclinedAccess case invalidFileSelected case fileNotReadable - case sqliteError(String) var errorDescription: String? { switch self { @@ -428,8 +247,6 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { return "Call history database access denied or invalid file selected" case .fileNotReadable: return "Selected database file is not readable" - case .sqliteError(let message): - return "SQLite error: \(message)" } } } @@ -520,6 +337,196 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { // MARK: - +/// A read-only connection to the Call History database. +private final class CallHistoryDatabase { + private let connection: OpaquePointer + + init(path: String) throws { + var connection: OpaquePointer? + guard sqlite3_open_v2(path, &connection, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + defer { sqlite3_close(connection) } + throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) + } + self.connection = connection! + } + + deinit { + sqlite3_close(connection) + } + + func fetch(_ request: CallRecord.FetchRequest) throws -> [CallRecord] { + let (sql, bindings) = request.statement + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(connection, sql, -1, &statement, nil) == SQLITE_OK else { + throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) + } + defer { sqlite3_finalize(statement) } + + for (offset, binding) in bindings.enumerated() { + binding.bind(to: statement, at: Int32(offset + 1)) + } + + var records: [CallRecord] = [] + while sqlite3_step(statement) == SQLITE_ROW { + records.append(CallRecord(statement)) + } + 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(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 From 6be9ed4519a7674120e04d493b69e9b35081b2d1 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 8 Sep 2026 06:12:51 -0700 Subject: [PATCH 6/8] Validate phone tool arguments and request database access only on fetch --- App/Services/Phone.swift | 144 +++++++++++++++++++++++---------------- 1 file changed, 86 insertions(+), 58 deletions(-) diff --git a/App/Services/Phone.swift b/App/Services/Phone.swift index b09065d6..3a7e4ba2 100644 --- a/App/Services/Phone.swift +++ b/App/Services/Phone.swift @@ -12,42 +12,6 @@ private let defaultLimit = 30 final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { static let shared = PhoneService() - func activate() async throws { - log.debug("Starting phone service activation") - - if canAccessDatabaseAtDefaultPath { - log.debug("Successfully activated using default database path") - return - } - - if canAccessDatabaseUsingBookmark { - log.debug("Successfully activated using stored bookmark") - return - } - - log.debug("Opening file picker for manual database selection") - guard try await showDatabaseAccessAlert() else { - throw DatabaseAccessError.userDeclinedAccess - } - - let selectedURL = try await showFilePicker() - - guard FileManager.default.isReadableFile(atPath: selectedURL.path) else { - throw DatabaseAccessError.fileNotReadable - } - - storeBookmark(for: selectedURL) - log.debug("Successfully activated phone service") - } - - var isActivated: Bool { - get async { - let isActivated = canAccessDatabaseAtDefaultPath || canAccessDatabaseUsingBookmark - log.debug("Phone service activation status: \(isActivated)") - return isActivated - } - } - var tools: [Tool] { Tool( name: "phone_calls_fetch", @@ -74,7 +38,8 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { ), "limit": .integer( description: "Maximum calls to return", - default: .int(defaultLimit) + default: .int(defaultLimit), + minimum: 1 ), ], additionalProperties: false @@ -86,30 +51,43 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { ) ) { arguments in log.debug("Starting call history fetch with arguments: \(arguments)") - try await self.activate() - var request = CallRecord.FetchRequest( - limit: arguments["limit"]?.intValue ?? defaultLimit - ) + let limit = arguments["limit"]?.intValue ?? defaultLimit + guard limit >= 1 else { + throw ArgumentError.invalid("limit must be a positive integer") + } + var request = CallRecord.FetchRequest(limit: limit) request.participant = arguments["participant"]?.stringValue - request.callType = arguments["call_type"]?.stringValue.flatMap { - CallRecord.CallType(rawValue: $0.lowercased()) + if let callType = arguments["call_type"]?.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 startStr = arguments["start"]?.stringValue, - let parsedStart = ISO8601DateFormatter.parsedLenientISO8601Date( - fromISO8601String: startStr - ) - { - request.startDate = parsedStart.date + if let start = arguments["start"]?.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 endStr = arguments["end"]?.stringValue, - let parsedEnd = ISO8601DateFormatter.parsedLenientISO8601Date( - fromISO8601String: endStr - ) - { - request.endDate = parsedEnd.date + if let end = arguments["end"]?.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") @@ -148,8 +126,20 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { throw CallError.missingPhoneNumber } - let dialString = phoneNumber.filter { $0.isNumber || $0 == "+" || $0 == "*" || $0 == "#" } - guard !dialString.isEmpty, let url = URL(string: "tel:\(dialString)") else { + // Accept only dialable characters plus common formatting; reject anything else + // rather than silently dialing a different number (e.g. "help911" -> "911"). + 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), + let url = URL(string: "tel:\(dialString)") + else { throw CallError.invalidPhoneNumber(phoneNumber) } @@ -168,6 +158,33 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { // MARK: - Database Access + /// Ensures the call history database is readable, asking the user to grant access if needed. + private func requestDatabaseAccess() async throws { + if canAccessDatabaseAtDefaultPath { + log.debug("Using call history database at default path") + return + } + + if canAccessDatabaseUsingBookmark { + log.debug("Using call history database from stored bookmark") + return + } + + log.debug("Opening file picker for manual database selection") + guard try await showDatabaseAccessAlert() else { + throw DatabaseAccessError.userDeclinedAccess + } + + let selectedURL = try await showFilePicker() + + guard FileManager.default.isReadableFile(atPath: selectedURL.path) else { + throw DatabaseAccessError.fileNotReadable + } + + storeBookmark(for: selectedURL) + log.debug("Granted access to call history database") + } + private var canAccessDatabaseAtDefaultPath: Bool { return FileManager.default.isReadableFile(atPath: callHistoryDatabasePath) } @@ -251,6 +268,17 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { } } + 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) @@ -396,7 +424,7 @@ private enum SQLiteValue { case .double(let double): sqlite3_bind_double(statement, index, double) case .int(let int): - sqlite3_bind_int(statement, index, Int32(int)) + sqlite3_bind_int(statement, index, Int32(clamping: int)) } } } From 6175b1c2790c14418c49199a3d28a7f5ab8522e1 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 8 Sep 2026 06:32:55 -0700 Subject: [PATCH 7/8] Incorporate review feedback --- App/Services/Phone.swift | 64 ++++++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/App/Services/Phone.swift b/App/Services/Phone.swift index 3a7e4ba2..1256243b 100644 --- a/App/Services/Phone.swift +++ b/App/Services/Phone.swift @@ -52,13 +52,13 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { ) { arguments in log.debug("Starting call history fetch with arguments: \(arguments)") - let limit = arguments["limit"]?.intValue ?? defaultLimit + 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 = arguments["participant"]?.stringValue - if let callType = arguments["call_type"]?.stringValue { + 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" @@ -66,7 +66,7 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { } request.callType = type } - if let start = arguments["start"]?.stringValue { + if let start = try self.argument("start", in: arguments, as: \.stringValue) { guard let parsed = ISO8601DateFormatter.parsedLenientISO8601Date( fromISO8601String: start @@ -76,7 +76,7 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { } request.startDate = parsed.date } - if let end = arguments["end"]?.stringValue { + if let end = try self.argument("end", in: arguments, as: \.stringValue) { guard let parsed = ISO8601DateFormatter.parsedLenientISO8601Date( fromISO8601String: end @@ -137,9 +137,15 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { } let dialString = phoneNumber.filter { !formatting.contains($0) } - guard dialString.contains(where: digits.contains), - let url = URL(string: "tel:\(dialString)") - else { + guard dialString.contains(where: digits.contains) else { + throw CallError.invalidPhoneNumber(phoneNumber) + } + + // Build through URLComponents so "#" is percent-encoded rather than parsed as a fragment + var components = URLComponents() + components.scheme = "tel" + components.path = dialString + guard let url = components.url else { throw CallError.invalidPhoneNumber(phoneNumber) } @@ -181,10 +187,23 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { throw DatabaseAccessError.fileNotReadable } - storeBookmark(for: selectedURL) + 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) } @@ -340,18 +359,14 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { return url } - private func storeBookmark(for url: URL) { - do { - let bookmarkData = try url.bookmarkData( - options: .securityScopeAllowOnlyReadAccess, - includingResourceValuesForKeys: nil, - relativeTo: nil - ) - UserDefaults.standard.set(bookmarkData, forKey: callHistoryDatabaseBookmarkKey) - log.debug("Successfully created and stored bookmark") - } catch { - log.error("Failed to create bookmark: \(error.localizedDescription)") - } + private func storeBookmark(for url: URL) throws { + let bookmarkData = try url.bookmarkData( + options: .securityScopeAllowOnlyReadAccess, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + UserDefaults.standard.set(bookmarkData, forKey: callHistoryDatabaseBookmarkKey) + log.debug("Successfully created and stored bookmark") } func panel(_ sender: Any, shouldEnable url: URL) -> Bool { @@ -396,8 +411,13 @@ private final class CallHistoryDatabase { } var records: [CallRecord] = [] - while sqlite3_step(statement) == SQLITE_ROW { + 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 } From 836d6188173ede4473e3ffea0d80af184196d9e4 Mon Sep 17 00:00:00 2001 From: Mattt Zmuda Date: Tue, 8 Sep 2026 06:49:48 -0700 Subject: [PATCH 8/8] Reject * and # in phone_call numbers since the Phone app will not dial them --- App/Services/Phone.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/App/Services/Phone.swift b/App/Services/Phone.swift index 1256243b..ea035780 100644 --- a/App/Services/Phone.swift +++ b/App/Services/Phone.swift @@ -107,7 +107,8 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { inputSchema: .object( properties: [ "phoneNumber": .string( - description: "Phone number to call. E.164 format is recommended." + description: + "Phone number to call. E.164 format is recommended. Digits, a leading +, and common formatting only; * and # are not supported." ) ], required: ["phoneNumber"], @@ -126,10 +127,11 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { throw CallError.missingPhoneNumber } - // Accept only dialable characters plus common formatting; reject anything else + // 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 dialable = digits.union("+") let formatting = Set(" -().") guard phoneNumber.allSatisfy({ dialable.contains($0) || formatting.contains($0) }) else { @@ -141,7 +143,6 @@ final class PhoneService: NSObject, Service, NSOpenSavePanelDelegate { throw CallError.invalidPhoneNumber(phoneNumber) } - // Build through URLComponents so "#" is percent-encoded rather than parsed as a fragment var components = URLComponents() components.scheme = "tel" components.path = dialString