├── .gitignore ├── Sources ├── TypedStream │ ├── Class.swift │ ├── Object.swift │ ├── Type.swift │ ├── Archivable.swift │ └── TypedStreamDecoder.swift └── iMessage │ ├── Message.swift │ ├── Chat.swift │ ├── Extensions │ ├── Date+Extensions.swift │ └── Data+Extensions.swift │ ├── GUID.swift │ ├── Account.swift │ └── Database.swift ├── Tests └── iMessageTests │ ├── Helpers │ ├── Database+Extensions.swift │ └── Fixtures.swift │ ├── DecodingTests.swift │ └── DatabaseTests.swift ├── .github └── workflows │ └── ci.yml ├── LICENSE.md ├── Package.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Sources/TypedStream/Class.swift: -------------------------------------------------------------------------------- 1 | /// Represents a class stored in the `typedstream` 2 | public struct Class: Hashable, Sendable { 3 | /// The name of the class 4 | public let name: String 5 | /// The encoded version of the class 6 | public let version: UInt64 7 | 8 | public init(name: String, version: UInt64) { 9 | self.name = name 10 | self.version = version 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/iMessageTests/Helpers/Database+Extensions.swift: -------------------------------------------------------------------------------- 1 | import SQLite3 2 | @testable import iMessage 3 | 4 | extension Database { 5 | func execute(_ sql: String) throws { 6 | var error: UnsafeMutablePointer? 7 | if sqlite3_exec(self.db, sql, nil, nil, &error) != SQLITE_OK { 8 | let message = String(cString: error!) 9 | sqlite3_free(error) 10 | throw Database.Error.queryError(message) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/iMessage/Message.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Message: Identifiable, Hashable, Codable, Sendable { 4 | public let id: GUID 5 | public let text: String 6 | public let date: Date 7 | public let isFromMe: Bool 8 | public let sender: Account.Handle? 9 | } 10 | 11 | // MARK: - Comparable 12 | 13 | extension Message: Comparable { 14 | public static func < (lhs: Message, rhs: Message) -> Bool { 15 | return (lhs.date, lhs.id) < (rhs.date, rhs.id) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/iMessage/Chat.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Chat: Identifiable, Hashable, Codable, Sendable { 4 | public var id: GUID 5 | public let displayName: String? 6 | public let participants: [Account.Handle] 7 | public let lastMessageDate: Date? 8 | } 9 | 10 | // MARK: - Comparable 11 | 12 | extension Chat: Comparable { 13 | public static func < (lhs: Chat, rhs: Chat) -> Bool { 14 | return lhs.lastMessageDate ?? .distantPast < rhs.lastMessageDate ?? .distantPast 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/iMessage/Extensions/Date+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private let nsecPerSec: Int64 = 1_000_000_000 4 | 5 | extension Date { 6 | init(nanosecondsSinceReferenceDate ns: Int64) { 7 | self.init(timeIntervalSinceReferenceDate: TimeInterval(Double(ns) / Double(nsecPerSec))) 8 | } 9 | 10 | var nanosecondsSinceReferenceDate: Int64? { 11 | let seconds = timeIntervalSinceReferenceDate 12 | let nanoseconds = seconds * Double(nsecPerSec) 13 | return Int64(exactly: nanoseconds) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | test: 11 | runs-on: macos-latest 12 | 13 | strategy: 14 | matrix: 15 | swift-version: 16 | - ^6 17 | 18 | name: Build and Test (Swift ${{ matrix.swift-version }}) 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: swift-actions/setup-swift@v2 23 | with: 24 | swift-version: ${{ matrix.swift-version }} 25 | - name: Build 26 | run: swift build -v 27 | - name: Run tests 28 | run: swift test -v 29 | -------------------------------------------------------------------------------- /Sources/iMessage/Extensions/Data+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Data { 4 | init?(hexString: String) { 5 | let string = hexString.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() 6 | var data = Data(capacity: string.count / 2) 7 | 8 | var index = string.startIndex 9 | while index < string.endIndex { 10 | let nextIndex = string.index(index, offsetBy: 2) 11 | guard nextIndex <= string.endIndex, 12 | let byte = UInt8(string[index.. Bool { 13 | return lhs.rawValue < rhs.rawValue 14 | } 15 | } 16 | 17 | // MARK: - Codable 18 | 19 | extension GUID: Codable { 20 | public init(from decoder: Decoder) throws { 21 | let container = try decoder.singleValueContainer() 22 | rawValue = try container.decode(String.self) 23 | } 24 | 25 | public func encode(to encoder: Encoder) throws { 26 | var container = encoder.singleValueContainer() 27 | try container.encode(rawValue) 28 | } 29 | } 30 | 31 | // MARK: - CustomStringConvertible 32 | 33 | extension GUID: ExpressibleByStringLiteral { 34 | public init(stringLiteral value: StringLiteralType) { 35 | self.init(rawValue: value) 36 | } 37 | } 38 | 39 | // MARK: - CustomStringConvertible 40 | 41 | extension GUID: CustomStringConvertible { 42 | public var description: String { 43 | return rawValue 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/iMessage/Account.swift: -------------------------------------------------------------------------------- 1 | public struct Account: Identifiable, Hashable, Codable, Sendable { 2 | public struct Handle: RawRepresentable, Hashable, Sendable { 3 | public let rawValue: String 4 | 5 | public init(rawValue: String) { 6 | self.rawValue = rawValue 7 | } 8 | } 9 | 10 | public let id: Handle 11 | 12 | public enum Service: String, CaseIterable, Hashable, Codable, Sendable { 13 | case iMessage = "iMessage" 14 | case sms = "SMS" 15 | } 16 | 17 | public let service: Service? 18 | } 19 | 20 | // MARK: - RawRepresentable 21 | 22 | extension Account.Service: RawRepresentable { 23 | public init(rawValue: String) { 24 | switch rawValue.lowercased() { 25 | case "imessage": self = .iMessage 26 | default: self = .sms 27 | } 28 | } 29 | } 30 | 31 | // MARK: - Codable 32 | 33 | extension Account.Handle: Codable { 34 | public init(from decoder: Decoder) throws { 35 | let container = try decoder.singleValueContainer() 36 | rawValue = try container.decode(String.self) 37 | } 38 | 39 | public func encode(to encoder: Encoder) throws { 40 | var container = encoder.singleValueContainer() 41 | try container.encode(rawValue) 42 | } 43 | } 44 | 45 | // MARK: - CustomStringConvertible 46 | 47 | extension Account.Handle: CustomStringConvertible { 48 | public var description: String { 49 | return rawValue 50 | } 51 | } 52 | 53 | // MARK: - ExpressibleByStringLiteral 54 | 55 | extension Account.Handle: ExpressibleByStringLiteral { 56 | public init(stringLiteral value: StringLiteralType) { 57 | self.init(rawValue: value) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Tests/iMessageTests/DecodingTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import iMessage 5 | @testable import TypedStream 6 | 7 | @Suite 8 | struct DecodingTests { 9 | @Test 10 | func testDecode() throws { 11 | let data = Data(hexString: Fixtures.attributedBody)! 12 | #expect(data.count == 232) 13 | 14 | let hexString = Array(data.prefix(16)).map { String(format: "%02X", $0) }.joined() 15 | #expect(hexString == "040B73747265616D747970656481E803") 16 | 17 | let decoded = try TypedStreamDecoder.decode(data) 18 | 19 | #expect(decoded.count == 6) 20 | 21 | // First element: NSString with "Hello" 22 | if case let .object(classInfo, data) = decoded[0] { 23 | #expect(classInfo.name == "NSString") 24 | #expect(classInfo.version == 1) 25 | #expect(data.count == 1) 26 | if case let .string(text) = data[0] { 27 | #expect(text == "Hello") 28 | } 29 | } 30 | 31 | // Second element: Data with [1, 9] 32 | if case let .data(data) = decoded[1] { 33 | #expect(data.count == 2) 34 | if case let .signedInteger(i1) = data[0] { 35 | #expect(i1 == 1) 36 | } 37 | if case let .unsignedInteger(i2) = data[1] { 38 | #expect(i2 == 9) 39 | } 40 | } 41 | 42 | // Third element: NSDictionary 43 | if case let .object(classInfo, data) = decoded[2] { 44 | #expect(classInfo.name == "NSDictionary") 45 | #expect(classInfo.version == 0) 46 | #expect(data.count == 1) 47 | if case let .signedInteger(i) = data[0] { 48 | #expect(i == 1) 49 | } 50 | } 51 | 52 | // Fourth element: NSNumber with -1 53 | if case let .object(classInfo, data) = decoded[3] { 54 | #expect(classInfo.name == "NSNumber") 55 | #expect(classInfo.version == 0) 56 | #expect(data.count == 1) 57 | if case let .signedInteger(i) = data[0] { 58 | #expect(i == -1) 59 | } 60 | } 61 | 62 | // Fifth element: NSString with "__kIMMessagePartAttributeName" 63 | if case let .object(classInfo, data) = decoded[4] { 64 | #expect(classInfo.name == "NSString") 65 | #expect(classInfo.version == 1) 66 | #expect(data.count == 1) 67 | if case let .string(text) = data[0] { 68 | #expect(text == "__kIMMessagePartAttributeName") 69 | } 70 | } 71 | 72 | // Sixth element: NSNumber with 0 73 | if case let .object(classInfo, data) = decoded[5] { 74 | #expect(classInfo.name == "NSNumber") 75 | #expect(classInfo.version == 0) 76 | #expect(data.count == 1) 77 | if case let .signedInteger(i) = data[0] { 78 | #expect(i == 0) 79 | } 80 | } 81 | } 82 | 83 | @Test 84 | func testExtractText() throws { 85 | let data = Data(hexString: Fixtures.attributedBody)! 86 | 87 | let decoded = try TypedStreamDecoder.decode(data) 88 | let text = decoded.compactMap { $0.stringValue }.filter { !$0.isEmpty } 89 | #expect(text.count == 1) 90 | #expect(text.contains("Hello")) 91 | #expect(!text.contains("NSString")) 92 | #expect(!text.contains("NSAttributedString")) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Sources/TypedStream/Type.swift: -------------------------------------------------------------------------------- 1 | /// Represents primitive types of data that can be stored in a `typedstream` 2 | /// 3 | /// These type encodings are partially documented [here](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html) by Apple. 4 | public enum Type: Hashable, Sendable { 5 | /// Encoded string data, usually embedded in an object. Denoted by: 6 | /// 7 | /// | Hex | UTF-8 | 8 | /// |--------|-------| 9 | /// | `0x28` | `+` | 10 | case utf8String 11 | /// Encoded bytes that can be parsed again as data. Denoted by: 12 | /// 13 | /// | Hex | UTF-8 | 14 | /// |--------|-------| 15 | /// | `0x2A` | `*` | 16 | case embeddedData 17 | /// An instance of a class, usually with data. Denoted by: 18 | /// 19 | /// | Hex | UTF-8 | 20 | /// |--------|-------| 21 | /// | `0x40` | `@` | 22 | case object 23 | /// A signed integer type. Denoted by: 24 | /// 25 | /// | Hex | UTF-8 | 26 | /// |--------|-------| 27 | /// | `0x63` | `c` | 28 | /// | `0x69` | `i` | 29 | /// | `0x6C` | `l` | 30 | /// | `0x71` | `q` | 31 | /// | `0x73` | `s` | 32 | case signedInt 33 | /// An unsigned integer type. Denoted by: 34 | /// 35 | /// | Hex | UTF-8 | 36 | /// |--------|-------| 37 | /// | `0x43` | `C` | 38 | /// | `0x49` | `I` | 39 | /// | `0x4C` | `L` | 40 | /// | `0x51` | `Q` | 41 | /// | `0x53` | `S` | 42 | case unsignedInt 43 | /// A `Float` value. Denoted by: 44 | /// 45 | /// | Hex | UTF-8 | 46 | /// |--------|-------| 47 | /// | `0x66` | `f` | 48 | case float 49 | /// A `Double` value. Denoted by: 50 | /// 51 | /// | Hex | UTF-8 | 52 | /// |--------|-------| 53 | /// | `0x64` | `d` | 54 | case double 55 | /// Some text we can reuse later, e.g., a class name. 56 | case string(String) 57 | /// An array containing some data of a given length. Denoted by braced digits: `[123]`. 58 | case array(Int) 59 | /// Data for which we do not know the type. 60 | case unknown(UInt8) 61 | 62 | static func fromByte(_ byte: UInt8) -> Type { 63 | switch byte { 64 | case 0x40: 65 | return .object 66 | case 0x2B: 67 | return .utf8String 68 | case 0x2A: 69 | return .embeddedData 70 | case 0x66: 71 | return .float 72 | case 0x64: 73 | return .double 74 | case 0x63, 0x69, 0x6C, 0x71, 0x73: 75 | return .signedInt 76 | case 0x43, 0x49, 0x4C, 0x51, 0x53: 77 | return .unsignedInt 78 | default: 79 | return .unknown(byte) 80 | } 81 | } 82 | 83 | static func newString(_ string: String) -> Type { 84 | return .string(string) 85 | } 86 | 87 | static func getArrayLength(types: [UInt8]) -> ([Type], Int)? { 88 | guard let first = types.first, first == 0x5B else { // '[' character 89 | return nil 90 | } 91 | var length = 0 92 | var index = 1 93 | while index < types.count, 94 | let digit = UInt8(exactly: types[index]), 95 | (48...57).contains(digit) 96 | { 97 | length = length * 10 + Int(digit - 48) // ASCII '0' is 48 98 | index += 1 99 | } 100 | if length > 0 { 101 | return ([.array(length)], index) 102 | } else { 103 | return nil 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/TypedStream/Archivable.swift: -------------------------------------------------------------------------------- 1 | /// Types of data that can be archived into the `typedstream` 2 | public enum Archivable: Hashable, Sendable { 3 | /// An instance of a class that may contain some embedded data. 4 | /// `typedstream` data doesn't include property names, so data is stored in order of appearance. 5 | case object(Class, [Object]) 6 | /// Data that is likely a property on the object described by the `typedstream` but not part of a class. 7 | case data([Object]) 8 | /// A class referenced in the `typedstream`, usually part of an inheritance hierarchy that does not contain any data itself. 9 | case `class`(Class) 10 | /// A placeholder, used when reserving a spot in the objects table for a reference to be filled with class information. 11 | case placeholder 12 | /// A type that made it through the parsing process without getting replaced by an object. 13 | case type([Type]) 14 | 15 | // MARK: - Convenience Properties 16 | 17 | /** 18 | If this archivable represents an `NSString` or `NSMutableString` object, 19 | returns its string value. 20 | 21 | ### Example 22 | ```swift 23 | let nsstring = Archivable.object( 24 | Class(name: "NSString", version: 1), 25 | [.string("Hello world")] 26 | ) 27 | print(nsstring.stringValue) // Optional("Hello world") 28 | 29 | let notNSString = Archivable.object( 30 | Class(name: "NSNumber", version: 1), 31 | [.signedInteger(100)] 32 | ) 33 | print(notNSString.stringValue) // nil 34 | ``` 35 | */ 36 | public var stringValue: String? { 37 | if case let .object(classInfo, value) = self, 38 | classInfo.name == "NSString" || classInfo.name == "NSMutableString", 39 | let first = value.first, 40 | case let .string(text) = first 41 | { 42 | // Filter out strings that look like attribute keys or metadata 43 | if text.hasPrefix("__k") // System keys often start with __k 44 | || text.contains("Attribute") // Attribute names 45 | || text.contains("NS") // Foundation framework keys 46 | || !text.contains(where: { $0.isLetter || $0.isNumber }) // Strings with no letters or numbers are likely metadata 47 | { 48 | return nil 49 | } 50 | 51 | return text 52 | } 53 | return nil 54 | } 55 | 56 | /** 57 | If this archivable represents an `NSNumber` object containing an integer, 58 | returns its 64-bit integer value. 59 | 60 | ### Example 61 | ```swift 62 | let nsnumber = Archivable.object( 63 | Class(name: "NSNumber", version: 1), 64 | [.signedInteger(100)] 65 | ) 66 | print(nsnumber.integerValue) // Optional(100) 67 | 68 | let notNSNumber = Archivable.object( 69 | Class(name: "NSString", version: 1), 70 | [.string("Hello world")] 71 | ) 72 | print(notNSNumber.integerValue) // nil 73 | ``` 74 | */ 75 | public var integerValue: Int64? { 76 | if case let .object(classInfo, value) = self, 77 | classInfo.name == "NSNumber", 78 | let first = value.first, 79 | case let .signedInteger(num) = first 80 | { 81 | return num 82 | } 83 | return nil 84 | } 85 | 86 | /** 87 | If this archivable represents an `NSNumber` object containing a floating-point value, 88 | returns its double-precision value. 89 | 90 | ### Example 91 | ```swift 92 | let nsnumber = Archivable.object( 93 | Class(name: "NSNumber", version: 1), 94 | [.double(100.001)] 95 | ) 96 | print(nsnumber.doubleValue) // Optional(100.001) 97 | 98 | let notNSNumber = Archivable.object( 99 | Class(name: "NSString", version: 1), 100 | [.string("Hello world")] 101 | ) 102 | print(notNSNumber.doubleValue) // nil 103 | ``` 104 | */ 105 | public var doubleValue: Double? { 106 | if case let .object(classInfo, value) = self, 107 | classInfo.name == "NSNumber", 108 | let first = value.first, 109 | case let .double(num) = first 110 | { 111 | return num 112 | } 113 | return nil 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Madrid 2 | 3 | Madrid is a Swift package that provides read-only access to 4 | your iMessage® `chat.db` database. 5 | 6 | It comprises the following two modules: 7 | 8 | - **iMessage**: 9 | Core functionality for querying an iMessage database. 10 | - **TypedStream**: 11 | A Swift implementation for decoding Apple's `typedstream` format, 12 | adapted from [Christopher Sardegna's work](https://chrissardegna.com/blog/reverse-engineering-apples-typedstream-format/) on 13 | [imessage-exporter](https://github.com/ReagentX/imessage-exporter). 14 | 15 | ## Requirements 16 | 17 | - Xcode 16+ 18 | - Swift 6.0+ 19 | - macOS 13.0+ 20 | 21 | ## Installation 22 | 23 | ### Swift Package Manager 24 | 25 | Add Madrid as a dependency to your `Package.swift`: 26 | 27 | ```swift 28 | dependencies: [ 29 | .package(url: "https://github.com/mattt/Madrid.git", from: "0.2.0") 30 | ] 31 | ``` 32 | 33 | Then add the modules you need to your target's dependencies: 34 | 35 | ```swift 36 | targets: [ 37 | .target( 38 | name: "YourTarget", 39 | dependencies: [ 40 | .product(name: "iMessage", package: "Madrid"), 41 | .product(name: "TypedStream", package: "Madrid") 42 | ] 43 | ) 44 | ] 45 | ``` 46 | 47 | ## Usage 48 | 49 | ### Fetching Messages 50 | 51 | ```swift 52 | import iMessage 53 | 54 | // Create a database (uses `~/Library/Messages/chat.db` by default) 55 | let db = try iMessage.Database() 56 | 57 | // Fetch recent messages 58 | let recentMessages = try db.fetchMessages(limit: 10) 59 | 60 | // Fetch messages from select individuals in time range 61 | let pastWeek = Date.now.addingTimeInterval(-7*24*60*60)../dev/null || true 122 | ``` 123 | 124 | **Always include the `-shm` and `-wal` files when copying a SQLite database using WAL mode** 125 | 126 | 3. **Use the Copied Database:** 127 | ```swift 128 | let homeURL = FileManager.default.homeDirectoryForCurrentUser 129 | let dbURL = homeURL.appendingPathComponent("imessage_db_copy/chat.db") 130 | let db = try iMessage.Database(path: dbURL.path) 131 | ``` 132 | 133 | ## Acknowledgments 134 | 135 | - [Christopher Sardegna](https://chrissardegna.com) 136 | ([@ReagentX](https://github.com/ReagentX)) 137 | for reverse-engineering the `typedstream` format. 138 | 139 | ## Legal 140 | 141 | iMessage® is a registered trademark of Apple Inc. 142 | This project is not affiliated with, endorsed, or sponsored by Apple Inc. 143 | 144 | ## License 145 | 146 | This project is available under the MIT license. 147 | See the LICENSE file for more info. 148 | -------------------------------------------------------------------------------- /Tests/iMessageTests/DatabaseTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SQLite3 3 | import Testing 4 | 5 | @testable import iMessage 6 | @testable import TypedStream 7 | 8 | @Suite(.serialized) 9 | struct DatabaseTests { 10 | var db: Database = Fixtures.testDatabase 11 | 12 | @Test 13 | func testDatabaseNotFound() async throws { 14 | do { 15 | _ = try Database(path: "/nonexistent/path") 16 | Issue.record("Should have thrown an error") 17 | } catch Database.Error.databaseNotFound { 18 | // Expected error 19 | } 20 | } 21 | 22 | @Test 23 | func testFetchChats() async throws { 24 | // Test basic fetch - should get both chats ordered by last message 25 | let chats = try db.fetchChats(limit: 10) 26 | #expect(chats.count == 2) 27 | #expect(chats[0].id.rawValue == "chat-guid-1") // Most recent chat first 28 | #expect(chats[1].id.rawValue == "chat-guid-2") // Older chat second 29 | 30 | // Test with date filter - should only get chat with recent messages 31 | let now = Date() 32 | let yesterday = now.addingTimeInterval(-86400) 33 | let filtered = try db.fetchChats(in: yesterday.. = [ 46 | "+1234567890", 47 | "person@example.com", 48 | ] 49 | let filteredChats = try db.fetchChats(with: participants) 50 | #expect(filteredChats.count == 1) 51 | #expect(filteredChats[0].id.rawValue == "chat-guid-1") 52 | 53 | // Test partial participant match - should not return chat-guid-1 54 | let partialMatch = try db.fetchChats(with: ["+1234567890", "third@example.com"]) 55 | #expect(partialMatch.count == 1) 56 | #expect(partialMatch[0].id.rawValue == "chat-guid-2") 57 | 58 | // Test with non-matching participants 59 | let nonMatching = try db.fetchChats(with: [ 60 | "nonexistent@example.com" 61 | ]) 62 | #expect(nonMatching.isEmpty) 63 | } 64 | 65 | @Test 66 | func testFetchMessages() async throws { 67 | let chatId: Chat.ID = "chat-guid-1" 68 | 69 | // Test fetch by chat 70 | let chatMessages = try db.fetchMessages(for: chatId, limit: 10).sorted() 71 | #expect(chatMessages.count == 3) 72 | 73 | #expect(chatMessages[0].id.rawValue == "msg-guid-1") 74 | #expect(chatMessages[0].text == "Hello!") 75 | 76 | #expect(chatMessages[1].id.rawValue == "msg-guid-2") 77 | #expect(chatMessages[1].text == "Hi there") 78 | 79 | #expect(chatMessages[2].id.rawValue == "msg-guid-3") 80 | #expect(chatMessages[2].text == "Hello") 81 | 82 | // Test fetch by participants 83 | let participants: Set = [ 84 | "+1234567890", 85 | "person@example.com", 86 | ] 87 | let participantMessages = try db.fetchMessages(with: participants) 88 | #expect(participantMessages.count == 3) 89 | #expect(participantMessages.contains { $0.sender?.rawValue == "+1234567890" }) 90 | #expect(participantMessages.contains { $0.sender?.rawValue == "person@example.com" }) 91 | 92 | // Test with date range 93 | let yesterday = Date().addingTimeInterval(-86400) 94 | let today = Date() 95 | let rangeMessages = try db.fetchMessages( 96 | for: chatId, 97 | in: yesterday.. NSObject 18 | "124E5341747472696275746564537472696E6700", // "NSAttributedString" (18 bytes) 19 | "8484084E534F626A65637400", // "NSObject" (8 bytes) 20 | 21 | // String content 22 | "8592", // String start marker 23 | "848484", // String markers 24 | "084E53537472696E6701", // "NSString" (8 bytes) 25 | "948401", // String content marker 26 | "2B", // UTF-8 string type 27 | "05", // Length of string (5 bytes) 28 | "48656C6C6F", // "Hello" in hex 29 | 30 | // Attributes dictionary 31 | "868402", // Dictionary start marker 32 | "694901", // Dictionary count (1) 33 | "0992", // Dictionary content marker 34 | "848484", // Dictionary markers 35 | "0C4E5344696374696F6E61727900", // "NSDictionary" (12 bytes) 36 | "948401", // Dictionary content marker 37 | "6901", // Dictionary count (1) 38 | "9292", // Dictionary entry markers 39 | "965F5F6B494D4261736557726974696E67446972656374696F6E4174747269627574654E616D65", // "__kIMBaseWritingDirectionAttributeName" 40 | "8692", // Attribute value marker 41 | "848484", // Value markers 42 | "084E534E756D62657200", // "NSNumber" (8 bytes) 43 | "8484074E5356616C756500", // "NSValue" (7 bytes) 44 | "948401", // Value content marker 45 | "2A", // Value type 46 | "848401", // Value content marker 47 | "719DFF", // Value data 48 | "8692", // Next attribute marker 49 | "849696", // Attribute markers 50 | "1D5F5F6B494D4D657373616765506172744174747269627574654E616D65", // "__kIMMessagePartAttributeName" 51 | "8692", // Attribute value marker 52 | "849B9C9D9D00", // Attribute value data 53 | "868686", // End markers 54 | ].joined() 55 | 56 | static var testDatabase: Database { 57 | let db = try! Database.inMemory() 58 | 59 | // Create schema 60 | try! db.execute( 61 | """ 62 | CREATE TABLE chat ( 63 | ROWID INTEGER PRIMARY KEY, 64 | guid TEXT UNIQUE NOT NULL, 65 | display_name TEXT, 66 | service_name TEXT 67 | ); 68 | 69 | CREATE TABLE handle ( 70 | ROWID INTEGER PRIMARY KEY, 71 | id TEXT NOT NULL, 72 | service TEXT 73 | ); 74 | 75 | CREATE TABLE message ( 76 | ROWID INTEGER PRIMARY KEY, 77 | guid TEXT UNIQUE NOT NULL, 78 | text TEXT, 79 | attributedBody BLOB, 80 | handle_id INTEGER REFERENCES handle(ROWID), 81 | date REAL, 82 | is_from_me INTEGER, 83 | service TEXT 84 | ); 85 | 86 | CREATE TABLE chat_handle_join ( 87 | chat_id INTEGER REFERENCES chat(ROWID), 88 | handle_id INTEGER REFERENCES handle(ROWID) 89 | ); 90 | 91 | CREATE TABLE chat_message_join ( 92 | chat_id INTEGER REFERENCES chat(ROWID), 93 | message_id INTEGER REFERENCES message(ROWID) 94 | ); 95 | """) 96 | 97 | // Insert sample chats 98 | try! db.execute( 99 | """ 100 | INSERT INTO chat (ROWID, guid, display_name, service_name) 101 | VALUES 102 | (1, 'chat-guid-1', 'Sample Group', 'iMessage'), 103 | (2, 'chat-guid-2', 'Another Group', 'iMessage'); 104 | """) 105 | 106 | // Insert sample handles (participants) 107 | try! db.execute( 108 | """ 109 | INSERT INTO handle (ROWID, id, service) 110 | VALUES 111 | (1, '+1234567890', 'iMessage'), 112 | (2, 'person@example.com', 'iMessage'), 113 | (3, 'third@example.com', 'iMessage'); 114 | """) 115 | 116 | // Link handles to chats - chat 2 shares one participant with chat 1 117 | try! db.execute( 118 | """ 119 | INSERT INTO chat_handle_join (chat_id, handle_id) 120 | VALUES 121 | (1, 1), (1, 2), -- First chat with two participants 122 | (2, 1), (2, 3); -- Second chat with overlapping participant 123 | """) 124 | 125 | // Insert sample messages with different dates 126 | let now = Date() 127 | let oneHourAgo = now.addingTimeInterval(-3600) 128 | let thirtyMinutesAgo = now.addingTimeInterval(-1800) 129 | let twoDaysAgo = now.addingTimeInterval(-86400 * 2) 130 | let twoDaysAndOneHourAgo = twoDaysAgo.addingTimeInterval(3600) 131 | 132 | try! db.execute( 133 | """ 134 | INSERT INTO message (ROWID, guid, text, attributedBody, handle_id, date, is_from_me, service) 135 | VALUES 136 | -- Messages for first chat 137 | (1, 'msg-guid-1', 'Hello!', NULL, 1, \(oneHourAgo.nanosecondsSinceReferenceDate ?? 0), 0, 'iMessage'), 138 | (2, 'msg-guid-2', 'Hi there', NULL, NULL, \(thirtyMinutesAgo.nanosecondsSinceReferenceDate ?? 0), 1, 'iMessage'), 139 | (3, 'msg-guid-3', NULL, X'\(Fixtures.attributedBody)', 2, \(now.nanosecondsSinceReferenceDate ?? 0), 0, 'iMessage'), 140 | -- Messages for second chat (older) 141 | (4, 'msg-guid-4', 'Old message', NULL, 1, \(twoDaysAgo.nanosecondsSinceReferenceDate ?? 0), 0, 'iMessage'), 142 | (5, 'msg-guid-5', 'Another old one', NULL, 3, \(twoDaysAndOneHourAgo.nanosecondsSinceReferenceDate ?? 0), 0, 'iMessage'); 143 | """) 144 | 145 | // Link messages to chats 146 | try! db.execute( 147 | """ 148 | INSERT INTO chat_message_join (chat_id, message_id) 149 | VALUES 150 | -- First chat messages (recent) 151 | (1, 1), (1, 2), (1, 3), 152 | -- Second chat messages (older) 153 | (2, 4), (2, 5); 154 | """) 155 | 156 | return db 157 | } 158 | } 159 | 160 | private func execute(db: OpaquePointer, _ sql: String) throws { 161 | var error: UnsafeMutablePointer? 162 | if sqlite3_exec(db, sql, nil, nil, &error) != SQLITE_OK { 163 | let message = String(cString: error!) 164 | sqlite3_free(error) 165 | throw Database.Error.queryError(message) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /Sources/iMessage/Database.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SQLite3 3 | import TypedStream 4 | 5 | private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) 6 | 7 | public final class Database { 8 | var db: OpaquePointer? 9 | 10 | public struct Flags: OptionSet, Sendable, Hashable { 11 | public let rawValue: Int32 12 | 13 | public init(rawValue: Int32) { 14 | self.rawValue = rawValue 15 | } 16 | 17 | /// Opens the database in read-only mode 18 | public static let readOnly = Flags(rawValue: SQLITE_OPEN_READONLY) 19 | 20 | /// Opens the database in read-write mode 21 | public static let readWrite = Flags(rawValue: SQLITE_OPEN_READWRITE) 22 | 23 | /// Creates the database if it does not exist 24 | public static let create = Flags(rawValue: SQLITE_OPEN_CREATE) 25 | 26 | /// Enables URI filename interpretation 27 | public static let uri = Flags(rawValue: SQLITE_OPEN_URI) 28 | 29 | /// Opens the database in shared cache mode 30 | public static let sharedCache = Flags(rawValue: SQLITE_OPEN_SHAREDCACHE) 31 | 32 | /// Opens the database in private cache mode 33 | public static let privateCache = Flags(rawValue: SQLITE_OPEN_PRIVATECACHE) 34 | 35 | /// Opens the database without mutex checking 36 | public static let noMutex = Flags(rawValue: SQLITE_OPEN_NOMUTEX) 37 | 38 | /// Opens the database with full mutex checking 39 | public static let fullMutex = Flags(rawValue: SQLITE_OPEN_FULLMUTEX) 40 | 41 | /// Common flag combinations 42 | public static let `default`: Flags = [.readOnly, .uri] 43 | } 44 | 45 | public enum Error: Swift.Error { 46 | case databaseNotFound 47 | case failedToOpen(String) 48 | case queryError(String) 49 | } 50 | 51 | private init( 52 | _ filename: String, 53 | flags: Flags = .default 54 | ) throws { 55 | if sqlite3_open_v2(filename, &db, flags.rawValue, nil) != SQLITE_OK { 56 | throw Error.failedToOpen(String(cString: sqlite3_errmsg(db))) 57 | } 58 | } 59 | 60 | public convenience init(path: String? = nil) throws { 61 | let resolvedPath: String 62 | if let path = path { 63 | resolvedPath = path 64 | } else { 65 | resolvedPath = "/Users/\(NSUserName())/Library/Messages/chat.db" 66 | } 67 | 68 | guard FileManager.default.fileExists(atPath: resolvedPath) else { 69 | throw Error.databaseNotFound 70 | } 71 | 72 | let dbURI = "file:\(resolvedPath)?immutable=1&mode=ro" 73 | try self.init(dbURI, flags: [.readOnly, .uri]) 74 | } 75 | 76 | public static func inMemory() throws -> Database { 77 | return try Database(":memory:", flags: [.readWrite, .create]) 78 | } 79 | 80 | deinit { 81 | sqlite3_close(db) 82 | } 83 | 84 | // Remove transaction from execute 85 | private func execute( 86 | _ query: String, 87 | parameters: [any Bindable] = [], 88 | transform: (OpaquePointer) throws -> T? 89 | ) throws -> [T] { 90 | var statement: OpaquePointer? 91 | guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK, 92 | let statement = statement 93 | else { 94 | throw Error.queryError(String(cString: sqlite3_errmsg(db))) 95 | } 96 | defer { sqlite3_finalize(statement) } 97 | 98 | // Bind all parameters 99 | for (index, value) in parameters.enumerated() { 100 | value.bind(to: statement, at: Int32(index + 1)) 101 | } 102 | 103 | var results: [T] = [] 104 | while sqlite3_step(statement) == SQLITE_ROW { 105 | if let result = try transform(statement) { 106 | results.append(result) 107 | } 108 | } 109 | 110 | return results 111 | } 112 | 113 | public func fetchChats( 114 | with participantHandles: Set? = nil, 115 | in dateRange: Range? = nil, 116 | limit: Int = 100 117 | ) throws -> [Chat] { 118 | try withTransaction { 119 | var conditions: [String] = [] 120 | var parameters: [any Bindable] = [] 121 | 122 | // Add date range if specified 123 | if let dateRange = dateRange { 124 | if let upperBound = dateRange.upperBound.nanosecondsSinceReferenceDate { 125 | conditions.append("m.date < ?") 126 | parameters.append(Int64(upperBound)) 127 | } 128 | if let lowerBound = dateRange.lowerBound.nanosecondsSinceReferenceDate { 129 | conditions.append("m.date >= ?") 130 | parameters.append(Int64(lowerBound)) 131 | } 132 | } 133 | 134 | // Add participants filter if specified 135 | if let handles = participantHandles, !handles.isEmpty { 136 | conditions.append( 137 | """ 138 | c.ROWID IN ( 139 | SELECT chat_id 140 | FROM chat_handle_join chj 141 | JOIN handle h ON chj.handle_id = h.ROWID 142 | WHERE h.id IN (\(String(repeating: "?,", count: handles.count).dropLast())) 143 | GROUP BY chat_id 144 | HAVING COUNT(DISTINCT handle_id) = ? 145 | ) 146 | """) 147 | 148 | // Add each participant as a value 149 | handles.forEach { handle in 150 | parameters.append(handle.rawValue) 151 | } 152 | // Add the count of participants 153 | parameters.append(Int32(handles.count)) 154 | } 155 | 156 | let query = """ 157 | SELECT 158 | c.guid, 159 | c.display_name, 160 | c.service_name, 161 | MAX(m.date) as last_message_date 162 | FROM chat c 163 | LEFT JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id 164 | LEFT JOIN message m ON cmj.message_id = m.ROWID 165 | \(conditions.isEmpty ? "" : "WHERE \(conditions.joined(separator: " AND "))") 166 | GROUP BY c.ROWID 167 | ORDER BY last_message_date DESC 168 | LIMIT ? 169 | """ 170 | 171 | parameters.append(Int32(limit)) 172 | 173 | return try execute(query, parameters: parameters) { statement in 174 | // Safely handle potentially null columns 175 | guard let guidText = sqlite3_column_text(statement, 0) else { return nil } 176 | let chatId = Chat.ID(rawValue: String(cString: guidText)) 177 | 178 | let displayName = sqlite3_column_text(statement, 1).map { String(cString: $0) } 179 | let lastMessageDate = Date( 180 | nanosecondsSinceReferenceDate: sqlite3_column_int64(statement, 3)) 181 | 182 | // Fetch participants for this chat 183 | let participants = try fetchParticipants(for: chatId) 184 | 185 | return Chat( 186 | id: chatId, 187 | displayName: displayName, 188 | participants: participants, 189 | lastMessageDate: lastMessageDate 190 | ) 191 | } 192 | } 193 | } 194 | 195 | public func fetchMessages( 196 | for chatId: Chat.ID? = nil, 197 | with participantHandles: Set? = nil, 198 | in dateRange: Range? = nil, 199 | limit: Int = 100 200 | ) throws -> [Message] { 201 | try withTransaction { 202 | var conditions: [String] = [] 203 | var parameters: [any Bindable] = [] 204 | 205 | // Add chat filter if specified 206 | if let chatId = chatId { 207 | conditions.append("c.guid = ?") 208 | parameters.append(chatId.rawValue) 209 | } 210 | 211 | // Add participants filter if specified 212 | if let handles = participantHandles, !handles.isEmpty { 213 | conditions.append( 214 | """ 215 | m.ROWID IN ( 216 | SELECT m.ROWID 217 | FROM message m 218 | JOIN handle h ON m.handle_id = h.ROWID 219 | WHERE h.id IN (\(String(repeating: "?,", count: handles.count).dropLast())) 220 | ) 221 | """) 222 | 223 | // Add each participant as a value 224 | handles.forEach { handle in 225 | parameters.append(handle.rawValue) 226 | } 227 | } 228 | 229 | // Add date range if specified 230 | if let dateRange = dateRange { 231 | if let upperBound = dateRange.upperBound.nanosecondsSinceReferenceDate { 232 | conditions.append("m.date < ?") 233 | parameters.append(upperBound) 234 | } 235 | if let lowerBound = dateRange.lowerBound.nanosecondsSinceReferenceDate { 236 | conditions.append("m.date >= ?") 237 | parameters.append(lowerBound) 238 | } 239 | } 240 | 241 | let query = """ 242 | SELECT 243 | m.guid, 244 | m.text, 245 | HEX(m.attributedBody), 246 | m.date, 247 | m.is_from_me, 248 | h.id, 249 | m.service 250 | FROM message m 251 | \(chatId != nil ? "JOIN chat_message_join cmj ON m.ROWID = cmj.message_id" : "") 252 | \(chatId != nil ? "JOIN chat c ON cmj.chat_id = c.ROWID" : "") 253 | LEFT JOIN handle h ON m.handle_id = h.ROWID 254 | \(conditions.isEmpty ? "" : "WHERE \(conditions.joined(separator: " AND "))") 255 | ORDER BY m.date DESC 256 | LIMIT ? 257 | """ 258 | 259 | parameters.append(Int32(limit)) 260 | 261 | return try execute(query, parameters: parameters) { statement in 262 | let messageID: Message.ID 263 | if let messageIdText = sqlite3_column_text(statement, 0) { 264 | messageID = Message.ID(rawValue: String(cString: messageIdText)) 265 | } else { 266 | messageID = "N/A" 267 | // FIXME 268 | } 269 | 270 | // Handle text 271 | let text: String 272 | if let rawText = sqlite3_column_text(statement, 1) { 273 | text = String(cString: rawText) 274 | } else if let hexData = sqlite3_column_text(statement, 2).map({ 275 | String(cString: $0) 276 | }), 277 | let data = Data(hexString: hexData), 278 | let plainText = try? TypedStreamDecoder.decode(data).compactMap({ 279 | $0.stringValue 280 | }).joined(separator: "\n") 281 | { 282 | text = plainText 283 | } else { 284 | text = "" 285 | } 286 | 287 | let date = Date( 288 | nanosecondsSinceReferenceDate: sqlite3_column_int64(statement, 3)) 289 | let isFromMe = sqlite3_column_int(statement, 4) != 0 290 | 291 | let senderText = sqlite3_column_text(statement, 5) 292 | let sender = senderText.map { Account.Handle(rawValue: String(cString: $0)) } 293 | 294 | return Message( 295 | id: messageID, 296 | text: text, 297 | date: date, 298 | isFromMe: isFromMe, 299 | sender: sender 300 | ) 301 | } 302 | } 303 | } 304 | 305 | public func fetchParticipants( 306 | for chatId: Chat.ID, 307 | limit: Int = 100 308 | ) throws -> [Account.Handle] { 309 | let query = """ 310 | SELECT h.id 311 | FROM chat c 312 | JOIN chat_handle_join chj ON c.ROWID = chj.chat_id 313 | JOIN handle h ON chj.handle_id = h.ROWID 314 | WHERE c.guid = ? 315 | LIMIT ? 316 | """ 317 | let parameters: [any Bindable] = [ 318 | chatId.rawValue, 319 | Int32(limit), 320 | ] 321 | 322 | return try execute(query, parameters: parameters) { statement in 323 | guard let idText = sqlite3_column_text(statement, 0) else { return nil } 324 | return Account.Handle(rawValue: String(cString: idText)) 325 | } 326 | } 327 | 328 | @available(iOS 16.0, *) 329 | @available(macOS 13.0, *) 330 | public func fetchParticipant( 331 | matching aliases: [String], 332 | limit: Int = 100 333 | ) throws -> [Account.Handle] { 334 | guard !aliases.isEmpty else { return [] } 335 | 336 | // Normalize the input phone numbers/emails 337 | let normalized = aliases.map { alias in 338 | if alias.contains("@") { 339 | // Email: just lowercase 340 | return alias.lowercased() 341 | .trimmingCharacters(in: .whitespacesAndNewlines) 342 | } else { 343 | // Phone: remove formatting characters 344 | return alias.replacing(/[\s\(\)\-]/, with: "") 345 | .trimmingCharacters(in: .whitespacesAndNewlines) 346 | } 347 | } 348 | 349 | // Create the placeholder strings for IN clauses 350 | let placeholders = Array(repeating: "?", count: normalized.count).joined(separator: ",") 351 | 352 | let query = """ 353 | SELECT DISTINCT h.id 354 | FROM handle h 355 | WHERE h.id IN (\(placeholders)) 356 | OR h.uncanonicalized_id IN (\(placeholders)) 357 | OR (\(normalized.map { _ in "h.id LIKE '%' || ?" }.joined(separator: " OR "))) 358 | LIMIT ? 359 | """ 360 | 361 | var parameters: [any Bindable] = [] 362 | // Exact matches with id 363 | parameters.append(contentsOf: normalized) 364 | // Match original inputs with uncanonicalized_id 365 | parameters.append(contentsOf: aliases) 366 | // Match as suffix of handle id (handles varying country code prefixes) 367 | parameters.append(contentsOf: normalized) 368 | // Add limit 369 | parameters.append(Int32(limit)) 370 | 371 | return try execute(query, parameters: parameters) { statement in 372 | guard let idText = sqlite3_column_text(statement, 0) else { return nil } 373 | return Account.Handle(rawValue: String(cString: idText)) 374 | } 375 | } 376 | 377 | private func withTransaction(_ block: () throws -> T) throws -> T { 378 | guard sqlite3_exec(db, "BEGIN TRANSACTION", nil, nil, nil) == SQLITE_OK else { 379 | throw Error.queryError(String(cString: sqlite3_errmsg(db))) 380 | } 381 | 382 | do { 383 | let result = try block() 384 | guard sqlite3_exec(db, "COMMIT", nil, nil, nil) == SQLITE_OK else { 385 | throw Error.queryError(String(cString: sqlite3_errmsg(db))) 386 | } 387 | return result 388 | } catch { 389 | sqlite3_exec(db, "ROLLBACK", nil, nil, nil) 390 | throw error 391 | } 392 | } 393 | } 394 | 395 | // MARK: - 396 | 397 | private protocol Bindable { 398 | func bind(to statement: OpaquePointer, at index: Int32) 399 | } 400 | 401 | extension String: Bindable { 402 | func bind(to statement: OpaquePointer, at index: Int32) { 403 | sqlite3_bind_text(statement, index, self, -1, SQLITE_TRANSIENT) 404 | } 405 | } 406 | 407 | extension Double: Bindable { 408 | fileprivate func bind(to statement: OpaquePointer, at index: Int32) { 409 | sqlite3_bind_double(statement, index, self) 410 | } 411 | } 412 | 413 | extension Int32: Bindable { 414 | fileprivate func bind(to statement: OpaquePointer, at index: Int32) { 415 | sqlite3_bind_int(statement, index, self) 416 | } 417 | } 418 | 419 | extension Int64: Bindable { 420 | fileprivate func bind(to statement: OpaquePointer, at index: Int32) { 421 | sqlite3_bind_int64(statement, index, self) 422 | } 423 | } 424 | -------------------------------------------------------------------------------- /Sources/TypedStream/TypedStreamDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /** 4 | Contains logic and data used to deserialize data from a `typedstream`. 5 | 6 | `typedstream` is a binary serialization format developed by NeXT and later adopted by Apple. 7 | It's designed to serialize and deserialize complex object graphs and data structures in C and Objective-C. 8 | 9 | A `typedstream` begins with a header that includes format version and architecture information, 10 | followed by a stream of typed data elements. Each element is prefixed with type information, 11 | allowing the `TypedStreamDecoder` to understand the original data structures. 12 | */ 13 | public final class TypedStreamDecoder { 14 | /// Errors that can happen when parsing `typedstream` data. 15 | /// This corresponds to the new `typedstream` deserializer. 16 | enum Error: Swift.Error, CustomStringConvertible { 17 | case outOfBounds(index: Int, length: Int) 18 | case invalidHeader 19 | case sliceError(Swift.Error) 20 | case stringParseError(Swift.Error) 21 | case invalidArray 22 | case invalidPointer(UInt8) 23 | 24 | var description: String { 25 | switch self { 26 | case .outOfBounds(let index, let length): 27 | return String(format: "Index %x is outside of range %x!", index, length) 28 | case .invalidHeader: 29 | return "Invalid typedstream header!" 30 | case .sliceError(let error): 31 | return "Unable to slice source stream: \(error)" 32 | case .stringParseError(let error): 33 | return "Failed to parse string: \(error)" 34 | case .invalidArray: 35 | return "Failed to parse array data" 36 | case .invalidPointer(let value): 37 | return String(format: "Failed to parse pointer: %x", value) 38 | } 39 | } 40 | } 41 | 42 | /// Represents data that results from attempting to parse a class from the `typedstream` 43 | private enum ClassResult { 44 | /// A reference to an already-seen class in the `TypedStreamReader`'s object table 45 | case index(Int) 46 | /// A new class hierarchy to be inserted into the `TypedStreamReader`'s object table 47 | case classHierarchy([Archivable]) 48 | } 49 | 50 | // MARK: - Constants 51 | 52 | /// Indicates an `Int16` in the byte stream 53 | private let I_16: UInt8 = 0x81 54 | /// Indicates an `Int32` in the byte stream 55 | private let I_32: UInt8 = 0x82 56 | /// Indicates a `Float` or `Double` in the byte stream; the `Type` determines the size 57 | private let DECIMAL: UInt8 = 0x83 58 | /// Indicates the start of a new object 59 | private let START: UInt8 = 0x84 60 | /// Indicates that there is no more data to parse, for example the end of a class inheritance chain 61 | private let EMPTY: UInt8 = 0x85 62 | /// Indicates the last byte of an object 63 | private let END: UInt8 = 0x86 64 | /// Bytes equal or greater in value than the reference tag indicate an index in the table of already-seen types 65 | private let REFERENCE_TAG: UInt64 = 0x92 66 | 67 | // MARK: - Properties 68 | 69 | /// The `typedstream` we want to parse 70 | let stream: [UInt8] 71 | /// The current index we are at in the stream 72 | var idx: Int 73 | /// As we parse the `typedstream`, build a table of seen `Type`s to reference in the future 74 | /// 75 | /// The first time a `Type` is seen, it is present in the stream literally, 76 | /// but afterwards are only referenced by index in order of appearance. 77 | var typesTable: [[Type]] 78 | /// As we parse the `typedstream`, build a table of seen archivable data to reference in the future 79 | var objectTable: [Archivable] 80 | /// We want to copy embedded types the first time they are seen, even if the types were resolved through references 81 | var seenEmbeddedTypes: Set 82 | /// Stores the position of the current `Archivable.placeholder` 83 | var placeholder: Int? 84 | 85 | // MARK: - Static Methods 86 | 87 | /// Decode typedstream data into an array of Archivable objects 88 | /// - Parameter data: The data to decode 89 | /// - Returns: An array of decoded Archivable objects 90 | /// - Throws: TypedStreamError if decoding fails 91 | public static func decode(_ data: Data) throws -> [Archivable] { 92 | let bytes = [UInt8](data) 93 | let decoder = TypedStreamDecoder(stream: bytes) 94 | return try decoder.parse() 95 | } 96 | 97 | // MARK: - Initialization 98 | 99 | /// Initialize the decoder with a stream of bytes 100 | init(stream: [UInt8]) { 101 | self.stream = stream 102 | self.idx = 0 103 | self.typesTable = [] 104 | self.objectTable = [] 105 | self.seenEmbeddedTypes = Set() 106 | self.placeholder = nil 107 | } 108 | 109 | // MARK: - Methods 110 | 111 | /// Attempt to get the data from the `typedstream`. 112 | /// 113 | /// Given a stream, construct a decoder object to parse it. `typedstream` data doesn't include property 114 | /// names, so data is stored on `object`s in order of appearance. 115 | /// 116 | /// Yields a new `Archivable` as they occur in the stream, but does not retain the object's inheritance hierarchy. 117 | /// Callers are responsible for assembling the deserialized stream into a useful data structure. 118 | /// 119 | /// - Returns: An array of `Archivable` objects parsed from the stream. 120 | func parse() throws -> [Archivable] { 121 | var output: [Archivable] = [] 122 | 123 | try validateHeader() 124 | 125 | while idx < stream.count { 126 | if try getCurrentByte() == END { 127 | idx += 1 128 | continue 129 | } 130 | // First, get the current type 131 | if let foundTypes = try getType(embedded: false) { 132 | if let result = try readTypes(foundTypes: foundTypes) { 133 | output.append(result) 134 | } 135 | } 136 | } 137 | 138 | return output 139 | } 140 | 141 | /// Validate the `typedstream` header to ensure correct format 142 | private func validateHeader() throws { 143 | // Encoding type 144 | let typedstreamVersion = try readUnsignedInt() 145 | // Encoding signature 146 | let signature = try readString() 147 | // System version 148 | let systemVersion = try readSignedInt() 149 | 150 | if typedstreamVersion != 4 || signature != "streamtyped" || systemVersion != 1000 { 151 | throw Error.invalidHeader 152 | } 153 | } 154 | 155 | /// Read a signed integer from the stream. 156 | /// Because we don't know the size of the integer ahead of time, we store it in the largest possible value. 157 | private func readSignedInt() throws -> Int64 { 158 | switch try getCurrentByte() { 159 | case I_16: 160 | idx += 1 161 | let size = 2 162 | let bytes = try readExactBytes(size: size) 163 | let value = Int16(littleEndian: bytes.withUnsafeBytes { $0.load(as: Int16.self) }) 164 | return Int64(value) 165 | case I_32: 166 | idx += 1 167 | let size = 4 168 | let bytes = try readExactBytes(size: size) 169 | let value = Int32(littleEndian: bytes.withUnsafeBytes { $0.load(as: Int32.self) }) 170 | return Int64(value) 171 | default: 172 | let currentByte = try getCurrentByte() 173 | if currentByte > UInt8(REFERENCE_TAG) { 174 | let nextByte = try getNextByte() 175 | if nextByte != END { 176 | idx += 1 177 | return try readSignedInt() 178 | } 179 | } 180 | let value = Int8(bitPattern: currentByte) 181 | idx += 1 182 | return Int64(value) 183 | } 184 | } 185 | 186 | /// Read an unsigned integer from the stream. 187 | /// Because we don't know the size of the integer ahead of time, we store it in the largest possible value. 188 | private func readUnsignedInt() throws -> UInt64 { 189 | switch try getCurrentByte() { 190 | case I_16: 191 | idx += 1 192 | let size = 2 193 | let bytes = try readExactBytes(size: size) 194 | let value = UInt16(littleEndian: bytes.withUnsafeBytes { $0.load(as: UInt16.self) }) 195 | return UInt64(value) 196 | case I_32: 197 | idx += 1 198 | let size = 4 199 | let bytes = try readExactBytes(size: size) 200 | let value = UInt32(littleEndian: bytes.withUnsafeBytes { $0.load(as: UInt32.self) }) 201 | return UInt64(value) 202 | default: 203 | let value = try getCurrentByte() 204 | idx += 1 205 | return UInt64(value) 206 | } 207 | } 208 | 209 | /// Read a single-precision float from the byte stream 210 | private func readFloat() throws -> Float { 211 | switch try getCurrentByte() { 212 | case DECIMAL: 213 | idx += 1 214 | let size = 4 215 | let bytes = try readExactBytes(size: size) 216 | let value = Float(bitPattern: bytes.withUnsafeBytes { $0.load(as: UInt32.self) }) 217 | return value 218 | case I_16, I_32: 219 | let intValue = try readSignedInt() 220 | return Float(intValue) 221 | default: 222 | idx += 1 223 | let intValue = try readSignedInt() 224 | return Float(intValue) 225 | } 226 | } 227 | 228 | /// Read a double-precision float from the byte stream 229 | private func readDouble() throws -> Double { 230 | switch try getCurrentByte() { 231 | case DECIMAL: 232 | idx += 1 233 | let size = 8 234 | let bytes = try readExactBytes(size: size) 235 | let value = Double(bitPattern: bytes.withUnsafeBytes { $0.load(as: UInt64.self) }) 236 | return value 237 | case I_16, I_32: 238 | let intValue = try readSignedInt() 239 | return Double(intValue) 240 | default: 241 | idx += 1 242 | let intValue = try readSignedInt() 243 | return Double(intValue) 244 | } 245 | } 246 | 247 | /// Read exactly `size` bytes from the stream 248 | private func readExactBytes(size: Int) throws -> Data { 249 | guard idx + size <= stream.count else { 250 | throw Error.outOfBounds(index: idx + size, length: stream.count) 251 | } 252 | let data = Data(stream[idx..<(idx + size)]) 253 | idx += size 254 | return data 255 | } 256 | 257 | /// Read `size` bytes as a String 258 | private func readExactAsString(length: Int) throws -> String { 259 | let bytes = try readExactBytes(size: length) 260 | guard let string = String(data: bytes, encoding: .utf8) else { 261 | throw Error.stringParseError(NSError(domain: "Invalid UTF-8", code: 0)) 262 | } 263 | return string 264 | } 265 | 266 | /// Get the byte at a given index, if the index is within the bounds of the `typedstream` 267 | private func getByte(at index: Int) throws -> UInt8 { 268 | guard index < stream.count else { 269 | throw Error.outOfBounds(index: index, length: stream.count) 270 | } 271 | return stream[index] 272 | } 273 | 274 | /// Read the current byte 275 | private func getCurrentByte() throws -> UInt8 { 276 | return try getByte(at: idx) 277 | } 278 | 279 | /// Read the next byte 280 | private func getNextByte() throws -> UInt8 { 281 | return try getByte(at: idx + 1) 282 | } 283 | 284 | /// Read some bytes as an array 285 | private func readArray(size: Int) throws -> [UInt8] { 286 | let data = try readExactBytes(size: size) 287 | return [UInt8](data) 288 | } 289 | 290 | /// Determine the current types 291 | private func readType() throws -> [Type] { 292 | let length = try readUnsignedInt() 293 | let typesData = try readExactBytes(size: Int(length)) 294 | let typesBytes = [UInt8](typesData) 295 | 296 | // Handle array size 297 | if typesBytes.first == 0x5B { // '[' character 298 | if let (arrayTypes, _) = Type.getArrayLength(types: typesBytes) { 299 | return arrayTypes 300 | } else { 301 | throw Error.invalidArray 302 | } 303 | } 304 | 305 | return typesBytes.map { Type.fromByte($0) } 306 | } 307 | 308 | /// Read a reference pointer for a Type 309 | private func readPointer() throws -> UInt32 { 310 | let pointer = try getCurrentByte() 311 | idx += 1 312 | guard let result = UInt32(exactly: pointer &- UInt8(REFERENCE_TAG)) else { 313 | throw Error.invalidPointer(pointer) 314 | } 315 | return result 316 | } 317 | 318 | /// Read a class 319 | private func readClass() throws -> ClassResult { 320 | var output: [Archivable] = [] 321 | switch try getCurrentByte() { 322 | case START: 323 | // Skip some header bytes 324 | while try getCurrentByte() == START { 325 | idx += 1 326 | } 327 | let length = try readUnsignedInt() 328 | 329 | if length >= REFERENCE_TAG { 330 | let index = length - REFERENCE_TAG 331 | return .index(Int(index)) 332 | } 333 | 334 | let className = try readExactAsString(length: Int(length)) 335 | let version = try readUnsignedInt() 336 | 337 | typesTable.append([.newString(className)]) 338 | output.append(.class(Class(name: className, version: version))) 339 | 340 | let parentClassResult = try readClass() 341 | if case .classHierarchy(let parent) = parentClassResult { 342 | output.append(contentsOf: parent) 343 | } 344 | case EMPTY: 345 | idx += 1 346 | default: 347 | let index = try readPointer() 348 | return .index(Int(index)) 349 | } 350 | return .classHierarchy(output) 351 | } 352 | 353 | /// Read an object into the cache and emit, or emit an already-cached object 354 | private func readObject() throws -> Archivable? { 355 | switch try getCurrentByte() { 356 | case START: 357 | let classResult = try readClass() 358 | switch classResult { 359 | case .index(let idx): 360 | return objectTable[safe: idx] 361 | case .classHierarchy(let classes): 362 | objectTable.append(contentsOf: classes) 363 | } 364 | return nil 365 | case EMPTY: 366 | idx += 1 367 | return nil 368 | default: 369 | let index = try readPointer() 370 | return objectTable[safe: Int(index)] 371 | } 372 | } 373 | 374 | /// Read String data 375 | private func readString() throws -> String { 376 | let length = try readUnsignedInt() 377 | let string = try readExactAsString(length: Int(length)) 378 | return string 379 | } 380 | 381 | /// `Archivable` data can be embedded on a class or in a C String marked as `Type.embeddedData` 382 | private func readEmbeddedData() throws -> Archivable? { 383 | // Skip the 0x84 384 | idx += 1 385 | if let types = try getType(embedded: true) { 386 | return try readTypes(foundTypes: types) 387 | } 388 | return nil 389 | } 390 | 391 | /// Gets the current type from the stream, either by reading it from the stream or reading it from 392 | /// the specified index of `typesTable`. 393 | private func getType(embedded: Bool) throws -> [Type]? { 394 | switch try getCurrentByte() { 395 | case START: 396 | // Ignore repeated types, for example in a dict 397 | idx += 1 398 | let objectTypes = try readType() 399 | // Embedded data is stored as a C String in the objects table 400 | if embedded { 401 | objectTable.append(.type(objectTypes)) 402 | seenEmbeddedTypes.insert(UInt32(typesTable.count)) 403 | } 404 | typesTable.append(objectTypes) 405 | return typesTable.last 406 | case END: 407 | // This indicates the end of the current object 408 | return nil 409 | default: 410 | // Ignore repeated types, for example in a dict 411 | while try getCurrentByte() == getNextByte() { 412 | idx += 1 413 | } 414 | let refTag = try readPointer() 415 | let result = typesTable[safe: Int(refTag)] 416 | if embedded, let res = result { 417 | // We only want to include the first embedded reference tag, not subsequent references to the same embed 418 | if !seenEmbeddedTypes.contains(refTag) { 419 | objectTable.append(.type(res)) 420 | seenEmbeddedTypes.insert(refTag) 421 | } 422 | } 423 | return result 424 | } 425 | } 426 | 427 | /// Given some `Type`s, look at the stream and parse the data according to the specified `Type` 428 | private func readTypes(foundTypes: [Type]) throws -> Archivable? { 429 | var output: [Object] = [] 430 | var isObject = false 431 | 432 | for foundType in foundTypes { 433 | switch foundType { 434 | case .utf8String: 435 | let string = try readString() 436 | output.append(.string(string)) 437 | case .embeddedData: 438 | if let embeddedData = try readEmbeddedData() { 439 | return embeddedData 440 | } 441 | case .object: 442 | isObject = true 443 | let length = objectTable.count 444 | placeholder = length 445 | objectTable.append(.placeholder) 446 | if let object = try readObject() { 447 | switch object { 448 | case .object(_, let data): 449 | // If this is a new object, i.e. one without any data, we add the data into it later 450 | // If the object already has data in it, we just want to return that object 451 | if !data.isEmpty { 452 | placeholder = nil 453 | objectTable.removeLast() 454 | return object 455 | } 456 | output.append(contentsOf: data) 457 | case .class(let cls): 458 | output.append(.class(cls)) 459 | case .data(let data): 460 | output.append(contentsOf: data) 461 | default: 462 | break 463 | } 464 | } 465 | case .signedInt: 466 | let value = try readSignedInt() 467 | output.append(.signedInteger(value)) 468 | case .unsignedInt: 469 | let value = try readUnsignedInt() 470 | output.append(.unsignedInteger(value)) 471 | case .float: 472 | let value = try readFloat() 473 | output.append(.float(value)) 474 | case .double: 475 | let value = try readDouble() 476 | output.append(.double(value)) 477 | case .unknown(let byte): 478 | output.append(.byte(byte)) 479 | case .string(let s): 480 | output.append(.string(s)) 481 | case .array(let size): 482 | let array = try readArray(size: size) 483 | output.append(.array(array)) 484 | } 485 | } 486 | 487 | // If we had reserved a place for an object, fill that spot 488 | if let spot = placeholder { 489 | if !output.isEmpty { 490 | // We got a class, but do not have its respective data yet 491 | if let last = output.last, case .class(let cls) = last { 492 | objectTable[spot] = .object(cls, []) 493 | } 494 | // The spot after the current placeholder contains the class at the top of the class hierarchy 495 | else if let next = objectTable[safe: spot + 1], case .class(let cls) = next { 496 | objectTable[spot] = .object(cls, output) 497 | placeholder = nil 498 | return objectTable[spot] 499 | } 500 | // We got some data for a class that was already seen 501 | else if case .object(let cls, var data) = objectTable[spot] { 502 | data.append(contentsOf: output) 503 | objectTable[spot] = .object(cls, data) 504 | placeholder = nil 505 | return objectTable[spot] 506 | } 507 | // We got some data that is not part of a class 508 | else { 509 | objectTable[spot] = .data(output) 510 | placeholder = nil 511 | return objectTable[spot] 512 | } 513 | } 514 | } 515 | 516 | if !output.isEmpty && !isObject { 517 | return .data(output) 518 | } 519 | 520 | return nil 521 | } 522 | } 523 | 524 | // MARK: - 525 | 526 | fileprivate extension Array { 527 | /// Safely access an array element to prevent index out of range errors 528 | subscript(safe index: Int) -> Element? { 529 | return indices.contains(index) ? self[index] : nil 530 | } 531 | } 532 | --------------------------------------------------------------------------------