From 12f84fede2b1ebc9b98db27aab9f50af6a9cc23d Mon Sep 17 00:00:00 2001 From: Brad Hallett <53977268+bradhallett@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:15:27 -0400 Subject: [PATCH 1/6] Add Visual Voicemail service * Add VoicemailService with voicemails_fetch, voicemail_transcript_fetch, and voicemail_audio_fetch tools * Read the store the Phone app syncs on macOS 26 and later: ZSTOREDMESSAGE rows, inline NSKeyedArchiver transcripts, and audio in the Assets tree * Decode both transcript shapes: transcript objects with flattened text and segments, and bare segment arrays * Gate service registration on the store's existence, so Macs without the Phone app are unaffected * Request access to the Data Store folder, since the write-ahead log and the Assets tree sit beside the database * List FaceTime video messages but return audio only for carrier voicemails and FaceTime audio messages * Add VoicemailStoreTests against a synthesized store; no fixture is copied from a real one --- App/Controllers/ServerController.swift | 17 + App/Services/Voicemail.swift | 494 ++++++++++++++++++++++++ App/Services/VoicemailStore.swift | 496 +++++++++++++++++++++++++ Assets/voicemail.svg | 9 + CLITests/VoicemailStoreTests.swift | 359 ++++++++++++++++++ README.md | 16 + iMCP.xcodeproj/project.pbxproj | 8 + 7 files changed, 1399 insertions(+) create mode 100644 App/Services/Voicemail.swift create mode 100644 App/Services/VoicemailStore.swift create mode 100644 Assets/voicemail.svg create mode 100644 CLITests/VoicemailStoreTests.swift diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index e2eeef00..1525d280 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -61,6 +61,9 @@ enum ServiceRegistry { ShortcutsService.shared, UtilitiesService.shared, ] + if VoicemailService.isStorePresent { + services.append(VoicemailService.shared) + } #if WEATHERKIT_AVAILABLE services.append(WeatherService.shared) #endif @@ -78,6 +81,7 @@ enum ServiceRegistry { remindersEnabled: Binding, shortcutsEnabled: Binding, utilitiesEnabled: Binding, + voicemailEnabled: Binding, weatherEnabled: Binding ) -> [ServiceConfig] { var configs: [ServiceConfig] = [ @@ -145,6 +149,17 @@ enum ServiceRegistry { binding: shortcutsEnabled ), ] + if VoicemailService.isStorePresent { + configs.append( + ServiceConfig( + name: "Voicemail", + iconName: "recordingtape", + color: .teal, + service: VoicemailService.shared, + binding: voicemailEnabled + ) + ) + } #if WEATHERKIT_AVAILABLE configs.append( ServiceConfig( @@ -184,6 +199,7 @@ final class ServerController: ObservableObject { @AppStorage("remindersEnabled") private var remindersEnabled = false @AppStorage("shortcutsEnabled") private var shortcutsEnabled = false @AppStorage("utilitiesEnabled") private var utilitiesEnabled = true // Default enabled + @AppStorage("voicemailEnabled") private var voicemailEnabled = false @AppStorage("weatherEnabled") private var weatherEnabled = false // MARK: - AppStorage for Trusted Clients @@ -206,6 +222,7 @@ final class ServerController: ObservableObject { remindersEnabled: $remindersEnabled, shortcutsEnabled: $shortcutsEnabled, utilitiesEnabled: $utilitiesEnabled, + voicemailEnabled: $voicemailEnabled, weatherEnabled: $weatherEnabled ) } diff --git a/App/Services/Voicemail.swift b/App/Services/Voicemail.swift new file mode 100644 index 00000000..476ff9e6 --- /dev/null +++ b/App/Services/Voicemail.swift @@ -0,0 +1,494 @@ +import AppKit +import OSLog + +private let log = Logger.service("voicemail") +private let dataStoreDirectoryPath = + "/Users/\(NSUserName())/Library/Group Containers/group.com.apple.FaceTime/com.apple.facetimemessagestored/Data Store" +private let dataStoreBookmarkKey: String = "me.mattt.iMCP.voicemailDataStoreBookmark" +private let defaultLimit = 30 + +/// Reads voicemails synced to the Mac by the Phone app on macOS 26 and later, +/// including FaceTime audio and video messages. +final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { + static let shared = VoicemailService() + + /// Whether this Mac has a voicemail store at all. The store only exists once the + /// Phone app is set up, so the service stays hidden on Macs without it. + static var isStorePresent: Bool { + FileManager.default.fileExists(atPath: defaultDatabasePath) + } + + private static var defaultDatabasePath: String { + dataStoreDirectoryPath + "/" + VoicemailStore.databaseFileName + } + + var tools: [Tool] { + Tool( + name: "voicemails_fetch", + description: + "Fetch voicemails from the Mac (synced from iPhone, including FaceTime audio messages)", + inputSchema: .object( + properties: [ + "sender": .string( + description: + "Phone number or address 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. Date-only uses local midnight.", + format: .dateTime + ), + "end": .string( + description: + "End of the date range (exclusive). ISO 8601 format. If timezone is omitted, local time is assumed. A date-only value includes that whole day.", + format: .dateTime + ), + "type": .string( + description: "Filter by message type, or omit for all", + enum: ["carrierVoicemail", "faceTimeAudio", "faceTimeVideo"] + ), + "limit": .integer( + description: "Maximum voicemails to return", + default: .int(defaultLimit), + minimum: 1 + ), + ], + additionalProperties: false + ), + annotations: .init( + title: "Fetch Voicemails", + readOnlyHint: true, + openWorldHint: false + ) + ) { arguments in + 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 = VoicemailMessage.FetchRequest(limit: limit) + request.sender = try self.argument("sender", in: arguments, as: \.stringValue) + if let type = try self.argument("type", in: arguments, as: \.stringValue) { + guard let kind = VoicemailMessage.Kind(rawValue: type) else { + throw ArgumentError.invalid( + "type must be one of: carrierVoicemail, faceTimeAudio, faceTimeVideo" + ) + } + request.kind = kind + } + 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 = Calendar.current.normalizedStartDate( + from: parsed.date, + isDateOnly: parsed.isDateOnly + ) + } + 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 = Calendar.current.normalizedEndDate( + from: parsed.date, + isDateOnly: parsed.isDateOnly + ) + } + + try await self.requestDataStoreAccess() + let voicemails = try self.withStore { try $0.fetch(request) } + + return [ + "@context": "https://schema.org", + "@type": "ItemList", + "name": "Voicemails", + "numberOfItems": .int(voicemails.count), + "itemListElement": Value.array(voicemails.map(\.value)), + ] + } + + Tool( + name: "voicemail_transcript_fetch", + description: "Fetch the transcript of a voicemail", + inputSchema: .object( + properties: [ + "id": .string( + description: "Identifier of the voicemail, as returned by voicemails_fetch" + ) + ], + required: ["id"], + additionalProperties: false + ), + annotations: .init( + title: "Fetch Voicemail Transcript", + readOnlyHint: true, + openWorldHint: false + ) + ) { arguments in + 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 { + throw TranscriptError.notFound(id) + } + return try store.transcript(id: id) + } + guard let transcript else { + throw TranscriptError.notTranscribed(id) + } + + var object: [String: Value] = [ + "@id": .string(String(id)), + "text": .string(transcript.text), + ] + if let confidence = transcript.confidence { + object["confidence"] = .double(confidence) + } + if !transcript.segments.isEmpty { + object["segment"] = Value.array(transcript.segments.map(\.value)) + } + return Value.object(object) + } + + Tool( + name: "voicemail_audio_fetch", + description: + "Fetch the audio of a voicemail (carrier voicemails and FaceTime audio messages)", + inputSchema: .object( + properties: [ + "id": .string( + description: "Identifier of the voicemail, as returned by voicemails_fetch" + ) + ], + required: ["id"], + additionalProperties: false + ), + annotations: .init( + title: "Fetch Voicemail Audio", + readOnlyHint: true, + openWorldHint: false + ) + ) { arguments in + let id = try self.messageIdentifier(from: arguments) + try await self.requestDataStoreAccess() + + let audio = try self.withStore { store -> (mimeType: String, data: Data)? in + guard let message = try store.message(id: id) else { + return nil + } + guard let mimeType = store.audioMimeType(for: message) else { + throw AudioError.unsupportedFileType(id) + } + guard let data = try store.audioData(for: message) else { + throw AudioError.audioMissing(id) + } + return (mimeType, data) + } + guard let audio else { + throw AudioError.notFound(id) + } + return Value.data(mimeType: audio.mimeType, audio.data) + } + } + + // MARK: - Data Store Access + + /// Ensures the voicemail store is readable, asking the user to grant access if needed. + /// + /// The grant has to cover the `Data Store` folder, not just the database file. + /// The store is a WAL-mode SQLite database, so SQLite also opens the `-wal` and + /// `-shm` files next to it, and the message audio lives in the `Assets` tree + /// beside the database. A bookmark on the file alone would leave both unreadable. + private func requestDataStoreAccess() async throws { + if canAccessStoreAtDefaultPath { + log.debug("Using voicemail store at default path") + return + } + + if canAccessStoreUsingBookmark { + log.debug("Using voicemail store from stored bookmark") + return + } + + log.debug("Opening folder picker for manual voicemail store selection") + guard try await showDataStoreAccessAlert() else { + throw DataStoreAccessError.userDeclinedAccess + } + + guard let selectedURL = try await showFolderPicker() else { + // Dismissing the picker is the same answer as Cancel on the alert. + throw DataStoreAccessError.userDeclinedAccess + } + + guard FileManager.default.isReadableFile(atPath: databaseURL(in: selectedURL).path) else { + throw DataStoreAccessError.fileNotReadable + } + + try storeBookmark(for: selectedURL) + log.debug("Granted access to voicemail store") + } + + /// 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 + } + + /// The numeric record identifier passed as the `id` argument. + 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") + } + return id + } + + private var canAccessStoreAtDefaultPath: Bool { + return FileManager.default.isReadableFile(atPath: VoicemailService.defaultDatabasePath) + } + + private var canAccessStoreUsingBookmark: Bool { + do { + let url = try resolveBookmarkedStoreDirectory() + return try withSecurityScopedAccess(url) { _ in + FileManager.default.isReadableFile(atPath: databaseURL(in: url).path) + } + } catch { + log.error( + "Error accessing voicemail store with bookmark: \(error.localizedDescription)" + ) + return false + } + } + + private func databaseURL(in directory: URL) -> URL { + return directory.appendingPathComponent(VoicemailStore.databaseFileName) + } + + private func resolveBookmarkedStoreDirectory() throws -> URL { + guard + let bookmarkData = UserDefaults.standard.data(forKey: dataStoreBookmarkKey) + else { + throw DataStoreAccessError.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 DataStoreAccessError.securityScopeAccessFailed + } + defer { url.stopAccessingSecurityScopedResource() } + return try operation(url) + } + + private func withStore(_ operation: (VoicemailStore) throws -> T) throws -> T { + if canAccessStoreAtDefaultPath { + return try operation(VoicemailStore(directoryPath: dataStoreDirectoryPath)) + } + + let directory = try resolveBookmarkedStoreDirectory() + + // The grant must stay open until the last read: + // SQLite opens the write-ahead log lazily on the first statement. + return try withSecurityScopedAccess(directory) { url in + try operation(VoicemailStore(directoryPath: url.path)) + } + } + + // MARK: - Errors + + private enum DataStoreAccessError: LocalizedError { + case noBookmarkFound + case securityScopeAccessFailed + case userDeclinedAccess + case invalidFolderSelected + case fileNotReadable + + var errorDescription: String? { + switch self { + case .noBookmarkFound: + return "No stored bookmark found for voicemail store access" + case .securityScopeAccessFailed: + return "Failed to access security-scoped resource" + case .userDeclinedAccess: + return "User declined to grant access to the voicemail store" + case .invalidFolderSelected: + return + "Voicemail store access denied or the selection is not the Data Store folder" + case .fileNotReadable: + return "The selected folder has no readable \(VoicemailStore.databaseFileName)" + } + } + } + + private enum ArgumentError: LocalizedError { + case invalid(String) + + var errorDescription: String? { + switch self { + case .invalid(let message): + return "Invalid argument: \(message)" + } + } + } + + private enum TranscriptError: LocalizedError { + case notFound(Int) + case notTranscribed(Int) + + 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" + } + } + } + + private enum AudioError: LocalizedError { + case notFound(Int) + case unsupportedFileType(Int) + case audioMissing(Int) + + var errorDescription: String? { + switch self { + case .notFound(let id): + return "No voicemail found with identifier \(id)" + case .unsupportedFileType(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 .audioMissing(let id): + return + "The audio file of the voicemail with identifier \(id) is missing from the store" + } + } + } + + // MARK: - UI + + @MainActor + private func showDataStoreAccessAlert() async throws -> Bool { + let alert = NSAlert() + alert.messageText = "Voicemail Store Access Required" + alert.informativeText = """ + To read your voicemails, we need access to their Data Store folder: \ + the database, its write-ahead log, and the audio files next to it. + + In the next screen, please select the `Data Store` 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 voicemail folder (~/Library/Group Containers/group.com.apple.FaceTime/com.apple.facetimemessagestored/Data Store)" + openPanel.prompt = "Grant Access" + openPanel.directoryURL = URL(fileURLWithPath: dataStoreDirectoryPath) + .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 isDataStoreDirectory(url) else { + throw DataStoreAccessError.invalidFolderSelected + } + + 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: dataStoreBookmarkKey) + log.debug("Successfully created and stored bookmark") + } + + private func isDataStoreDirectory(_ url: URL) -> Bool { + return url.lastPathComponent == "Data Store" + } + + // NSOpenSavePanelDelegate method to constrain the selection to the Data Store folder + func panel(_ sender: Any, shouldEnable url: URL) -> Bool { + let shouldEnable = isDataStoreDirectory(url) + log.debug( + "File selection panel: \(shouldEnable ? "enabling" : "disabling") URL: \(url.path)" + ) + return shouldEnable + } +} + +// MARK: - + +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] = [ + "@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), + "isRead": .bool(isRead), + "transcribed": .bool(hasTranscript), + "hasAudio": .bool(hasAudio), + ] + if let kind { + object["type"] = .string(kind.rawValue) + } + return .object(object) + } +} + +extension VoicemailTranscript.Segment { + /// The segment as a JSON object for tool output. + var value: Value { + var object: [String: Value] = [ + "text": .string(text) + ] + if let confidence { + object["confidence"] = .double(confidence) + } + return .object(object) + } +} diff --git a/App/Services/VoicemailStore.swift b/App/Services/VoicemailStore.swift new file mode 100644 index 00000000..c98aa3f7 --- /dev/null +++ b/App/Services/VoicemailStore.swift @@ -0,0 +1,496 @@ +import Foundation +import SQLite3 + +/// A read-only view of the voicemail store kept by the Phone app on macOS 26 and later. +/// +/// Carrier and FaceTime voicemails sync to the Mac through the `facetimemessagestored` +/// 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. +struct VoicemailStore { + static let databaseFileName = "FaceTimeMessageStore-local.sqlitedb" + + /// Path of the `Data Store` folder holding the database and the `Assets` tree. + let directoryPath: String + + var databasePath: String { + directoryPath + "/" + VoicemailStore.databaseFileName + } + + func fetch(_ request: VoicemailMessage.FetchRequest) throws -> [VoicemailMessage] { + var messages = try read { try $0.fetch(request) } + for index in messages.indices { + messages[index].hasAudio = hasPlayableAudio(messages[index]) + } + return messages + } + + func message(id: Int) throws -> VoicemailMessage? { + guard var message = try read({ try $0.message(id: id) }) else { return nil } + message.hasAudio = hasPlayableAudio(message) + return message + } + + /// The transcript of the message, or nil when the store has none for it. + func transcript(id: Int) throws -> VoicemailTranscript? { + guard let data = try read({ try $0.transcriptData(id: id) }) else { return nil } + return try VoicemailTranscriptDecoder.decode(data) + } + + /// Location of the message's audio file in the `Assets` tree, when it exists on disk. + func audioURL(for message: VoicemailMessage) -> URL? { + guard let audioFileName = message.audioFileName else { return nil } + let url = URL(fileURLWithPath: directoryPath).appendingPathComponent(audioFileName) + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } + + /// The bytes of the message's audio, when it exists in a playable format. + func audioData(for message: VoicemailMessage) throws -> Data? { + guard audioMimeType(for: message) != nil, let url = audioURL(for: message) else { + return nil + } + return try Data(contentsOf: url) + } + + /// The MIME type of the message's audio, when it is a format clients can play. + func audioMimeType(for message: VoicemailMessage) -> String? { + switch message.fileType { + case "amr": return "audio/amr" + case "m4a": 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 + } + + /// 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. + private func read(_ operation: (VoicemailStoreDatabase) throws -> T) throws -> T { + do { + return try operation(VoicemailStoreDatabase(path: databasePath, immutable: false)) + } catch is SQLiteError { + return try operation(VoicemailStoreDatabase(path: databasePath, immutable: true)) + } + } +} + +// MARK: - + +/// A row of the `ZSTOREDMESSAGE` table: one voicemail or FaceTime audio message. +struct VoicemailMessage { + enum Kind: String { + case carrierVoicemail + case faceTimeAudio + case faceTimeVideo + + /// The `ZFILETYPE` value rows of this kind carry. + var fileType: String { + switch self { + case .carrierVoicemail: return "amr" + case .faceTimeAudio: return "m4a" + case .faceTimeVideo: return "MOV" + } + } + + /// The SQL predicate that selects messages of this kind. + fileprivate var predicate: String { + return "ZFILETYPE = '" + fileType + "'" + } + + /// 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 + } + } + } + + let id: Int + 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 + let isRead: Bool + let fileType: String? + let recordUUID: Data + let hasTranscript: Bool + /// Whether the message's audio exists in the `Assets` tree in a format clients can play. + var hasAudio = false + + var kind: Kind? { + Kind(fileType: fileType) + } + + /// 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 } + return "Assets/" + uuid.prefix(2) + "/" + uuid + "." + fileType + } + + /// Reads the current row of a statement prepared with the store's column list. + 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 + } + + func blob(_ column: Int32) -> Data { + guard let bytes = sqlite3_column_blob(statement, column) else { return Data() } + let byteCount = Int(sqlite3_column_bytes(statement, column)) + guard byteCount > 0 else { return Data() } + return Data(bytes: bytes, count: byteCount) + } + + id = Int(sqlite3_column_int64(statement, 0)) + sender = text(1) + date = Date(timeIntervalSinceReferenceDate: sqlite3_column_double(statement, 2)) + duration = sqlite3_column_double(statement, 3) + isRead = sqlite3_column_int(statement, 4) == 1 + fileType = text(5) + recordUUID = blob(6) + hasTranscript = sqlite3_column_int(statement, 7) == 1 + } +} + +extension VoicemailMessage { + struct FetchRequest { + /// Sender (phone number or address) to match (partial, case-insensitive). + var sender: String? + /// Start of the date range (inclusive). + var startDate: Date? + /// End of the date range (exclusive). + var endDate: Date? + var kind: Kind? + var limit: Int + + /// The SQL and its bound values, in placeholder order. + fileprivate var statement: (sql: String, bindings: [SQLiteValue]) { + var conditions = ["ZDATEDELETED IS NULL"] + var bindings: [SQLiteValue] = [] + + if let sender { + conditions.append("ZFROM LIKE ?") + bindings.append(.text("%" + sender + "%")) + } + if let startDate { + conditions.append("ZDATECREATED >= ?") + bindings.append(.double(startDate.timeIntervalSinceReferenceDate)) + } + if let endDate { + conditions.append("ZDATECREATED < ?") + bindings.append(.double(endDate.timeIntervalSinceReferenceDate)) + } + if let kind { + conditions.append(kind.predicate) + } + bindings.append(.int(limit)) + + let sql = """ + SELECT \(voicemailColumns) + FROM ZSTOREDMESSAGE + WHERE \(conditions.joined(separator: " AND ")) + ORDER BY ZDATECREATED DESC + LIMIT ? + """ + return (sql, bindings) + } + } +} + +// MARK: - + +/// A voicemail transcript, as stored by the voicemail daemon. +struct VoicemailTranscript { + struct Segment { + let text: String + let confidence: Double? + } + + /// The flattened transcript text. + let text: String + /// Per-utterance segments, when the store provides them. + let segments: [Segment] + /// Overall confidence, when the store provides it. + let confidence: Double? +} + +/// Decodes the `NSKeyedArchiver` archives the voicemail daemon writes into `ZTRANSCRIPT`. +enum VoicemailTranscriptDecoder { + static func decode(_ data: Data) throws -> VoicemailTranscript { + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + guard let archive = plist as? [String: Any], + let objects = archive["$objects"] as? [Any], + let top = archive["$top"] as? [String: Any], + let rootReference = top["root"] + else { + throw DecodingError.malformedArchive + } + + let root = resolve(rootReference, in: objects) + + // Carrier voicemails archive a transcript object with the flattened text + // alongside per-segment detail. + if let dictionary = root as? [String: Any], + dictionary["transcriptionString"] != nil || dictionary["segments"] != nil + { + let segments = (dictionary["segments"] as? [Any] ?? []).compactMap { + $0 as? [String: Any] + }.map(Self.segment) + let text = + (dictionary["transcriptionString"] as? String) + ?? segments.map(\.text).joined(separator: " ") + return VoicemailTranscript( + text: text, + segments: segments, + confidence: dictionary["confidence"] as? Double + ) + } + + // FaceTime messages archive a bare array of segments. + if let elements = root as? [Any] { + let segments = elements.compactMap { $0 as? [String: Any] }.map(Self.segment) + return VoicemailTranscript( + text: segments.map(\.text).joined(separator: " "), + segments: segments, + confidence: nil + ) + } + + throw DecodingError.malformedArchive + } + + private static func segment(_ dictionary: [String: Any]) -> VoicemailTranscript.Segment { + return VoicemailTranscript.Segment( + text: dictionary["text"] as? String ?? "", + confidence: dictionary["confidence"] as? Double + ) + } + + /// Materializes a keyed-archive object graph, following `CFKeyedArchiverUID` + /// references into the archive's `$objects` array. + /// + /// `PropertyListSerialization` hands those references back as opaque Core Foundation + /// types. Their only public surface is the debug description, + /// `{value = N}`, so the index is read from it. + private static func resolve(_ object: Any, in objects: [Any]) -> Any { + if let index = keyedArchiverUIDValue(of: object) { + guard objects.indices.contains(index) else { return NSNull() } + return resolve(objects[index], in: objects) + } + + switch object { + case let dictionary as [String: Any]: + // A mutable-dictionary proxy in the archive: pair its keys with its values. + if let keys = dictionary["NS.keys"] as? [Any], + let values = dictionary["NS.objects"] as? [Any] + { + var pairs: [String: Any] = [:] + for (key, value) in zip(keys, values) { + guard let key = resolve(key, in: objects) as? String else { continue } + pairs[key] = resolve(value, in: objects) + } + return pairs + } + // A mutable-array proxy in the archive: materialize its elements. + if let elements = dictionary["NS.objects"] as? [Any] { + return elements.map { resolve($0, in: objects) } + } + if dictionary["$classname"] != nil { + return NSNull() // A class description, not data. + } + var values: [String: Any] = [:] + for (key, value) in dictionary where key != "$class" { + values[key] = resolve(value, in: objects) + } + return values + case let array as [Any]: + return array.map { resolve($0, in: objects) } + default: + return object + } + } + + /// The `$objects` index a `CFKeyedArchiverUID` refers to, read from its description. + private static func keyedArchiverUIDValue(of object: Any) -> Int? { + let description = String(describing: object) + guard description.hasPrefix(" [VoicemailMessage] { + 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 messages: [VoicemailMessage] = [] + var result = sqlite3_step(statement) + while result == SQLITE_ROW { + messages.append(VoicemailMessage(statement)) + result = sqlite3_step(statement) + } + guard result == SQLITE_DONE else { + throw SQLiteError(message: String(cString: sqlite3_errmsg(connection))) + } + return messages + } + + func message(id: Int) throws -> VoicemailMessage? { + var statement: OpaquePointer? + let sql = "SELECT \(voicemailColumns) 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))) + } + defer { sqlite3_finalize(statement) } + + SQLiteValue.int(id).bind(to: statement, at: 1) + + switch sqlite3_step(statement) { + case SQLITE_ROW: + return VoicemailMessage(statement) + case SQLITE_DONE: + return nil + case let result: + throw SQLiteError(message: "stepping the statement failed with code \(result)") + } + } + + func transcriptData(id: Int) throws -> Data? { + var statement: OpaquePointer? + let sql = "SELECT ZTRANSCRIPT 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))) + } + defer { sqlite3_finalize(statement) } + + SQLiteValue.int(id).bind(to: statement, at: 1) + + switch sqlite3_step(statement) { + case SQLITE_ROW: + guard let bytes = sqlite3_column_blob(statement, 0) else { return nil } + let byteCount = Int(sqlite3_column_bytes(statement, 0)) + guard byteCount > 0 else { return nil } + return Data(bytes: bytes, count: byteCount) + case SQLITE_DONE: + return nil + case let result: + throw SQLiteError(message: "stepping the statement failed with code \(result)") + } + } +} + +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) diff --git a/Assets/voicemail.svg b/Assets/voicemail.svg new file mode 100644 index 00000000..5d06e9f3 --- /dev/null +++ b/Assets/voicemail.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/CLITests/VoicemailStoreTests.swift b/CLITests/VoicemailStoreTests.swift new file mode 100644 index 00000000..27c8d2b2 --- /dev/null +++ b/CLITests/VoicemailStoreTests.swift @@ -0,0 +1,359 @@ +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 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_int(statement, 1, Int32(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: String = "synthesized audio") 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 Data(contents.utf8).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) + } + + // 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]) + } + + // 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 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: "carrier audio") + + 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)) + } + + // 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 51c8a449..feab6a2c 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,13 @@ and a [growing list of clients][mcp-clients] that support the Phone View call history synced from your iPhone, filter calls by participant, date, or call type, and start calls with system confirmation. + + + + + Voicemail + Fetch voicemails synced from your iPhone, with transcripts and audio, filtering by sender, date, or message type. + @@ -322,6 +329,15 @@ 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. +### Visual Voicemail Database Access + +Voicemails synced from your iPhone by the Phone app (on macOS 26 and later) +live in a SQLite database at +`~/Library/Group Containers/group.com.apple.FaceTime/com.apple.facetimemessagestored/Data Store/FaceTimeMessageStore-local.sqlitedb`. +The store is a write-ahead log database, and the message audio sits in an `Assets` +tree beside it, so the Voicemail service asks you to open the `Data Store` folder +rather than the file alone. + ### JSON-LD for Tool Results The tools provided by iMCP return results as diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index 5b517727..7ae20b5c 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 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 */; }; + B0D1DEADBEEF00000000000A /* VoicemailStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF000000000009 /* VoicemailStore.swift */; }; + B0D1DEADBEEF00000000000C /* VoicemailStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF00000000000B /* 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 */; }; @@ -62,6 +64,8 @@ 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 = ""; }; + B0D1DEADBEEF000000000009 /* VoicemailStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = VoicemailStore.swift; path = ../App/Services/VoicemailStore.swift; sourceTree = ""; }; + B0D1DEADBEEF00000000000B /* 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; }; @@ -151,6 +155,8 @@ B0D1DEADBEEF000000000001 /* BonjourDiscovery.swift */, B0D1DEADBEEF000000000007 /* MCPServiceLoopTests.swift */, B0D1DEADBEEF000000000005 /* MCPServiceLoop.swift */, + B0D1DEADBEEF000000000009 /* VoicemailStore.swift */, + B0D1DEADBEEF00000000000B /* VoicemailStoreTests.swift */, ); path = CLITests; sourceTree = ""; @@ -342,6 +348,8 @@ B0D1DEADBEEF000000000002 /* BonjourDiscovery.swift in Sources */, B0D1DEADBEEF000000000008 /* MCPServiceLoopTests.swift in Sources */, B0D1DEADBEEF000000000006 /* MCPServiceLoop.swift in Sources */, + B0D1DEADBEEF00000000000A /* VoicemailStore.swift in Sources */, + B0D1DEADBEEF00000000000C /* VoicemailStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 5c64b06db2e777769f7f76d4989060a0614e9a29 Mon Sep 17 00:00:00 2001 From: Brad Hallett <53977268+bradhallett@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:32:31 -0400 Subject: [PATCH 2/6] Register Voicemail by OS availability and grant access on activation - Register the service on macOS 26 and later instead of probing the store path, which the app sandbox hides before any grant is given; activation validates the store and requests the Data Store folder - Implement Service activation: isActivated reports real access state and activate() runs the folder-grant flow when the service is toggled on, matching MessageService - Escape SQL LIKE wildcards in the sender filter, so a literal backslash, percent, or underscore in an address matches only itself - Add a regression test for wildcard senders --- App/Controllers/ServerController.swift | 4 ++-- App/Services/Voicemail.swift | 30 ++++++++++++++++++++++---- App/Services/VoicemailStore.swift | 21 ++++++++++++++++-- CLITests/VoicemailStoreTests.swift | 15 +++++++++++++ 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/App/Controllers/ServerController.swift b/App/Controllers/ServerController.swift index 1525d280..eaecad48 100644 --- a/App/Controllers/ServerController.swift +++ b/App/Controllers/ServerController.swift @@ -61,7 +61,7 @@ enum ServiceRegistry { ShortcutsService.shared, UtilitiesService.shared, ] - if VoicemailService.isStorePresent { + if VoicemailService.isSupported { services.append(VoicemailService.shared) } #if WEATHERKIT_AVAILABLE @@ -149,7 +149,7 @@ enum ServiceRegistry { binding: shortcutsEnabled ), ] - if VoicemailService.isStorePresent { + if VoicemailService.isSupported { configs.append( ServiceConfig( name: "Voicemail", diff --git a/App/Services/Voicemail.swift b/App/Services/Voicemail.swift index 476ff9e6..aaea7d52 100644 --- a/App/Services/Voicemail.swift +++ b/App/Services/Voicemail.swift @@ -12,16 +12,38 @@ private let defaultLimit = 30 final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { static let shared = VoicemailService() - /// Whether this Mac has a voicemail store at all. The store only exists once the - /// Phone app is set up, so the service stays hidden on Macs without it. - static var isStorePresent: Bool { - FileManager.default.fileExists(atPath: defaultDatabasePath) + /// Whether this Mac can run the Phone app that creates the voicemail store + /// (macOS 26 and later). The app sandbox hides the store until access is granted, + /// so registration cannot rely on the store being present at the default path; + /// activation requests the grant instead. + static var isSupported: Bool { + if #available(macOS 26.0, *) { return true } + return false } private static var defaultDatabasePath: String { dataStoreDirectoryPath + "/" + VoicemailStore.databaseFileName } + func activate() async throws { + log.debug("Starting voicemail service activation") + try await requestDataStoreAccess() + log.debug("Successfully activated voicemail service") + } + + var isActivated: Bool { + get async { + var isActivated = canAccessStoreAtDefaultPath + // Only probe the bookmark when one exists, so polling a Mac that has + // not granted access yet does not log an error every time. + if UserDefaults.standard.data(forKey: dataStoreBookmarkKey) != nil { + isActivated = isActivated || canAccessStoreUsingBookmark + } + log.debug("Voicemail service activation status: \(isActivated)") + return isActivated + } + } + var tools: [Tool] { Tool( name: "voicemails_fetch", diff --git a/App/Services/VoicemailStore.swift b/App/Services/VoicemailStore.swift index c98aa3f7..d6c3f844 100644 --- a/App/Services/VoicemailStore.swift +++ b/App/Services/VoicemailStore.swift @@ -178,8 +178,8 @@ extension VoicemailMessage { var bindings: [SQLiteValue] = [] if let sender { - conditions.append("ZFROM LIKE ?") - bindings.append(.text("%" + sender + "%")) + conditions.append("ZFROM LIKE ? ESCAPE '\\'") + bindings.append(.text("%" + escapingLikeWildcards(sender) + "%")) } if let startDate { conditions.append("ZDATECREATED >= ?") @@ -203,6 +203,23 @@ extension VoicemailMessage { """ return (sql, bindings) } + + /// Escapes SQL LIKE wildcards, for use with the ESCAPE clause, so the + /// sender filter matches literal text: a_b@example.com must not match + /// axb@example.com. + private func escapingLikeWildcards(_ text: String) -> String { + var escaped = "" + for character in text { + switch character { + case "\\", "%", "_": + escaped.append("\\") + escaped.append(character) + default: + escaped.append(character) + } + } + return escaped + } } } diff --git a/CLITests/VoicemailStoreTests.swift b/CLITests/VoicemailStoreTests.swift index 27c8d2b2..a43d3ef1 100644 --- a/CLITests/VoicemailStoreTests.swift +++ b/CLITests/VoicemailStoreTests.swift @@ -249,6 +249,21 @@ final class VoicemailStoreTests: XCTestCase { 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 { From af3e511690bddd5a2695145560a52b323bc7b1fa Mon Sep 17 00:00:00 2001 From: Brad Hallett <53977268+bradhallett@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:18:22 -0400 Subject: [PATCH 3/6] Classify audio-only .MOV voicemails as carrier voicemails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the iCloud voicemail era, the daemon stores carrier voicemails as audio-only QuickTime .MOV files — the same ZFILETYPE FaceTime video messages use — so every recent voicemail listed as a faceTimeVideo with hasAudio false, and voicemail_audio_fetch rejected them. The store now inspects each .MOV asset's tracks: a sound track with no video track is a carrier voicemail, served as audio/mp4; a video track is a FaceTime video message. Rows whose asset is missing or unparseable keep the conservative faceTimeVideo default. Kind filters for the two .MOV kinds select candidates by ZFILETYPE and resolve them by asset, with the limit applied after classification. --- App/Services/VoicemailStore.swift | 128 +++++++++++++++++++++++++---- CLITests/VoicemailStoreTests.swift | 91 +++++++++++++++++++- 2 files changed, 200 insertions(+), 19 deletions(-) diff --git a/App/Services/VoicemailStore.swift b/App/Services/VoicemailStore.swift index d6c3f844..12d3fab9 100644 --- a/App/Services/VoicemailStore.swift +++ b/App/Services/VoicemailStore.swift @@ -21,14 +21,20 @@ struct VoicemailStore { func fetch(_ request: VoicemailMessage.FetchRequest) throws -> [VoicemailMessage] { var messages = try read { try $0.fetch(request) } for index in messages.indices { - messages[index].hasAudio = hasPlayableAudio(messages[index]) + 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)) } return messages } func message(id: Int) throws -> VoicemailMessage? { guard var message = try read({ try $0.message(id: id) }) else { return nil } - message.hasAudio = hasPlayableAudio(message) + resolveClassification(of: &message) return message } @@ -54,10 +60,13 @@ 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. func audioMimeType(for message: VoicemailMessage) -> String? { switch message.fileType { case "amr": return "audio/amr" case "m4a": return "audio/mp4" + case "MOV": return message.kind == .carrierVoicemail ? "audio/mp4" : nil default: return nil } } @@ -67,6 +76,23 @@ struct VoicemailStore { audioMimeType(for: message) != nil && audioURL(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), + let data = try? Data(contentsOf: url), + QuickTimeMovie.isAudioOnlyMovie(data) + { + message.kind = .carrierVoicemail + } + message.hasAudio = hasPlayableAudio(message) + } + /// 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. @@ -88,18 +114,20 @@ struct VoicemailMessage { case faceTimeAudio case faceTimeVideo - /// The `ZFILETYPE` value rows of this kind carry. - var fileType: String { + /// 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. + fileprivate var predicate: String { switch self { - case .carrierVoicemail: return "amr" - case .faceTimeAudio: return "m4a" - case .faceTimeVideo: return "MOV" + case .carrierVoicemail: return "ZFILETYPE IN ('amr', 'MOV')" + case .faceTimeAudio: return "ZFILETYPE = 'm4a'" + case .faceTimeVideo: return "ZFILETYPE = 'MOV'" } } - /// The SQL predicate that selects messages of this kind. - fileprivate var predicate: String { - return "ZFILETYPE = '" + fileType + "'" + /// Whether selecting rows of this kind requires inspecting their assets. + fileprivate var requiresAssetInspection: Bool { + self == .carrierVoicemail || self == .faceTimeVideo } /// The kind matching a `ZFILETYPE` value, for the kinds the store defines. @@ -125,9 +153,9 @@ struct VoicemailMessage { /// Whether the message's audio exists in the `Assets` tree in a format clients can play. var hasAudio = false - var kind: Kind? { - Kind(fileType: fileType) - } + /// 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? { @@ -158,6 +186,7 @@ struct VoicemailMessage { fileType = text(5) recordUUID = blob(6) hasTranscript = sqlite3_column_int(statement, 7) == 1 + kind = Kind(fileType: fileType) } } @@ -192,15 +221,20 @@ extension VoicemailMessage { if let kind { conditions.append(kind.predicate) } - bindings.append(.int(limit)) - let sql = """ + var sql = """ SELECT \(voicemailColumns) 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) } @@ -377,6 +411,68 @@ fileprivate extension Data { } } +// MARK: - + +/// Reads track information from QuickTime movie data. +/// +/// The daemon writes audio-only carrier voicemails and FaceTime video messages +/// as `.MOV` files alike; their tracks are what separate the two. +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 + 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 + } + } + } + } + } + return hasSoundTrack + } + + /// 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) + } + + /// The payload ranges of the immediate child atoms of the given type, + /// within a container atom's payload — or the whole file, at its root. + private static func childPayloads( + ofType type: String, + in data: Data, + within bounds: Range + ) -> [Range] { + var payloads: [Range] = [] + var offset = bounds.lowerBound + while offset + 8 <= bounds.upperBound { + let header = data.subdata(in: offset ..< (offset + 8)) + var size = header.prefix(4).reduce(0) { $0 << 8 | Int($1) } + if size == 0 { size = bounds.upperBound - offset } // To end of container. + guard size >= 8, offset + size <= bounds.upperBound else { break } + if header.suffix(4) == Data(type.utf8) { + payloads.append((offset + 8) ..< (offset + size)) + } + offset += size + } + return payloads + } +} + /// 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" diff --git a/CLITests/VoicemailStoreTests.swift b/CLITests/VoicemailStoreTests.swift index a43d3ef1..b5977eb0 100644 --- a/CLITests/VoicemailStoreTests.swift +++ b/CLITests/VoicemailStoreTests.swift @@ -39,6 +39,7 @@ final class VoicemailStoreTests: XCTestCase { 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? @@ -146,7 +147,7 @@ final class VoicemailStoreTests: XCTestCase { } /// Writes a fake audio file for the row into the store's `Assets` tree. - private func writeAudio(for row: Row, contents: String = "synthesized audio") throws { + 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 = @@ -156,7 +157,7 @@ final class VoicemailStoreTests: XCTestCase { at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) - try Data(contents.utf8).write(to: url) + try contents.write(to: url) } /// A transcript archive in the shape carrier voicemails use: @@ -185,6 +186,26 @@ final class VoicemailStoreTests: XCTestCase { return try NSKeyedArchiver.archivedData(withRootObject: segments, requiringSecureCoding: false) } + /// A synthetic QuickTime movie whose single track declares an audio or a + /// video handler — the structure `QuickTimeMovie` reads. + private func movieFile(hasVideoTrack: Bool) -> Data { + 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 + } + + 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 { @@ -323,7 +344,7 @@ final class VoicemailStoreTests: XCTestCase { 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: "carrier audio") + try writeAudio(for: row, contents: Data("carrier audio".utf8)) let message = try XCTUnwrap(store.message(id: 1)) XCTAssertEqual(message.hasAudio, true) @@ -360,6 +381,70 @@ final class VoicemailStoreTests: XCTestCase { 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())) + } + // MARK: - Helpers /// A fixed UUID synthesized for tests, distinct per index. From 0d3ab920ca0c6ae9e2899aa0e111df520c20cf3f Mon Sep 17 00:00:00 2001 From: Brad Hallett <53977268+bradhallett@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:18:22 -0400 Subject: [PATCH 4/6] Point the untranscribed-voicemail error at voicemail_audio_fetch A client hitting the missing-transcript error can fall back to fetching the audio; the error message now says so. --- App/Services/Voicemail.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/App/Services/Voicemail.swift b/App/Services/Voicemail.swift index aaea7d52..375ca42a 100644 --- a/App/Services/Voicemail.swift +++ b/App/Services/Voicemail.swift @@ -386,7 +386,8 @@ final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { case .notFound(let id): return "No voicemail found with identifier \(id)" case .notTranscribed(let id): - return "The voicemail with identifier \(id) has no transcript" + return + "The voicemail with identifier \(id) has no transcript; its audio is available from voicemail_audio_fetch" } } } From f87c9073707ff9d3c44576684ac2b62ac720d890 Mon Sep 17 00:00:00 2001 From: Brad Hallett <53977268+bradhallett@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:10:44 -0400 Subject: [PATCH 5/6] Harden bookmark scope, atom parsing, and 64-bit identifiers - Create the Data Store bookmark with .withSecurityScope alongside .securityScopeAllowOnlyReadAccess, so it resolves as read-only security-scoped rather than as an ordinary bookmark. - Probe .MOV assets through a memory-mapped read, so classification faults only the header pages it inspects instead of loading whole video assets. - Parse extended-size QuickTime atoms: a size of 1 marks a 16-byte header whose 64-bit size follows the type, and traversal enters or skips such atoms correctly. - Bind integer parameters with sqlite3_bind_int64 so Z_PK values above Int32.max keep resolving to their transcripts and audio. --- App/Services/Voicemail.swift | 2 +- App/Services/VoicemailStore.swift | 22 ++++++++--- CLITests/VoicemailStoreTests.swift | 62 ++++++++++++++++++++++++++---- 3 files changed, 73 insertions(+), 13 deletions(-) diff --git a/App/Services/Voicemail.swift b/App/Services/Voicemail.swift index 375ca42a..51911dec 100644 --- a/App/Services/Voicemail.swift +++ b/App/Services/Voicemail.swift @@ -457,7 +457,7 @@ final class VoicemailService: NSObject, Service, NSOpenSavePanelDelegate { private func storeBookmark(for url: URL) throws { let bookmarkData = try url.bookmarkData( - options: .securityScopeAllowOnlyReadAccess, + options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess], includingResourceValuesForKeys: nil, relativeTo: nil ) diff --git a/App/Services/VoicemailStore.swift b/App/Services/VoicemailStore.swift index 12d3fab9..2aa4fc59 100644 --- a/App/Services/VoicemailStore.swift +++ b/App/Services/VoicemailStore.swift @@ -85,7 +85,7 @@ struct VoicemailStore { private func resolveClassification(of message: inout VoicemailMessage) { if message.fileType == "MOV", let url = audioURL(for: message), - let data = try? Data(contentsOf: url), + let data = try? Data(contentsOf: url, options: .mappedIfSafe), QuickTimeMovie.isAudioOnlyMovie(data) { message.kind = .carrierVoicemail @@ -462,10 +462,22 @@ enum QuickTimeMovie { while offset + 8 <= bounds.upperBound { let header = data.subdata(in: offset ..< (offset + 8)) var size = header.prefix(4).reduce(0) { $0 << 8 | Int($1) } - if size == 0 { size = bounds.upperBound - offset } // To end of container. - guard size >= 8, offset + size <= bounds.upperBound else { break } + var headerSize = 8 + if size == 1 { + // A size of 1 marks an extended header whose 64-bit size + // follows the atom type. + guard offset + 16 <= bounds.upperBound else { break } + let extended = data.subdata(in: (offset + 8) ..< (offset + 16)) + .reduce(UInt64(0)) { $0 << 8 | UInt64($1) } + guard let exact = Int(exactly: extended) else { break } + size = exact == 0 ? bounds.upperBound - offset : exact // To end of container. + headerSize = 16 + } else if size == 0 { + size = bounds.upperBound - offset // To end of container. + } + guard size >= headerSize, offset + size <= bounds.upperBound else { break } if header.suffix(4) == Data(type.utf8) { - payloads.append((offset + 8) ..< (offset + size)) + payloads.append((offset + headerSize) ..< (offset + size)) } offset += size } @@ -601,7 +613,7 @@ private enum SQLiteValue { case .double(let double): sqlite3_bind_double(statement, index, double) case .int(let int): - sqlite3_bind_int(statement, index, Int32(clamping: int)) + sqlite3_bind_int64(statement, index, Int64(int)) } } } diff --git a/CLITests/VoicemailStoreTests.swift b/CLITests/VoicemailStoreTests.swift index b5977eb0..adc2c7bd 100644 --- a/CLITests/VoicemailStoreTests.swift +++ b/CLITests/VoicemailStoreTests.swift @@ -85,7 +85,7 @@ final class VoicemailStoreTests: XCTestCase { XCTAssertEqual(sqlite3_prepare_v2(connection, sql, -1, &statement, nil), SQLITE_OK) defer { sqlite3_finalize(statement) } - sqlite3_bind_int(statement, 1, Int32(row.id)) + 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) @@ -186,15 +186,25 @@ final class VoicemailStoreTests: XCTestCase { 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 { - 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 - } - var handler = Data(repeating: 0, count: 8) handler.append(Data((hasVideoTrack ? "vide" : "soun").utf8)) handler.append(Data(repeating: 0, count: 12)) @@ -298,6 +308,20 @@ final class VoicemailStoreTests: XCTestCase { 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()) @@ -445,6 +469,30 @@ final class VoicemailStoreTests: XCTestCase { 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)) + } + // MARK: - Helpers /// A fixed UUID synthesized for tests, distinct per index. From b093d079608645f30c4db159630056093ba4c6b8 Mon Sep 17 00:00:00 2001 From: Brad Hallett <53977268+bradhallett@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:43:03 -0400 Subject: [PATCH 6/6] Reject malformed atom sizes without integer overflow A malformed extended-size atom can declare a size up to Int.max; computing offset + size then traps instead of breaking out of the atom walk. Compare against the bytes remaining in the container so damaged assets keep the documented conservative classification. --- App/Services/VoicemailStore.swift | 4 +++- CLITests/VoicemailStoreTests.swift | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/App/Services/VoicemailStore.swift b/App/Services/VoicemailStore.swift index 2aa4fc59..b19b7fca 100644 --- a/App/Services/VoicemailStore.swift +++ b/App/Services/VoicemailStore.swift @@ -475,7 +475,9 @@ enum QuickTimeMovie { } else if size == 0 { size = bounds.upperBound - offset // To end of container. } - guard size >= headerSize, offset + size <= bounds.upperBound else { break } + // Compare against the remaining bytes — a malformed size up to + // `Int.max` would overflow an `offset + size` addition and trap. + guard size >= headerSize, size <= bounds.upperBound - offset else { break } if header.suffix(4) == Data(type.utf8) { payloads.append((offset + headerSize) ..< (offset + size)) } diff --git a/CLITests/VoicemailStoreTests.swift b/CLITests/VoicemailStoreTests.swift index adc2c7bd..ea239f61 100644 --- a/CLITests/VoicemailStoreTests.swift +++ b/CLITests/VoicemailStoreTests.swift @@ -493,6 +493,17 @@ final class VoicemailStoreTests: XCTestCase { 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.