diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 36282a43..78471e1b 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -154,7 +154,7 @@ enum ServiceRegistry { ServiceConfig( name: "Voicemail", iconName: "recordingtape", - color: .teal, + color: .green, service: VoicemailService.shared, binding: voicemailEnabled ) @@ -522,7 +522,7 @@ actor MCPConnectionManager { version: Bundle.main.shortVersionString ?? "unknown", instructions: """ Use iMCP for apps and data on the user's Mac: \ - Calendar, Contacts, Location, Maps, Messages, Phone, Reminders, Shortcuts, Weather, \ + Calendar, Contacts, Location, Maps, Messages, Phone, Reminders, Shortcuts, Voicemail, Weather, \ and the camera, microphone, and screen. \ Only the services that the user turns on in iMCP have tools. """, diff --git a/App/Services/Voicemail.swift b/App/Services/Voicemail.swift index 51911dec..12c7f307 100644 --- a/App/Services/Voicemail.swift +++ b/App/Services/Voicemail.swift @@ -48,12 +48,12 @@ final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { Tool( name: "voicemails_fetch", description: - "Fetch voicemails from the Mac (synced from iPhone, including FaceTime audio messages)", + "Fetch voicemails from the Mac (synced from iPhone, including FaceTime messages), newest first, from the inbox unless another mailbox is requested", inputSchema: .object( properties: [ "sender": .string( description: - "Phone number or address to filter by (partial match supported)" + "Phone number or address to filter by (partial match supported; phone numbers match by digits, ignoring formatting)" ), "start": .string( description: @@ -69,6 +69,12 @@ final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { description: "Filter by message type, or omit for all", enum: ["carrierVoicemail", "faceTimeAudio", "faceTimeVideo"] ), + "mailbox": .string( + description: + "Voicemail mailbox to read: the inbox, blocked or deleted messages, or all of them", + default: .string("inbox"), + enum: ["inbox", "blocked", "deleted", "all"] + ), "limit": .integer( description: "Maximum voicemails to return", default: .int(defaultLimit), @@ -97,6 +103,15 @@ final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { } request.kind = kind } + if let name = try self.argument("mailbox", in: arguments, as: \.stringValue) { + if name == "all" { + request.mailbox = nil + } else if let mailbox = VoicemailMessage.Mailbox(name: name) { + request.mailbox = mailbox + } else { + throw ArgumentError.invalid("mailbox must be one of: inbox, blocked, deleted, all") + } + } if let start = try self.argument("start", in: arguments, as: \.stringValue) { guard let parsed = ISO8601DateFormatter.parsedLenientISO8601Date( @@ -157,14 +172,14 @@ final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { let id = try self.messageIdentifier(from: arguments) try await self.requestDataStoreAccess() - let transcript = try self.withStore { store -> VoicemailTranscript? in - guard try store.message(id: id) != nil else { + let transcript = try self.withStore { store -> VoicemailTranscript in + guard let message = try store.message(id: id) else { throw TranscriptError.notFound(id) } - return try store.transcript(id: id) - } - guard let transcript else { - throw TranscriptError.notTranscribed(id) + guard let transcript = try store.transcript(id: id) else { + throw TranscriptError.notTranscribed(id, hasAudio: message.hasAudio) + } + return transcript } var object: [String: Value] = [ @@ -207,7 +222,10 @@ final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { return nil } guard let mimeType = store.audioMimeType(for: message) else { - throw AudioError.unsupportedFileType(id) + if message.kind == .faceTimeVideo { + throw AudioError.videoMessage(id) + } + throw AudioError.unsupportedFormat(id, message.fileType) } guard let data = try store.audioData(for: message) else { throw AudioError.audioMissing(id) @@ -271,12 +289,18 @@ final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { return result } - /// The numeric record identifier passed as the `id` argument. + /// The numeric record identifier passed as the `id` argument, + /// as the string that voicemails_fetch returns or as an integer. private func messageIdentifier(from arguments: [String: Value]) throws -> Int { - guard let identifier = arguments["id"]?.stringValue, let id = Int(identifier) else { - throw ArgumentError.invalid("id must be the identifier of a voicemail") + if let identifier = arguments["id"]?.stringValue, + let id = Int(identifier.trimmingCharacters(in: .whitespaces)) + { + return id } - return id + if let id = arguments["id"]?.intValue { + return id + } + throw ArgumentError.invalid("id must be the identifier of a voicemail") } private var canAccessStoreAtDefaultPath: Bool { @@ -379,31 +403,38 @@ final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { private enum TranscriptError: LocalizedError { case notFound(Int) - case notTranscribed(Int) + case notTranscribed(Int, hasAudio: Bool) var errorDescription: String? { switch self { case .notFound(let id): return "No voicemail found with identifier \(id)" - case .notTranscribed(let id): - return - "The voicemail with identifier \(id) has no transcript; its audio is available from voicemail_audio_fetch" + case .notTranscribed(let id, let hasAudio): + if hasAudio { + return + "The voicemail with identifier \(id) has no transcript; its audio is available from voicemail_audio_fetch" + } + return "The voicemail with identifier \(id) has no transcript" } } } private enum AudioError: LocalizedError { case notFound(Int) - case unsupportedFileType(Int) + case videoMessage(Int) + case unsupportedFormat(Int, String?) case audioMissing(Int) var errorDescription: String? { switch self { case .notFound(let id): return "No voicemail found with identifier \(id)" - case .unsupportedFileType(let id): + case .videoMessage(let id): return "The message with identifier \(id) is a video message, which this tool does not return; only carrier voicemails and FaceTime audio messages do" + case .unsupportedFormat(let id, let fileType): + return + "The audio of the voicemail with identifier \(id) is in a format this tool does not return (\(fileType ?? "unknown"))" case .audioMissing(let id): return "The audio file of the voicemail with identifier \(id) is missing from the store" @@ -484,22 +515,21 @@ final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { extension VoicemailMessage { /// 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] = [ + let totalSeconds = Int(duration.rounded()) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return [ "@id": .string(String(id)), "sender": .string(sender ?? "Unknown"), "date": .string(date.formatted(.iso8601)), "duration": .string(minutes > 0 ? "\(minutes)m \(seconds)s" : "\(seconds)s"), - "durationSeconds": .double(duration), + "durationSeconds": .double((duration * 10).rounded() / 10), "isRead": .bool(isRead), "transcribed": .bool(hasTranscript), "hasAudio": .bool(hasAudio), + "type": .string(kind?.rawValue ?? "unknown"), + "mailbox": .string(mailbox?.name ?? "unknown"), ] - if let kind { - object["type"] = .string(kind.rawValue) - } - return .object(object) } } diff --git a/App/Services/VoicemailStore.swift b/App/Services/VoicemailStore.swift index b19b7fca..f72b21e6 100644 --- a/App/Services/VoicemailStore.swift +++ b/App/Services/VoicemailStore.swift @@ -7,7 +7,7 @@ import SQLite3 /// daemon into a Core Data SQLite store inside the FaceTime group container. Message /// audio lives in an `Assets` tree next to the database, addressed by each record's /// UUID. The schema is private to the daemon; this store reads only the columns it -/// needs and never writes. +/// needs, tolerates the absence of the ones it can do without, and never writes. struct VoicemailStore { static let databaseFileName = "FaceTimeMessageStore-local.sqlitedb" @@ -21,20 +21,14 @@ struct VoicemailStore { func fetch(_ request: VoicemailMessage.FetchRequest) throws -> [VoicemailMessage] { var messages = try read { try $0.fetch(request) } for index in messages.indices { - resolveClassification(of: &messages[index]) - } - if let kind = request.kind, kind.requiresAssetInspection { - // These kinds could not take a SQL LIMIT: it would cut rows before - // their assets reveal which `.MOV` kind they are. - messages.removeAll { $0.kind != kind } - messages = Array(messages.prefix(request.limit)) + resolveAsset(of: &messages[index]) } return messages } func message(id: Int) throws -> VoicemailMessage? { guard var message = try read({ try $0.message(id: id) }) else { return nil } - resolveClassification(of: &message) + resolveAsset(of: &message) return message } @@ -60,37 +54,33 @@ struct VoicemailStore { } /// The MIME type of the message's audio, when it is a format clients can play. - /// Audio-only `.MOV` carrier voicemails carry AAC audio in a QuickTime - /// container, which `audio/mp4` players handle. + /// + /// The daemon writes the extension in either case (`MOV` and `mov` both occur). + /// Audio-only `.mov` messages carry AAC audio in a QuickTime container, + /// which `audio/mp4` players handle; video messages have no audio-only form. func audioMimeType(for message: VoicemailMessage) -> String? { - switch message.fileType { + guard message.kind != .faceTimeVideo else { return nil } + switch message.fileType?.lowercased() { case "amr": return "audio/amr" - case "m4a": return "audio/mp4" - case "MOV": return message.kind == .carrierVoicemail ? "audio/mp4" : nil + case "m4a", "mov": return "audio/mp4" default: return nil } } - /// Whether the message's audio is available: an existing file in a playable format. - private func hasPlayableAudio(_ message: VoicemailMessage) -> Bool { - audioMimeType(for: message) != nil && audioURL(for: message) != nil - } + /// Sets whether the message's audio is playable, and fills in a missing duration + /// from the asset: the store records a duration of zero for some messages. + private func resolveAsset(of message: inout VoicemailMessage) { + let url = audioURL(for: message) + message.hasAudio = url != nil && audioMimeType(for: message) != nil - /// Sets the message's kind from its asset, then whether its audio is playable. - /// - /// Since the iCloud voicemail era the daemon stores carrier voicemails as - /// audio-only `.MOV` files, the same `ZFILETYPE` FaceTime video messages use; - /// a video track is the only thing that tells them apart. Rows whose asset - /// is missing or unreadable keep the conservative `faceTimeVideo` default. - private func resolveClassification(of message: inout VoicemailMessage) { - if message.fileType == "MOV", - let url = audioURL(for: message), + if message.duration <= 0, + let url, + ["m4a", "mov"].contains(message.fileType?.lowercased()), let data = try? Data(contentsOf: url, options: .mappedIfSafe), - QuickTimeMovie.isAudioOnlyMovie(data) + let duration = QuickTimeMovie.duration(of: data) { - message.kind = .carrierVoicemail + message.duration = duration } - message.hasAudio = hasPlayableAudio(message) } /// Reads the store live, honoring its write-ahead log. @@ -107,37 +97,63 @@ struct VoicemailStore { // MARK: - -/// A row of the `ZSTOREDMESSAGE` table: one voicemail or FaceTime audio message. +/// A row of the `ZSTOREDMESSAGE` table: one voicemail or FaceTime message. struct VoicemailMessage { + /// Where the message came from and what it holds. + /// + /// The provider separates the two sources: carrier voicemails come from + /// `com.apple.coretelephony` whatever their file format (`amr`, `m4a`, or `MOV`), + /// and FaceTime messages from the FaceTime call provider. Among FaceTime + /// messages, a message type of 2 marks video. enum Kind: String { case carrierVoicemail case faceTimeAudio case faceTimeVideo - /// The SQL predicate that selects candidate rows of this kind. Both - /// `.MOV` kinds match the same `ZFILETYPE`; the store inspects their - /// assets to separate audio-only carrier voicemails from video messages. + /// The SQL predicate that selects rows of this kind. fileprivate var predicate: String { switch self { - case .carrierVoicemail: return "ZFILETYPE IN ('amr', 'MOV')" - case .faceTimeAudio: return "ZFILETYPE = 'm4a'" - case .faceTimeVideo: return "ZFILETYPE = 'MOV'" + case .carrierVoicemail: + return "ZPROVIDER = '\(carrierProvider)'" + case .faceTimeAudio: + return "ZPROVIDER LIKE '%\(faceTimeProviderSuffix)' AND ZMESSAGETYPE IS NOT 2" + case .faceTimeVideo: + return "ZPROVIDER LIKE '%\(faceTimeProviderSuffix)' AND ZMESSAGETYPE = 2" } } - /// Whether selecting rows of this kind requires inspecting their assets. - fileprivate var requiresAssetInspection: Bool { - self == .carrierVoicemail || self == .faceTimeVideo + /// The kind of a row, or nil when its provider is missing or unfamiliar. + fileprivate init?(provider: String?, messageType: Int?) { + guard let provider else { return nil } + if provider == carrierProvider { + self = .carrierVoicemail + } else if provider.lowercased().hasSuffix(faceTimeProviderSuffix.lowercased()) { + self = messageType == 2 ? .faceTimeVideo : .faceTimeAudio + } else { + return nil + } } + } - /// The kind matching a `ZFILETYPE` value, for the kinds the store defines. - fileprivate init?(fileType: String?) { - switch fileType { - case "amr": self = .carrierVoicemail - case "m4a": self = .faceTimeAudio - case "MOV": self = .faceTimeVideo - default: return nil + /// The Phone app's voicemail mailbox that holds the message. + enum Mailbox: Int, CaseIterable { + case inbox = 0 + case blocked = 1 + case deleted = 2 + + var name: String { + switch self { + case .inbox: return "inbox" + case .blocked: return "blocked" + case .deleted: return "deleted" + } + } + + init?(name: String) { + guard let mailbox = Self.allCases.first(where: { $0.name == name }) else { + return nil } + self = mailbox } } @@ -145,18 +161,19 @@ struct VoicemailMessage { let sender: String? /// When the message was left, as seconds since the Core Data reference date (2001-01-01). let date: Date - let duration: TimeInterval + /// Length in seconds; the store filling in a missing value from the asset. + var duration: TimeInterval let isRead: Bool let fileType: String? let recordUUID: Data let hasTranscript: Bool + /// The message's kind, or nil when the store does not say. + let kind: Kind? + /// The message's mailbox, or nil when the store does not say. + let mailbox: Mailbox? /// Whether the message's audio exists in the `Assets` tree in a format clients can play. var hasAudio = false - /// The message's kind, refined by the store for `.MOV` rows; see - /// `resolveClassification(of:)`. - var kind: Kind? - /// Path of the audio file in the `Assets` tree, relative to the store's directory. var audioFileName: String? { guard let fileType, !fileType.isEmpty, let uuid = recordUUID.uuidString else { return nil } @@ -171,6 +188,11 @@ struct VoicemailMessage { return string.isEmpty ? nil : string } + func integer(_ column: Int32) -> Int? { + guard sqlite3_column_type(statement, column) != SQLITE_NULL else { return nil } + return Int(sqlite3_column_int64(statement, column)) + } + func blob(_ column: Int32) -> Data { guard let bytes = sqlite3_column_blob(statement, column) else { return Data() } let byteCount = Int(sqlite3_column_bytes(statement, column)) @@ -186,29 +208,41 @@ struct VoicemailMessage { fileType = text(5) recordUUID = blob(6) hasTranscript = sqlite3_column_int(statement, 7) == 1 - kind = Kind(fileType: fileType) + kind = Kind(provider: text(8), messageType: integer(9)) + mailbox = integer(10).flatMap(Mailbox.init(rawValue:)) } } extension VoicemailMessage { struct FetchRequest { /// Sender (phone number or address) to match (partial, case-insensitive). + /// A phone number also matches by its digits alone, whatever its formatting. var sender: String? /// Start of the date range (inclusive). var startDate: Date? /// End of the date range (exclusive). var endDate: Date? var kind: Kind? + /// The mailbox to read, or nil for all of them. + var mailbox: Mailbox? = .inbox var limit: Int - /// The SQL and its bound values, in placeholder order. - fileprivate var statement: (sql: String, bindings: [SQLiteValue]) { + /// The SQL and its bound values, in placeholder order, + /// for a store with the given columns. + fileprivate func statement( + columns: Set + ) throws -> (sql: String, bindings: [SQLiteValue]) { var conditions = ["ZDATEDELETED IS NULL"] var bindings: [SQLiteValue] = [] if let sender { - conditions.append("ZFROM LIKE ? ESCAPE '\\'") + var matches = ["ZFROM LIKE ? ESCAPE '\\'"] bindings.append(.text("%" + escapingLikeWildcards(sender) + "%")) + if let digits = phoneNumberDigits(sender) { + matches.append("\(digitsOnly("ZFROM")) LIKE ?") + bindings.append(.text("%" + digits + "%")) + } + conditions.append("(" + matches.joined(separator: " OR ") + ")") } if let startDate { conditions.append("ZDATECREATED >= ?") @@ -219,22 +253,25 @@ extension VoicemailMessage { bindings.append(.double(endDate.timeIntervalSinceReferenceDate)) } if let kind { + guard columns.contains("ZPROVIDER"), columns.contains("ZMESSAGETYPE") else { + throw VoicemailStoreError.unsupportedFilter("type") + } conditions.append(kind.predicate) } + // A store without mailboxes keeps every message in one place. + if let mailbox, columns.contains("ZMAILBOXTYPE") { + conditions.append("ZMAILBOXTYPE = ?") + bindings.append(.int(mailbox.rawValue)) + } - var sql = """ - SELECT \(voicemailColumns) + bindings.append(.int(limit)) + let sql = """ + SELECT \(selectedColumns(in: columns)) FROM ZSTOREDMESSAGE WHERE \(conditions.joined(separator: " AND ")) ORDER BY ZDATECREATED DESC + LIMIT ? """ - // A SQL LIMIT would cut rows before their assets can reveal which - // `.MOV` kind they are, so kinds needing asset inspection fetch all - // candidates and `fetch` applies the limit after classifying. - if kind?.requiresAssetInspection != true { - bindings.append(.int(limit)) - sql += "\nLIMIT ?" - } return (sql, bindings) } @@ -254,6 +291,35 @@ extension VoicemailMessage { } return escaped } + + /// The digits of a sender filter that reads as a phone number: + /// at least three digits, and nothing but digits and phone formatting. + private func phoneNumberDigits(_ text: String) -> String? { + let digits = text.filter { $0.isASCII && $0.isNumber } + let formatting: Set = ["+", "-", " ", "(", ")", "."] + guard digits.count >= 3, + text.allSatisfy({ ($0.isASCII && $0.isNumber) || formatting.contains($0) }) + else { return nil } + return digits + } + + /// A SQL expression for a column's value with phone formatting removed. + private func digitsOnly(_ column: String) -> String { + ["+", "-", " ", "(", ")", "."].reduce(column) { expression, character in + "REPLACE(\(expression), '\(character)', '')" + } + } + } +} + +enum VoicemailStoreError: LocalizedError { + case unsupportedFilter(String) + + var errorDescription: String? { + switch self { + case .unsupportedFilter(let name): + return "This Mac's voicemail store does not record the information the \(name) filter needs" + } } } @@ -413,41 +479,48 @@ fileprivate extension Data { // MARK: - -/// Reads track information from QuickTime movie data. +/// Reads the movie header of QuickTime and MPEG-4 files. /// -/// The daemon writes audio-only carrier voicemails and FaceTime video messages -/// as `.MOV` files alike; their tracks are what separate the two. +/// The daemon writes `.mov` and `.m4a` messages; both use the same atom structure. enum QuickTimeMovie { - /// Whether the data is a QuickTime movie whose tracks are audio only: at - /// least one sound track, and no video track. - /// - /// Data that does not parse as a movie does not qualify, so callers can - /// keep a conservative default for it. - static func isAudioOnlyMovie(_ data: Data) -> Bool { - var hasSoundTrack = false + /// The movie's duration in seconds, from its `mvhd` atom, + /// or nil when the data does not parse as a movie with a duration. + static func duration(of data: Data) -> TimeInterval? { for movie in childPayloads(ofType: "moov", in: data, within: 0 ..< data.count) { - for track in childPayloads(ofType: "trak", in: data, within: movie) { - for media in childPayloads(ofType: "mdia", in: data, within: track) { - for handler in childPayloads(ofType: "hdlr", in: data, within: media) { - switch handlerSubtype(of: handler, in: data) { - case "vide": return false - case "soun": hasSoundTrack = true - default: break - } - } - } + for header in childPayloads(ofType: "mvhd", in: data, within: movie) { + // Version 0 stores 32-bit times; version 1 stores 64-bit + // creation, modification, and duration values. + guard header.count >= 1 else { continue } + let isVersion1 = data[header.lowerBound] == 1 + let timescaleOffset = header.lowerBound + (isVersion1 ? 20 : 12) + let durationLength = isVersion1 ? 8 : 4 + guard + let timescale = integer(in: data, at: timescaleOffset, length: 4, within: header), + let duration = integer( + in: data, + at: timescaleOffset + 4, + length: durationLength, + within: header + ), + timescale > 0 + else { continue } + return TimeInterval(duration) / TimeInterval(timescale) } } - return hasSoundTrack + return nil } - /// The handler subtype of an `hdlr` payload — `vide`, `soun`, and so on. - private static func handlerSubtype(of handler: Range, in data: Data) -> String? { - // The subtype sits 8 bytes into the `hdlr` payload, after the - // version/flags and the component type. - let subtype = handler.lowerBound + 8 - guard subtype + 4 <= handler.upperBound else { return nil } - return String(data: data.subdata(in: subtype ..< (subtype + 4)), encoding: .ascii) + /// A big-endian unsigned integer, when it lies within the bounds. + private static func integer( + in data: Data, + at offset: Int, + length: Int, + within bounds: Range + ) -> UInt64? { + guard offset >= bounds.lowerBound, length <= bounds.upperBound - offset else { + return nil + } + return data[offset ..< (offset + length)].reduce(UInt64(0)) { $0 << 8 | UInt64($1) } } /// The payload ranges of the immediate child atoms of the given type, @@ -487,14 +560,51 @@ enum QuickTimeMovie { } } -/// The columns of `ZSTOREDMESSAGE` the store reads, in selection order. -private let voicemailColumns = - "Z_PK, ZFROM, ZDATECREATED, ZDURATION, ZISREAD, ZFILETYPE, ZRECORDUUID, ZTRANSCRIPT IS NOT NULL" +/// The provider of carrier voicemails. +private let carrierProvider = "com.apple.coretelephony" + +/// The end of the FaceTime call provider's identifier, +/// `com.apple.telephonyutilities.callservicesd.FaceTimeProvider`. +private let faceTimeProviderSuffix = ".FaceTimeProvider" + +/// Columns the store needs, in selection order. +private let requiredColumns = [ + "Z_PK", "ZFROM", "ZDATECREATED", "ZDURATION", "ZISREAD", "ZFILETYPE", "ZRECORDUUID", + "ZTRANSCRIPT IS NOT NULL", +] + +/// Columns the store reads when present, in selection order after the required ones. +/// Without them, kinds and mailboxes read as unknown. +private let optionalColumns = ["ZPROVIDER", "ZMESSAGETYPE", "ZMAILBOXTYPE"] + +/// The select list for a store with the given columns, +/// with NULL in place of each optional column it lacks. +private func selectedColumns(in columns: Set) -> String { + let optional = optionalColumns.map { columns.contains($0) ? $0 : "NULL" } + return (requiredColumns + optional).joined(separator: ", ") +} /// A read-only connection to the voicemail store. private final class VoicemailStoreDatabase { private let connection: OpaquePointer + /// The names of the columns of `ZSTOREDMESSAGE`. + private lazy var columns: Set = { + var statement: OpaquePointer? + let sql = "SELECT name FROM pragma_table_info('ZSTOREDMESSAGE')" + guard sqlite3_prepare_v2(connection, sql, -1, &statement, nil) == SQLITE_OK else { + return [] + } + defer { sqlite3_finalize(statement) } + var names: Set = [] + while sqlite3_step(statement) == SQLITE_ROW { + if let name = sqlite3_column_text(statement, 0) { + names.insert(String(cString: name)) + } + } + return names + }() + /// 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. @@ -526,7 +636,7 @@ private final class VoicemailStoreDatabase { } func fetch(_ request: VoicemailMessage.FetchRequest) throws -> [VoicemailMessage] { - let (sql, bindings) = request.statement + let (sql, bindings) = try request.statement(columns: columns) var statement: OpaquePointer? guard sqlite3_prepare_v2(connection, sql, -1, &statement, nil) == SQLITE_OK else { @@ -552,7 +662,11 @@ private final class VoicemailStoreDatabase { func message(id: Int) throws -> VoicemailMessage? { var statement: OpaquePointer? - let sql = "SELECT \(voicemailColumns) FROM ZSTOREDMESSAGE WHERE Z_PK = ? AND ZDATEDELETED IS NULL" + let sql = """ + SELECT \(selectedColumns(in: columns)) + FROM ZSTOREDMESSAGE + WHERE Z_PK = ? AND ZDATEDELETED IS NULL + """ guard sqlite3_prepare_v2(connection, sql, -1, &statement, nil) == SQLITE_OK else { throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) } diff --git a/CLITests/VoicemailStoreTests.swift b/CLITests/VoicemailStoreTests.swift deleted file mode 100644 index ea239f61..00000000 --- a/CLITests/VoicemailStoreTests.swift +++ /dev/null @@ -1,518 +0,0 @@ -import Foundation -import SQLite3 -import XCTest - -/// Tests for `VoicemailStore` against a synthesized store: -/// a fresh SQLite database and `Assets` tree in a temporary folder. -/// No row is ever copied from a real voicemail store. -final class VoicemailStoreTests: XCTestCase { - private var folder: URL! - private var store: VoicemailStore! - - override func setUpWithError() throws { - folder = FileManager.default.temporaryDirectory - .appendingPathComponent("voicemail-store-tests-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - store = VoicemailStore(directoryPath: folder.path) - } - - override func tearDownWithError() throws { - try FileManager.default.removeItem(at: folder) - } - - // MARK: - Fixtures - - /// One row of the `ZSTOREDMESSAGE` table, with synthesized values. - private struct Row { - var id: Int - var sender: String? = nil - var created: Double = 0 - var duration: Double = 30 - var isRead: Bool = false - var fileType: String? = nil - var recordUUID: UUID? = nil - var transcript: Data? = nil - var deleted: Double? = nil - } - - /// Fixed dates far from any real message, as seconds since the reference date. - private let firstDate: Double = 700_000_000 - private let secondDate: Double = 750_000_000 - private let thirdDate: Double = 800_000_000 - private let fourthDate: Double = 850_000_000 - - private func makeStore(_ rows: [Row]) throws { - var connection: OpaquePointer? - XCTAssertEqual(sqlite3_open(store.databasePath, &connection), SQLITE_OK) - defer { sqlite3_close(connection) } - - try execute( - """ - CREATE TABLE ZSTOREDMESSAGE ( - Z_PK INTEGER PRIMARY KEY, - ZFROM TEXT, - ZDATECREATED REAL, - ZDATEDELETED REAL, - ZDURATION REAL, - ZISREAD INTEGER, - ZFILETYPE TEXT, - ZRECORDUUID BLOB, - ZTRANSCRIPT BLOB - ) - """, - on: connection - ) - - for row in rows { - try insert(row, into: connection) - } - } - - private func execute(_ sql: String, on connection: OpaquePointer?) throws { - var statement: OpaquePointer? - XCTAssertEqual(sqlite3_prepare_v2(connection, sql, -1, &statement, nil), SQLITE_OK) - defer { sqlite3_finalize(statement) } - XCTAssertEqual(sqlite3_step(statement), SQLITE_DONE) - } - - private func insert(_ row: Row, into connection: OpaquePointer?) throws { - let sql = """ - INSERT INTO ZSTOREDMESSAGE - (Z_PK, ZFROM, ZDATECREATED, ZDATEDELETED, ZDURATION, ZISREAD, ZFILETYPE, ZRECORDUUID, ZTRANSCRIPT) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """ - var statement: OpaquePointer? - XCTAssertEqual(sqlite3_prepare_v2(connection, sql, -1, &statement, nil), SQLITE_OK) - defer { sqlite3_finalize(statement) } - - sqlite3_bind_int64(statement, 1, Int64(row.id)) - bindText(row.sender, to: statement, at: 2) - sqlite3_bind_double(statement, 3, row.created) - bindDouble(row.deleted, to: statement, at: 4) - sqlite3_bind_double(statement, 5, row.duration) - sqlite3_bind_int(statement, 6, row.isRead ? 1 : 0) - bindText(row.fileType, to: statement, at: 7) - bindUUID(row.recordUUID, to: statement, at: 8) - bindData(row.transcript, to: statement, at: 9) - - XCTAssertEqual(sqlite3_step(statement), SQLITE_DONE) - } - - private func bindText(_ text: String?, to statement: OpaquePointer?, at index: Int32) { - guard let text else { - sqlite3_bind_null(statement, index) - return - } - sqlite3_bind_text(statement, index, text, -1, transientDestructor) - } - - private func bindDouble(_ value: Double?, to statement: OpaquePointer?, at index: Int32) { - guard let value else { - sqlite3_bind_null(statement, index) - return - } - sqlite3_bind_double(statement, index, value) - } - - private func bindUUID(_ uuid: UUID?, to statement: OpaquePointer?, at index: Int32) { - guard let uuid else { - sqlite3_bind_null(statement, index) - return - } - withUnsafeBytes(of: uuid.uuid) { bytes in - _ = sqlite3_bind_blob( - statement, - index, - bytes.baseAddress, - Int32(bytes.count), - transientDestructor - ) - } - } - - private func bindData(_ data: Data?, to statement: OpaquePointer?, at index: Int32) { - guard let data else { - sqlite3_bind_null(statement, index) - return - } - data.withUnsafeBytes { bytes in - _ = sqlite3_bind_blob( - statement, - index, - bytes.baseAddress, - Int32(bytes.count), - transientDestructor - ) - } - } - - /// Writes a fake audio file for the row into the store's `Assets` tree. - private func writeAudio(for row: Row, contents: Data = Data("synthesized audio".utf8)) throws { - let uuid = try XCTUnwrap(row.recordUUID) - let fileType = try XCTUnwrap(row.fileType) - let url = - folder - .appendingPathComponent("Assets/\(uuid.uuidString.prefix(2))/\(uuid.uuidString).\(fileType)") - try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try contents.write(to: url) - } - - /// A transcript archive in the shape carrier voicemails use: - /// a dictionary with the flattened text alongside per-segment detail. - private func carrierTranscript() throws -> Data { - let segments: [[String: Any]] = [ - ["text": "Hello", "confidence": 0.9], - ["text": "leave a message", "confidence": 0.8], - ] - let root: [String: Any] = [ - "transcriptionString": "Hello, leave a message", - "confidence": 0.85, - "confidenceRating": 2, - "segments": segments, - ] - return try NSKeyedArchiver.archivedData(withRootObject: root, requiringSecureCoding: false) - } - - /// A transcript archive in the shape FaceTime messages use: - /// a bare array of segments. - private func faceTimeTranscript() throws -> Data { - let segments: [[String: Any]] = [ - ["text": "First sentence", "confidence": 0.7], - ["text": "Second sentence", "confidence": 0.6], - ] - return try NSKeyedArchiver.archivedData(withRootObject: segments, requiringSecureCoding: false) - } - - /// An atom with the standard 32-bit size header. - private func atom(_ type: String, payload: Data) -> Data { - var data = withUnsafeBytes(of: UInt32(payload.count + 8).bigEndian) { Data($0) } - data.append(Data(type.utf8)) - return data + payload - } - - /// An atom with the extended-size header QuickTime uses for 64-bit sizes, - /// as large `mdat` atoms carry. - private func extendedSizeAtom(_ type: String, payload: Data) -> Data { - var data = withUnsafeBytes(of: UInt32(1).bigEndian) { Data($0) } - data.append(Data(type.utf8)) - data.append(withUnsafeBytes(of: UInt64(payload.count + 16).bigEndian) { Data($0) }) - return data + payload - } - - /// A synthetic QuickTime movie whose single track declares an audio or a - /// video handler — the structure `QuickTimeMovie` reads. - private func movieFile(hasVideoTrack: Bool) -> Data { - var handler = Data(repeating: 0, count: 8) - handler.append(Data((hasVideoTrack ? "vide" : "soun").utf8)) - handler.append(Data(repeating: 0, count: 12)) - - let media = atom("mdia", payload: atom("hdlr", payload: handler)) - let track = atom("trak", payload: media) - var file = atom("ftyp", payload: Data("qt ".utf8)) - file.append(atom("moov", payload: track)) - return file - } - - // MARK: - Fetching - - func testFetchMapsRowsNewestFirst() throws { - try makeStore([ - Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr"), - Row( - id: 2, - sender: "facetime@example.com", - created: thirdDate, - duration: 12.5, - isRead: true, - fileType: "m4a" - ), - Row(id: 3, sender: "+15550102", created: secondDate, fileType: "MOV"), - ]) - - let voicemails = try store.fetch(VoicemailMessage.FetchRequest(limit: 30)) - - XCTAssertEqual(voicemails.map(\.id), [2, 3, 1]) - XCTAssertEqual(voicemails[0].sender, "facetime@example.com") - XCTAssertEqual(voicemails[0].date, Date(timeIntervalSinceReferenceDate: thirdDate)) - XCTAssertEqual(voicemails[0].duration, 12.5) - XCTAssertEqual(voicemails[0].isRead, true) - XCTAssertEqual(voicemails[0].kind, .faceTimeAudio) - XCTAssertEqual(voicemails[0].hasTranscript, false) - XCTAssertEqual(voicemails[1].kind, .faceTimeVideo) - XCTAssertEqual(voicemails[2].kind, .carrierVoicemail) - } - - func testFetchFiltersBySenderTypeAndDate() throws { - try makeStore([ - Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr"), - Row(id: 2, sender: "+15550101", created: secondDate, fileType: "amr"), - Row(id: 3, sender: "facetime@example.com", created: thirdDate, fileType: "m4a"), - ]) - - var request = VoicemailMessage.FetchRequest(limit: 30) - request.sender = "5550101" - XCTAssertEqual(try store.fetch(request).map(\.id), [2, 1]) - - request = VoicemailMessage.FetchRequest(limit: 30) - request.kind = .faceTimeAudio - XCTAssertEqual(try store.fetch(request).map(\.id), [3]) - - request = VoicemailMessage.FetchRequest(limit: 30) - request.startDate = Date(timeIntervalSinceReferenceDate: firstDate) - request.endDate = Date(timeIntervalSinceReferenceDate: thirdDate) - XCTAssertEqual(try store.fetch(request).map(\.id), [2, 1]) - - request = VoicemailMessage.FetchRequest(limit: 2) - XCTAssertEqual(try store.fetch(request).map(\.id), [3, 2]) - } - - func testFetchExcludesDeletedMessages() throws { - try makeStore([ - Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr"), - Row(id: 2, sender: "+15550102", created: secondDate, fileType: "amr", deleted: secondDate), - ]) - - let voicemails = try store.fetch(VoicemailMessage.FetchRequest(limit: 30)) - - XCTAssertEqual(voicemails.map(\.id), [1]) - } - - func testFetchSenderFilterMatchesLiteralWildcards() throws { - try makeStore([ - Row(id: 1, sender: "a_b@example.com", created: firstDate, fileType: "amr"), - Row(id: 2, sender: "axb@example.com", created: secondDate, fileType: "amr"), - Row(id: 3, sender: "100%.example.com", created: thirdDate, fileType: "amr"), - ]) - - var request = VoicemailMessage.FetchRequest(limit: 30) - request.sender = "_" - XCTAssertEqual(try store.fetch(request).map(\.id), [1]) - - request.sender = "%" - XCTAssertEqual(try store.fetch(request).map(\.id), [3]) - } - - // MARK: - Messages and Transcripts - - func testMessageReturnsRowByID() throws { - try makeStore([ - Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr", recordUUID: testUUID(1)) - ]) - - let message = try XCTUnwrap(store.message(id: 1)) - XCTAssertEqual(message.sender, "+15550101") - XCTAssertEqual(message.kind, .carrierVoicemail) - XCTAssertNil(try store.message(id: 2)) - } - - func testMessageRoundTrips64BitIdentifiers() throws { - try makeStore([ - Row(id: 5_000_000_000, sender: "+15550101", created: firstDate, fileType: "amr") - ]) - - XCTAssertEqual( - try store.fetch(VoicemailMessage.FetchRequest(limit: 30)).map(\.id), - [5_000_000_000] - ) - - let message = try XCTUnwrap(store.message(id: 5_000_000_000)) - XCTAssertEqual(message.id, 5_000_000_000) - } - - func testTranscriptDecodesCarrierShape() throws { - try makeStore([ - Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr", transcript: carrierTranscript()) - ]) - - let transcript = try XCTUnwrap(store.transcript(id: 1)) - XCTAssertEqual(transcript.text, "Hello, leave a message") - XCTAssertEqual(transcript.confidence, 0.85) - XCTAssertEqual(transcript.segments.count, 2) - XCTAssertEqual(transcript.segments[0].text, "Hello") - XCTAssertEqual(transcript.segments[0].confidence, 0.9) - XCTAssertEqual(transcript.segments[1].text, "leave a message") - } - - func testTranscriptDecodesFaceTimeShape() throws { - try makeStore([ - Row( - id: 1, - sender: "facetime@example.com", - created: firstDate, - fileType: "m4a", - transcript: faceTimeTranscript() - ) - ]) - - let transcript = try XCTUnwrap(store.transcript(id: 1)) - XCTAssertEqual(transcript.text, "First sentence Second sentence") - XCTAssertNil(transcript.confidence) - XCTAssertEqual(transcript.segments.count, 2) - XCTAssertEqual(transcript.segments[0].text, "First sentence") - XCTAssertEqual(transcript.segments[1].confidence, 0.6) - } - - func testTranscriptIsNilWithoutBlob() throws { - try makeStore([ - Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr") - ]) - - XCTAssertNil(try store.transcript(id: 1)) - } - - // MARK: - Audio - - func testAudioResolvesByRecordUUID() throws { - let row = Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr", recordUUID: testUUID(1)) - try makeStore([row]) - try writeAudio(for: row, contents: Data("carrier audio".utf8)) - - let message = try XCTUnwrap(store.message(id: 1)) - XCTAssertEqual(message.hasAudio, true) - XCTAssertEqual(store.audioMimeType(for: message), "audio/amr") - let data = try XCTUnwrap(try store.audioData(for: message)) - XCTAssertEqual(String(data: data, encoding: .utf8), "carrier audio") - } - - func testAudioHandlesOtherTypesAndMissingFiles() throws { - let m4aRow = Row( - id: 1, - sender: "facetime@example.com", - created: firstDate, - fileType: "m4a", - recordUUID: testUUID(1) - ) - let movRow = Row(id: 2, sender: "+15550102", created: secondDate, fileType: "MOV", recordUUID: testUUID(2)) - let missingRow = Row(id: 3, sender: "+15550103", created: thirdDate, fileType: "amr", recordUUID: testUUID(3)) - try makeStore([m4aRow, movRow, missingRow]) - try writeAudio(for: m4aRow) - try writeAudio(for: movRow) - - let m4aMessage = try XCTUnwrap(store.message(id: 1)) - XCTAssertEqual(store.audioMimeType(for: m4aMessage), "audio/mp4") - XCTAssertEqual(m4aMessage.hasAudio, true) - - let movMessage = try XCTUnwrap(store.message(id: 2)) - XCTAssertEqual(movMessage.hasAudio, false) - XCTAssertNil(store.audioMimeType(for: movMessage)) - XCTAssertNil(try store.audioData(for: movMessage)) - - let missingMessage = try XCTUnwrap(store.message(id: 3)) - XCTAssertEqual(missingMessage.hasAudio, false) - XCTAssertNil(try store.audioData(for: missingMessage)) - } - - func testFetchClassifiesMoviesByAsset() throws { - let carrier = Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr", recordUUID: testUUID(1)) - let audioMovie = Row(id: 2, sender: "+15550102", created: secondDate, fileType: "MOV", recordUUID: testUUID(2)) - let videoMovie = Row(id: 3, sender: "+15550103", created: thirdDate, fileType: "MOV", recordUUID: testUUID(3)) - let missingMovie = Row( - id: 4, - sender: "+15550104", - created: fourthDate, - fileType: "MOV", - recordUUID: testUUID(4) - ) - try makeStore([carrier, audioMovie, videoMovie, missingMovie]) - try writeAudio(for: carrier, contents: Data("carrier audio".utf8)) - try writeAudio(for: audioMovie, contents: movieFile(hasVideoTrack: false)) - try writeAudio(for: videoMovie, contents: movieFile(hasVideoTrack: true)) - - let voicemails = try store.fetch(VoicemailMessage.FetchRequest(limit: 30)) - - XCTAssertEqual( - voicemails.map(\.kind), - [.faceTimeVideo, .faceTimeVideo, .carrierVoicemail, .carrierVoicemail] - ) - XCTAssertEqual(voicemails.map(\.hasAudio), [false, false, true, true]) - } - - func testKindFilterResolvesMoviesByAssetAndAppliesLimitAfter() throws { - let carrier = Row(id: 1, sender: "+15550101", created: firstDate, fileType: "amr", recordUUID: testUUID(1)) - let audioMovie = Row(id: 2, sender: "+15550102", created: secondDate, fileType: "MOV", recordUUID: testUUID(2)) - let videoMovie = Row(id: 3, sender: "+15550103", created: thirdDate, fileType: "MOV", recordUUID: testUUID(3)) - try makeStore([carrier, audioMovie, videoMovie]) - try writeAudio(for: audioMovie, contents: movieFile(hasVideoTrack: false)) - try writeAudio(for: videoMovie, contents: movieFile(hasVideoTrack: true)) - - var request = VoicemailMessage.FetchRequest(limit: 30) - request.kind = .carrierVoicemail - XCTAssertEqual(try store.fetch(request).map(\.id), [2, 1]) - - request.kind = .faceTimeVideo - XCTAssertEqual(try store.fetch(request).map(\.id), [3]) - - request.kind = .carrierVoicemail - request.limit = 1 - XCTAssertEqual(try store.fetch(request).map(\.id), [2]) - } - - func testAudioOnlyMovieIsPlayableCarrierAudio() throws { - let row = Row(id: 1, sender: "+15550101", created: firstDate, fileType: "MOV", recordUUID: testUUID(1)) - try makeStore([row]) - try writeAudio(for: row, contents: movieFile(hasVideoTrack: false)) - - let message = try XCTUnwrap(store.message(id: 1)) - XCTAssertEqual(message.kind, .carrierVoicemail) - XCTAssertEqual(message.hasAudio, true) - XCTAssertEqual(store.audioMimeType(for: message), "audio/mp4") - XCTAssertEqual(try XCTUnwrap(try store.audioData(for: message)), movieFile(hasVideoTrack: false)) - } - - func testQuickTimeMovieProbeDistinguishesAudioOnlyMovies() { - XCTAssertTrue(QuickTimeMovie.isAudioOnlyMovie(movieFile(hasVideoTrack: false))) - XCTAssertFalse(QuickTimeMovie.isAudioOnlyMovie(movieFile(hasVideoTrack: true))) - XCTAssertFalse(QuickTimeMovie.isAudioOnlyMovie(Data("not a movie".utf8))) - XCTAssertFalse(QuickTimeMovie.isAudioOnlyMovie(Data())) - } - - func testQuickTimeMovieSkipsExtendedSizeAtoms() { - var audioFile = atom("ftyp", payload: Data("qt ".utf8)) - audioFile.append(extendedSizeAtom("mdat", payload: Data(repeating: 0, count: 65_536))) - audioFile.append(movieFile(hasVideoTrack: false)) - XCTAssertTrue(QuickTimeMovie.isAudioOnlyMovie(audioFile)) - - var videoFile = atom("ftyp", payload: Data("qt ".utf8)) - videoFile.append(extendedSizeAtom("mdat", payload: Data(repeating: 0, count: 65_536))) - videoFile.append(movieFile(hasVideoTrack: true)) - XCTAssertFalse(QuickTimeMovie.isAudioOnlyMovie(videoFile)) - } - - func testQuickTimeMovieEntersExtendedSizeContainers() { - var handler = Data(repeating: 0, count: 8) - handler.append(Data("soun".utf8)) - handler.append(Data(repeating: 0, count: 12)) - let track = atom("trak", payload: atom("mdia", payload: atom("hdlr", payload: handler))) - - var file = atom("ftyp", payload: Data("qt ".utf8)) - file.append(extendedSizeAtom("moov", payload: track)) - - XCTAssertTrue(QuickTimeMovie.isAudioOnlyMovie(file)) - } - - func testQuickTimeMovieRejectsOverflowingExtendedSizes() { - var malformed = withUnsafeBytes(of: UInt32(1).bigEndian) { Data($0) } - malformed.append(Data("mdat".utf8)) - malformed.append(withUnsafeBytes(of: UInt64(Int.max).bigEndian) { Data($0) }) - - var file = atom("ftyp", payload: Data("qt ".utf8)) - file.append(malformed) - - XCTAssertFalse(QuickTimeMovie.isAudioOnlyMovie(file)) - } - - // MARK: - Helpers - - /// A fixed UUID synthesized for tests, distinct per index. - private func testUUID(_ index: Int) -> UUID { - let pattern = "00000000-0000-0000-0000-00000000000X" - let string = pattern.replacingOccurrences(of: "X", with: String(index)) - return UUID(uuidString: string)! - } -} - -/// The copy-on-bind destructor for `sqlite3_bind_` calls. -private let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self) diff --git a/README.md b/README.md index c808a541..735300fe 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ and a [growing list of clients][mcp-clients] that support the Voicemail - Fetch voicemails synced from your iPhone, with transcripts and audio, filtering by sender, date, or message type. + Fetch voicemails synced from your iPhone, with transcripts and audio, filtering by sender, date, message type, or mailbox (inbox, blocked, or deleted). diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index be455aab..ce3c7ba3 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -15,8 +15,6 @@ 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 */; }; - B0D1DEADBEEF00000000000E /* VoicemailStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF00000000000D /* VoicemailStore.swift */; }; - B0D1DEADBEEF000000000010 /* VoicemailStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF00000000000F /* VoicemailStoreTests.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 */; }; @@ -66,8 +64,6 @@ 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 = ""; }; - B0D1DEADBEEF00000000000D /* VoicemailStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = VoicemailStore.swift; path = ../App/Services/VoicemailStore.swift; sourceTree = ""; }; - B0D1DEADBEEF00000000000F /* VoicemailStoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoicemailStoreTests.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; }; @@ -161,8 +157,6 @@ B0D1DEADBEEF000000000009 /* MCPInitializeCompatibility.swift */, B0D1DEADBEEF00000000000B /* MCPInitializeCompatibilityTests.swift */, B0D1DEADBEEF000000000005 /* MCPServiceLoop.swift */, - B0D1DEADBEEF00000000000D /* VoicemailStore.swift */, - B0D1DEADBEEF00000000000F /* VoicemailStoreTests.swift */, ); path = CLITests; sourceTree = ""; @@ -354,8 +348,6 @@ B0D1DEADBEEF000000000002 /* BonjourDiscovery.swift in Sources */, B0D1DEADBEEF000000000008 /* MCPServiceLoopTests.swift in Sources */, B0D1DEADBEEF000000000006 /* MCPServiceLoop.swift in Sources */, - B0D1DEADBEEF00000000000E /* VoicemailStore.swift in Sources */, - B0D1DEADBEEF000000000010 /* VoicemailStoreTests.swift in Sources */, B0D1DEADBEEF00000000000A /* MCPInitializeCompatibility.swift in Sources */, B0D1DEADBEEF00000000000C /* MCPInitializeCompatibilityTests.swift in Sources */, );