diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9839325..23f3243 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: - build: + macos: runs-on: macos-latest steps: - uses: actions/checkout@v6 @@ -20,3 +20,25 @@ jobs: run: make test - name: Build run: make build ARCHES="$(uname -m)" + + linux-read-core: + runs-on: ubuntu-latest + container: swift:6.2.4-noble + steps: + - uses: actions/checkout@v6 + - name: Swift version + run: swift --version + - name: Install Python + run: | + apt-get update + apt-get install -y --no-install-recommends python3 + - name: Generate version + run: scripts/generate-version.sh + - name: Resolve dependencies + run: swift package resolve + - name: Patch dependencies + run: scripts/patch-deps.sh + - name: Test Linux read core + run: swift test + - name: Build CLI + run: swift build --product imsg diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a01d36..4ce8020 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,12 +7,18 @@ on: description: "Tag to (re)release (e.g. v0.1.0)" required: true type: string + include_macos: + description: "Also rebuild and upload the macOS archive" + required: false + default: false + type: boolean permissions: contents: write jobs: - release: + macos-release: + if: ${{ inputs.include_macos }} runs-on: macos-latest steps: - name: Checkout @@ -92,3 +98,55 @@ jobs: fi gh release edit "$TAG" --notes-file "$notes_file" + + linux-release: + runs-on: ubuntu-latest + container: swift:6.2.4-noble + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Determine tag + id: tag + shell: bash + run: | + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout release tag + if: ${{ github.event_name == 'workflow_dispatch' }} + run: git checkout ${{ steps.tag.outputs.tag }} + + - name: Install Python + run: | + apt-get update + apt-get install -y --no-install-recommends python3 + + - name: Resolve packages + run: swift package resolve + + - name: Patch dependencies + run: scripts/patch-deps.sh + + - name: Sync version + run: scripts/generate-version.sh + + - name: Build Linux archive + run: | + rm -rf dist + OUTPUT_DIR=dist scripts/build-linux.sh + + - name: Publish Linux release asset + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: ${{ steps.tag.outputs.tag }} + files: dist/imsg-linux-x86_64.tar.gz + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 914b763..9d2ed28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Linux Read-Only Preview +- feat: add a Linux read-only core build with fixture-backed tests and GitHub + CI coverage for copied Messages databases. +- build: add Linux release archive packaging for `imsg-linux-x86_64.tar.gz`. + ## 0.7.3 - 2026-05-06 ### Private API Bridge diff --git a/Makefile b/Makefile index 482d2ed..ea1c372 100644 --- a/Makefile +++ b/Makefile @@ -14,10 +14,10 @@ help: "make clean - swift package clean" format: - swift format --in-place --recursive Sources Tests + swift format --in-place --recursive Sources Tests TestsLinux lint: - swift format lint --recursive Sources Tests + swift format lint --recursive Sources Tests TestsLinux swiftlint test: diff --git a/Package.swift b/Package.swift index 4aafae2..7ecac17 100644 --- a/Package.swift +++ b/Package.swift @@ -2,62 +2,85 @@ import PackageDescription let package = Package( - name: "imsg", - platforms: [.macOS(.v14)], - products: [ - .library(name: "IMsgCore", targets: ["IMsgCore"]), - .executable(name: "imsg", targets: ["imsg"]), - ], - dependencies: [ - .package(url: "https://github.com/steipete/Commander.git", from: "0.2.1"), - .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.5"), - .package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.2.5"), - ], - targets: [ - .target( - name: "IMsgCore", - dependencies: [ - .product(name: "SQLite", package: "SQLite.swift"), - .product(name: "PhoneNumberKit", package: "PhoneNumberKit"), - ], - linkerSettings: [ - .linkedFramework("ScriptingBridge"), - .linkedFramework("Contacts"), - ] - ), - .executableTarget( - name: "imsg", + name: "imsg", + platforms: [.macOS(.v14)], + products: [ + .library(name: "IMsgCore", targets: ["IMsgCore"]), + .executable(name: "imsg", targets: ["imsg"]), + ], + dependencies: [ + .package(url: "https://github.com/steipete/Commander.git", from: "0.2.1"), + .package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.5"), + .package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.2.5"), + ], + targets: { + var targets: [Target] = [ + .target( + name: "IMsgCore", dependencies: [ - "IMsgCore", - .product(name: "Commander", package: "Commander"), - ], - exclude: [ - "Resources/Info.plist", + .product(name: "SQLite", package: "SQLite.swift"), + .product(name: "PhoneNumberKit", package: "PhoneNumberKit"), ], linkerSettings: [ - .unsafeFlags([ - "-Xlinker", "-sectcreate", - "-Xlinker", "__TEXT", - "-Xlinker", "__info_plist", - "-Xlinker", "Sources/imsg/Resources/Info.plist", - ]) + .linkedFramework("ScriptingBridge", .when(platforms: [.macOS])), + .linkedFramework("Contacts", .when(platforms: [.macOS])), ] - ), - .testTarget( - name: "IMsgCoreTests", - dependencies: [ - "IMsgCore", - ] - ), - .testTarget( - name: "imsgTests", - dependencies: [ - "imsg", - "IMsgCore", + ), + .executableTarget( + name: "imsg", + dependencies: [ + "IMsgCore", + .product(name: "Commander", package: "Commander"), + ], + exclude: [ + "Resources/Info.plist" + ], + linkerSettings: [ + .unsafeFlags( + [ + "-Xlinker", "-sectcreate", + "-Xlinker", "__TEXT", + "-Xlinker", "__info_plist", + "-Xlinker", "Sources/imsg/Resources/Info.plist", ], - exclude: [ - "README-live.md", - ] - ), + .when(platforms: [.macOS]) + ) + ] + ), ] + + #if os(macOS) + targets.append(contentsOf: [ + .testTarget( + name: "IMsgCoreTests", + dependencies: [ + "IMsgCore" + ] + ), + .testTarget( + name: "imsgTests", + dependencies: [ + "imsg", + "IMsgCore", + ], + exclude: [ + "README-live.md" + ] + ), + ]) + #else + targets.append( + .testTarget( + name: "IMsgLinuxTests", + dependencies: [ + "imsg", + "IMsgCore", + .product(name: "SQLite", package: "SQLite.swift"), + ], + path: "TestsLinux" + )) + #endif + + return targets + }() ) diff --git a/Sources/IMsgCore/AttachmentResolver.swift b/Sources/IMsgCore/AttachmentResolver.swift index 51e5158..b0e696f 100644 --- a/Sources/IMsgCore/AttachmentResolver.swift +++ b/Sources/IMsgCore/AttachmentResolver.swift @@ -1,6 +1,9 @@ -import CryptoKit import Foundation +#if canImport(CryptoKit) + import CryptoKit +#endif + enum AttachmentResolver { private struct ConversionPlan { let targetExtension: String @@ -58,9 +61,7 @@ enum AttachmentResolver { let modification = values?.contentModificationDate?.timeIntervalSince1970 ?? 0 let size = values?.fileSize ?? 0 let token = "\(sourceURL.path)|\(size)|\(modification)" - let digest = SHA256.hash(data: Data(token.utf8)) - .map { String(format: "%02x", $0) } - .joined() + let digest = cacheDigest(for: token) let base = sourceURL.deletingPathExtension().lastPathComponent .components(separatedBy: CharacterSet.alphanumerics.inverted) .filter { !$0.isEmpty } @@ -158,6 +159,23 @@ enum AttachmentResolver { ) } + private static func cacheDigest(for token: String) -> String { + #if canImport(CryptoKit) + return SHA256.hash(data: Data(token.utf8)) + .map { String(format: "%02x", $0) } + .joined() + #else + // Linux Swift does not ship CryptoKit. This digest only names cache files; + // it is not used as a security boundary, so stable FNV-1a is enough. + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in token.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(format: "%016llx", hash) + #endif + } + private static func executableURL(named name: String) -> URL? { let path = ProcessInfo.processInfo.environment["PATH"] ?? "" let candidates = diff --git a/Sources/IMsgCore/ContactResolver.swift b/Sources/IMsgCore/ContactResolver.swift index 2bc619c..ecff40d 100644 --- a/Sources/IMsgCore/ContactResolver.swift +++ b/Sources/IMsgCore/ContactResolver.swift @@ -1,6 +1,9 @@ -@preconcurrency import Contacts import Foundation +#if os(macOS) + @preconcurrency import Contacts +#endif + public struct ContactMatch: Equatable, Sendable { public let name: String public let handle: String @@ -32,153 +35,181 @@ public final class NoOpContactResolver: ContactResolving, Sendable { } public final class ContactResolver: ContactResolving, @unchecked Sendable { - private let phoneToName: [String: String] - private let emailToName: [String: String] - private let contacts: [ContactRecord] - private let normalizer = PhoneNumberNormalizer() - private let region: String + #if os(macOS) + private let phoneToName: [String: String] + private let emailToName: [String: String] + private let contacts: [ContactRecord] + private let normalizer = PhoneNumberNormalizer() + private let region: String - public let contactsUnavailable: Bool + public let contactsUnavailable: Bool - private init( - phoneToName: [String: String], - emailToName: [String: String], - contacts: [ContactRecord], - region: String - ) { - self.phoneToName = phoneToName - self.emailToName = emailToName - self.contacts = contacts - self.region = region - self.contactsUnavailable = false - } + private init( + phoneToName: [String: String], + emailToName: [String: String], + contacts: [ContactRecord], + region: String + ) { + self.phoneToName = phoneToName + self.emailToName = emailToName + self.contacts = contacts + self.region = region + self.contactsUnavailable = false + } + #else + public let contactsUnavailable = true + #endif public static func create(region: String = "US") async -> any ContactResolving { - let store = CNContactStore() - switch CNContactStore.authorizationStatus(for: .contacts) { - case .authorized: - return load(store: store, region: region) - case .notDetermined: - let granted = await requestAccess(store: store) - return granted - ? load(store: store, region: region) : NoOpContactResolver(contactsUnavailable: true) - case .denied, .restricted: + #if os(macOS) + let store = CNContactStore() + switch CNContactStore.authorizationStatus(for: .contacts) { + case .authorized: + return load(store: store, region: region) + case .notDetermined: + let granted = await requestAccess(store: store) + return granted + ? load(store: store, region: region) : NoOpContactResolver(contactsUnavailable: true) + case .denied, .restricted: + return NoOpContactResolver(contactsUnavailable: true) + @unknown default: + return NoOpContactResolver(contactsUnavailable: true) + } + #else + _ = region return NoOpContactResolver(contactsUnavailable: true) - @unknown default: - return NoOpContactResolver(contactsUnavailable: true) - } + #endif } public func displayName(for handle: String) -> String? { - let lookup = normalizedLookupHandle(handle) - if lookup.contains("@") { - return emailToName[lookup.lowercased()] - } - return phoneToName[normalizer.normalize(lookup, region: region)] + #if os(macOS) + let lookup = normalizedLookupHandle(handle) + if lookup.contains("@") { + return emailToName[lookup.lowercased()] + } + return phoneToName[normalizer.normalize(lookup, region: region)] + #else + _ = handle + return nil + #endif } public func displayNames(for handles: [String]) -> [String: String] { - var resolved: [String: String] = [:] - for handle in handles { - if let name = displayName(for: handle) { - resolved[handle] = name + #if os(macOS) + var resolved: [String: String] = [:] + for handle in handles { + if let name = displayName(for: handle) { + resolved[handle] = name + } } - } - return resolved + return resolved + #else + _ = handles + return [:] + #endif } public func searchByName(_ query: String) -> [ContactMatch] { - let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !normalizedQuery.isEmpty else { return [] } + #if os(macOS) + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalizedQuery.isEmpty else { return [] } - var matches: [ContactMatch] = [] - for contact in contacts where contact.name.lowercased().contains(normalizedQuery) { - if let phone = contact.phones.first { - matches.append(ContactMatch(name: contact.name, handle: phone)) - } else if let email = contact.emails.first { - matches.append(ContactMatch(name: contact.name, handle: email)) - } - } - return matches - } - - private static func requestAccess(store: CNContactStore) async -> Bool { - await withCheckedContinuation { continuation in - store.requestAccess(for: .contacts) { granted, _ in - continuation.resume(returning: granted) - } - } - } - - private static func load(store: CNContactStore, region: String) -> any ContactResolving { - let keysToFetch: [CNKeyDescriptor] = [ - CNContactGivenNameKey as CNKeyDescriptor, - CNContactFamilyNameKey as CNKeyDescriptor, - CNContactNicknameKey as CNKeyDescriptor, - CNContactPhoneNumbersKey as CNKeyDescriptor, - CNContactEmailAddressesKey as CNKeyDescriptor, - ] - let request = CNContactFetchRequest(keysToFetch: keysToFetch) - let normalizer = PhoneNumberNormalizer() - var phoneToName: [String: String] = [:] - var emailToName: [String: String] = [:] - var contacts: [ContactRecord] = [] - - do { - try store.enumerateContacts(with: request) { contact, _ in - guard let name = displayName(for: contact) else { return } - var phones: [String] = [] - var emails: [String] = [] - - for number in contact.phoneNumbers { - let normalized = normalizer.normalize(number.value.stringValue, region: region) - phones.append(normalized) - phoneToName[normalized] = phoneToName[normalized] ?? name - } - for email in contact.emailAddresses { - let normalized = String(email.value).lowercased() - emails.append(normalized) - emailToName[normalized] = emailToName[normalized] ?? name - } - - if !phones.isEmpty || !emails.isEmpty { - contacts.append(ContactRecord(name: name, phones: phones, emails: emails)) + var matches: [ContactMatch] = [] + for contact in contacts where contact.name.lowercased().contains(normalizedQuery) { + if let phone = contact.phones.first { + matches.append(ContactMatch(name: contact.name, handle: phone)) + } else if let email = contact.emails.first { + matches.append(ContactMatch(name: contact.name, handle: email)) } } - } catch { - return NoOpContactResolver(contactsUnavailable: true) - } - - return ContactResolver( - phoneToName: phoneToName, - emailToName: emailToName, - contacts: contacts, - region: region - ) + return matches + #else + _ = query + return [] + #endif } - private static func displayName(for contact: CNContact) -> String? { - if !contact.nickname.isEmpty { - return contact.nickname + #if os(macOS) + private static func requestAccess(store: CNContactStore) async -> Bool { + await withCheckedContinuation { continuation in + store.requestAccess(for: .contacts) { granted, _ in + continuation.resume(returning: granted) + } + } } - let name = [contact.givenName, contact.familyName] - .filter { !$0.isEmpty } - .joined(separator: " ") - return name.isEmpty ? nil : name - } - private func normalizedLookupHandle(_ handle: String) -> String { - let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines) - for prefix in ["iMessage;-;", "iMessage;+;", "SMS;-;", "SMS;+;", "any;-;", "any;+;"] - where trimmed.hasPrefix(prefix) { - return String(trimmed.dropFirst(prefix.count)) + private static func load(store: CNContactStore, region: String) -> any ContactResolving { + let keysToFetch: [CNKeyDescriptor] = [ + CNContactGivenNameKey as CNKeyDescriptor, + CNContactFamilyNameKey as CNKeyDescriptor, + CNContactNicknameKey as CNKeyDescriptor, + CNContactPhoneNumbersKey as CNKeyDescriptor, + CNContactEmailAddressesKey as CNKeyDescriptor, + ] + let request = CNContactFetchRequest(keysToFetch: keysToFetch) + let normalizer = PhoneNumberNormalizer() + var phoneToName: [String: String] = [:] + var emailToName: [String: String] = [:] + var contacts: [ContactRecord] = [] + + do { + try store.enumerateContacts(with: request) { contact, _ in + guard let name = displayName(for: contact) else { return } + var phones: [String] = [] + var emails: [String] = [] + + for number in contact.phoneNumbers { + let normalized = normalizer.normalize(number.value.stringValue, region: region) + phones.append(normalized) + phoneToName[normalized] = phoneToName[normalized] ?? name + } + for email in contact.emailAddresses { + let normalized = String(email.value).lowercased() + emails.append(normalized) + emailToName[normalized] = emailToName[normalized] ?? name + } + + if !phones.isEmpty || !emails.isEmpty { + contacts.append(ContactRecord(name: name, phones: phones, emails: emails)) + } + } + } catch { + return NoOpContactResolver(contactsUnavailable: true) + } + + return ContactResolver( + phoneToName: phoneToName, + emailToName: emailToName, + contacts: contacts, + region: region + ) } - return trimmed - } + + private static func displayName(for contact: CNContact) -> String? { + if !contact.nickname.isEmpty { + return contact.nickname + } + let name = [contact.givenName, contact.familyName] + .filter { !$0.isEmpty } + .joined(separator: " ") + return name.isEmpty ? nil : name + } + + private func normalizedLookupHandle(_ handle: String) -> String { + let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines) + for prefix in ["iMessage;-;", "iMessage;+;", "SMS;-;", "SMS;+;", "any;-;", "any;+;"] + where trimmed.hasPrefix(prefix) { + return String(trimmed.dropFirst(prefix.count)) + } + return trimmed + } + #endif } -private struct ContactRecord: Sendable { - let name: String - let phones: [String] - let emails: [String] -} +#if os(macOS) + private struct ContactRecord: Sendable { + let name: String + let phones: [String] + let emails: [String] + } +#endif diff --git a/Sources/IMsgCore/IMsgEventTailer.swift b/Sources/IMsgCore/IMsgEventTailer.swift index 9dc6056..1665e58 100644 --- a/Sources/IMsgCore/IMsgEventTailer.swift +++ b/Sources/IMsgCore/IMsgEventTailer.swift @@ -1,5 +1,9 @@ import Foundation +#if os(macOS) + import Darwin +#endif + /// Live tailer for `.imsg-events.jsonl` written by the injected dylib. /// /// Uses `DispatchSource.makeFileSystemObjectSource` watching `.write`, @@ -36,9 +40,11 @@ public final class IMsgEventTailer: @unchecked Sendable { private let path: String private let replayExisting: Bool - private var source: DispatchSourceFileSystemObject? - private var fd: Int32 = -1 - private var pending = Data() + #if os(macOS) + private var source: DispatchSourceFileSystemObject? + private var fd: Int32 = -1 + private var pending = Data() + #endif private var continuation: AsyncStream.Continuation? private let queue = DispatchQueue(label: "imsg.event.tailer") @@ -55,109 +61,115 @@ public final class IMsgEventTailer: @unchecked Sendable { continuation.onTermination = { @Sendable _ in self.stop() } - self.queue.async { - self.openAndStart() - } + #if os(macOS) + self.queue.async { + self.openAndStart() + } + #endif } } public func stop() { - queue.async { [weak self] in - guard let self else { return } - self.source?.cancel() - self.source = nil - if self.fd >= 0 { - close(self.fd) - self.fd = -1 + #if os(macOS) + queue.async { [weak self] in + guard let self else { return } + self.source?.cancel() + self.source = nil + if self.fd >= 0 { + close(self.fd) + self.fd = -1 + } } - } + #endif } // MARK: - Private - private func openAndStart() { - if !FileManager.default.fileExists(atPath: path) { - // Create empty file so we can watch it. The dylib appends; missing - // file means injection isn't active yet — caller can retry later. - FileManager.default.createFile(atPath: path, contents: Data(), attributes: nil) - } - let fd = open(path, O_RDONLY) - if fd < 0 { return } - self.fd = fd - if replayExisting { - drainAvailable() - } else { - lseek(fd, 0, SEEK_END) + #if os(macOS) + private func openAndStart() { + if !FileManager.default.fileExists(atPath: path) { + // Create empty file so we can watch it. The dylib appends; missing + // file means injection isn't active yet — caller can retry later. + FileManager.default.createFile(atPath: path, contents: Data(), attributes: nil) + } + let fd = open(path, O_RDONLY) + if fd < 0 { return } + self.fd = fd + if replayExisting { + drainAvailable() + } else { + lseek(fd, 0, SEEK_END) + } + + let src = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.extend, .write, .rename, .delete], + queue: queue + ) + src.setEventHandler { [weak self] in + guard let self else { return } + let mask = src.data + if mask.contains(.rename) || mask.contains(.delete) { + // File rotated by the dylib — close and reopen the new file. + self.reopen() + return + } + self.drainAvailable() + } + src.setCancelHandler { [weak self] in + guard let self else { return } + if self.fd >= 0 { + close(self.fd) + self.fd = -1 + } + } + src.resume() + self.source = src } - let src = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.extend, .write, .rename, .delete], - queue: queue - ) - src.setEventHandler { [weak self] in - guard let self else { return } - let mask = src.data - if mask.contains(.rename) || mask.contains(.delete) { - // File rotated by the dylib — close and reopen the new file. - self.reopen() - return + private func reopen() { + source?.cancel() + source = nil + if fd >= 0 { + close(fd) + fd = -1 } - self.drainAvailable() - } - src.setCancelHandler { [weak self] in - guard let self else { return } - if self.fd >= 0 { - close(self.fd) - self.fd = -1 + pending.removeAll(keepingCapacity: true) + // Small delay lets the dylib finish the rename; then start fresh. + queue.asyncAfter(deadline: .now() + 0.25) { [weak self] in + self?.openAndStart() } } - src.resume() - self.source = src - } - private func reopen() { - source?.cancel() - source = nil - if fd >= 0 { - close(fd) - fd = -1 - } - pending.removeAll(keepingCapacity: true) - // Small delay lets the dylib finish the rename; then start fresh. - queue.asyncAfter(deadline: .now() + 0.25) { [weak self] in - self?.openAndStart() - } - } - - private func drainAvailable() { - guard fd >= 0 else { return } - var buffer = Data(count: 8192) - while true { - let n = buffer.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) -> Int in - guard let base = raw.baseAddress else { return -1 } - return read(fd, base, raw.count) + private func drainAvailable() { + guard fd >= 0 else { return } + var buffer = Data(count: 8192) + while true { + let n = buffer.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) -> Int in + guard let base = raw.baseAddress else { return -1 } + return read(fd, base, raw.count) + } + if n <= 0 { break } + pending.append(buffer.prefix(n)) + processPending() } - if n <= 0 { break } - pending.append(buffer.prefix(n)) - processPending() } - } - private func processPending() { - while let nl = pending.firstIndex(of: 0x0A) { - let line = pending[.. String { @@ -202,48 +211,60 @@ public struct MessageSender { } private static func runAppleScript(source: String, arguments: [String]) throws { - guard let script = NSAppleScript(source: source) else { - throw IMsgError.appleScriptFailure("Unable to compile AppleScript") - } - var errorInfo: NSDictionary? - let event = NSAppleEventDescriptor( - eventClass: AEEventClass(kASAppleScriptSuite), - eventID: AEEventID(kASSubroutineEvent), - targetDescriptor: nil, - returnID: AEReturnID(kAutoGenerateReturnID), - transactionID: AETransactionID(kAnyTransactionID) - ) - event.setParam( - NSAppleEventDescriptor(string: "run"), forKeyword: AEKeyword(keyASSubroutineName)) - let list = NSAppleEventDescriptor.list() - for (index, value) in arguments.enumerated() { - list.insert(NSAppleEventDescriptor(string: value), at: index + 1) - } - event.setParam(list, forKeyword: keyDirectObject) - script.executeAppleEvent(event, error: &errorInfo) - if let errorInfo { - if shouldFallbackToOsascript(errorInfo: errorInfo) { - try runOsascript(source: source, arguments: arguments) - return + #if os(macOS) + guard let script = NSAppleScript(source: source) else { + throw IMsgError.appleScriptFailure("Unable to compile AppleScript") } - let message = - (errorInfo[NSAppleScript.errorMessage] as? String) ?? "Unknown AppleScript error" - throw IMsgError.appleScriptFailure(message) - } + var errorInfo: NSDictionary? + let event = NSAppleEventDescriptor( + eventClass: AEEventClass(kASAppleScriptSuite), + eventID: AEEventID(kASSubroutineEvent), + targetDescriptor: nil, + returnID: AEReturnID(kAutoGenerateReturnID), + transactionID: AETransactionID(kAnyTransactionID) + ) + event.setParam( + NSAppleEventDescriptor(string: "run"), forKeyword: AEKeyword(keyASSubroutineName)) + let list = NSAppleEventDescriptor.list() + for (index, value) in arguments.enumerated() { + list.insert(NSAppleEventDescriptor(string: value), at: index + 1) + } + event.setParam(list, forKeyword: keyDirectObject) + script.executeAppleEvent(event, error: &errorInfo) + if let errorInfo { + if shouldFallbackToOsascript(errorInfo: errorInfo) { + try runOsascript(source: source, arguments: arguments) + return + } + let message = + (errorInfo[NSAppleScript.errorMessage] as? String) ?? "Unknown AppleScript error" + throw IMsgError.appleScriptFailure(message) + } + #else + _ = source + _ = arguments + throw IMsgError.appleScriptFailure( + "Sending requires Messages.app automation and is only supported on macOS.") + #endif } private static func shouldFallbackToOsascript(errorInfo: NSDictionary) -> Bool { - if let errorNumber = errorInfo[NSAppleScript.errorNumber] as? Int, errorNumber == -1743 { - return true - } - if errorInfo[NSAppleScript.errorMessage] == nil { - return true - } - if let message = errorInfo[NSAppleScript.errorMessage] as? String { - let lower = message.lowercased() - return lower.contains("not authorized") || lower.contains("not authorised") - } - return false + #if os(macOS) + if let errorNumber = errorInfo[NSAppleScript.errorNumber] as? Int, errorNumber == -1743 { + return true + } + if errorInfo[NSAppleScript.errorMessage] == nil { + return true + } + if let message = errorInfo[NSAppleScript.errorMessage] as? String { + let lower = message.lowercased() + return lower.contains("not authorized") || lower.contains("not authorised") + } + return false + #else + _ = errorInfo + return false + #endif } private static func runOsascript(source: String, arguments: [String]) throws { diff --git a/Sources/IMsgCore/MessageWatcher.swift b/Sources/IMsgCore/MessageWatcher.swift index 08ed1b5..390bce9 100644 --- a/Sources/IMsgCore/MessageWatcher.swift +++ b/Sources/IMsgCore/MessageWatcher.swift @@ -1,6 +1,9 @@ -import Darwin import Foundation +#if os(macOS) + import Darwin +#endif + public struct MessageWatcherConfiguration: Sendable, Equatable { public var debounceInterval: TimeInterval public var fallbackPollInterval: TimeInterval? @@ -57,7 +60,9 @@ private final class WatchState: @unchecked Sendable { private let queue = DispatchQueue(label: "imsg.watch", qos: .userInitiated) private var cursor: Int64 - private var sources: [DispatchSourceFileSystemObject] = [] + #if os(macOS) + private var sources: [DispatchSourceFileSystemObject] = [] + #endif private var pending = false private var stopped = false @@ -87,12 +92,14 @@ private final class WatchState: @unchecked Sendable { } } - let paths = [store.path, store.path + "-wal", store.path + "-shm"] - for path in paths { - if let source = makeSource(path: path) { - sources.append(source) + #if os(macOS) + let paths = [store.path, store.path + "-wal", store.path + "-shm"] + for path in paths { + if let source = makeSource(path: path) { + sources.append(source) + } } - } + #endif queue.async { self.scheduleFallbackPoll() @@ -102,30 +109,34 @@ private final class WatchState: @unchecked Sendable { func stop() { queue.async { self.stopped = true - for source in self.sources { - source.cancel() - } - self.sources.removeAll() + #if os(macOS) + for source in self.sources { + source.cancel() + } + self.sources.removeAll() + #endif } } - private func makeSource(path: String) -> DispatchSourceFileSystemObject? { - let fd = open(path, O_EVTONLY) - guard fd >= 0 else { return nil } - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.write, .extend, .rename, .delete], - queue: queue - ) - source.setEventHandler { [weak self] in - self?.schedulePoll() + #if os(macOS) + private func makeSource(path: String) -> DispatchSourceFileSystemObject? { + let fd = open(path, O_EVTONLY) + guard fd >= 0 else { return nil } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .rename, .delete], + queue: queue + ) + source.setEventHandler { [weak self] in + self?.schedulePoll() + } + source.setCancelHandler { + close(fd) + } + source.resume() + return source } - source.setCancelHandler { - close(fd) - } - source.resume() - return source - } + #endif private func schedulePoll() { if stopped { return } diff --git a/Sources/IMsgCore/MessagesLauncher.swift b/Sources/IMsgCore/MessagesLauncher.swift index f34f75a..3eab6b7 100644 --- a/Sources/IMsgCore/MessagesLauncher.swift +++ b/Sources/IMsgCore/MessagesLauncher.swift @@ -1,327 +1,371 @@ import Foundation -/// Manages Messages.app lifecycle for DYLD injection. -/// -/// Kills any running Messages.app, relaunches with `DYLD_INSERT_LIBRARIES` -/// pointing to the imsg-bridge dylib, then waits for the lock file that -/// confirms the dylib is ready for commands. -public final class MessagesLauncher: @unchecked Sendable { - public static let shared = MessagesLauncher() +#if os(macOS) + /// Manages Messages.app lifecycle for DYLD injection. + /// + /// Kills any running Messages.app, relaunches with `DYLD_INSERT_LIBRARIES` + /// pointing to the imsg-bridge dylib, then waits for the lock file that + /// confirms the dylib is ready for commands. + public final class MessagesLauncher: @unchecked Sendable { + public static let shared = MessagesLauncher() - // File-based IPC paths — must match the paths in IMsgInjected.m. - // The dylib uses NSHomeDirectory() which resolves to the container path; - // from outside we construct the full container path ourselves. - private var commandFile: String { - containerPath + "/.imsg-command.json" - } + // File-based IPC paths — must match the paths in IMsgInjected.m. + // The dylib uses NSHomeDirectory() which resolves to the container path; + // from outside we construct the full container path ourselves. + private var commandFile: String { + containerPath + "/.imsg-command.json" + } - private var responseFile: String { - containerPath + "/.imsg-response.json" - } + private var responseFile: String { + containerPath + "/.imsg-response.json" + } - private var lockFile: String { - containerPath + "/.imsg-bridge-ready" - } + private var lockFile: String { + containerPath + "/.imsg-bridge-ready" + } - private var containerPath: String { - NSHomeDirectory() + "/Library/Containers/com.apple.MobileSMS/Data" - } + private var containerPath: String { + NSHomeDirectory() + "/Library/Containers/com.apple.MobileSMS/Data" + } - /// Inbox directory for v2 RPC requests (`.json` files dropped here by - /// the CLI; consumed by the dylib). - public var bridgeInboxDirectory: String { - containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/" - + IMsgBridgeProtocol.inboxDirectoryName - } + /// Inbox directory for v2 RPC requests (`.json` files dropped here by + /// the CLI; consumed by the dylib). + public var bridgeInboxDirectory: String { + containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/" + + IMsgBridgeProtocol.inboxDirectoryName + } - /// Outbox directory for v2 RPC responses (`.json` files written by - /// the dylib; consumed by the CLI). - public var bridgeOutboxDirectory: String { - containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/" - + IMsgBridgeProtocol.outboxDirectoryName - } + /// Outbox directory for v2 RPC responses (`.json` files written by + /// the dylib; consumed by the CLI). + public var bridgeOutboxDirectory: String { + containerPath + "/" + IMsgBridgeProtocol.rpcDirectoryName + "/" + + IMsgBridgeProtocol.outboxDirectoryName + } - /// Path to the dylib's append-only event log. - public var bridgeEventsFile: String { - containerPath + "/" + IMsgBridgeProtocol.eventsFileName - } + /// Path to the dylib's append-only event log. + public var bridgeEventsFile: String { + containerPath + "/" + IMsgBridgeProtocol.eventsFileName + } - private let messagesAppPath = - "/System/Applications/Messages.app/Contents/MacOS/Messages" - private let queue = DispatchQueue(label: "imsg.messages.launcher") - private let lock = NSLock() + private let messagesAppPath = + "/System/Applications/Messages.app/Contents/MacOS/Messages" + private let queue = DispatchQueue(label: "imsg.messages.launcher") + private let lock = NSLock() - /// Path to the dylib to inject. - public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib" + /// Path to the dylib to inject. + public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib" - private init() { - let possiblePaths = [ - "/usr/local/lib/imsg-bridge-helper.dylib", - ".build/release/imsg-bridge-helper.dylib", - ".build/debug/imsg-bridge-helper.dylib", - ] - for path in possiblePaths { - if FileManager.default.fileExists(atPath: path) { - self.dylibPath = path + private init() { + let possiblePaths = [ + "/usr/local/lib/imsg-bridge-helper.dylib", + ".build/release/imsg-bridge-helper.dylib", + ".build/debug/imsg-bridge-helper.dylib", + ] + for path in possiblePaths { + if FileManager.default.fileExists(atPath: path) { + self.dylibPath = path + break + } + } + } + + /// Check if Messages.app has published the bridge-ready lock file. + public func hasReadyLockFile() -> Bool { + FileManager.default.fileExists(atPath: lockFile) + } + + /// Check if Messages.app is running with our dylib (lock file exists and responds to ping). + public func isInjectedAndReady() -> Bool { + guard hasReadyLockFile() else { + return false + } + do { + let response = try sendCommandSync(action: "ping", params: [:]) + return response["success"] as? Bool == true + } catch { + return false + } + } + + /// Ensure Messages.app is running with our dylib injected. + public func ensureRunning() throws { + if isInjectedAndReady() { return } + try launchInjectedMessages() + } + + /// Ensure Messages.app is launched with the helper without touching legacy IPC. + public func ensureLaunched() throws { + if hasReadyLockFile() { return } + try launchInjectedMessages() + } + + private func launchInjectedMessages() throws { + switch Self.currentSIPStatus() { + case .disabled: break + case .enabled: + throw MessagesLauncherError.sipEnabled + case .unknown(let details): + throw MessagesLauncherError.sipStatusUnknown(details) } - } - } - /// Check if Messages.app has published the bridge-ready lock file. - public func hasReadyLockFile() -> Bool { - FileManager.default.fileExists(atPath: lockFile) - } + guard FileManager.default.fileExists(atPath: dylibPath) else { + throw MessagesLauncherError.dylibNotFound(dylibPath) + } - /// Check if Messages.app is running with our dylib (lock file exists and responds to ping). - public func isInjectedAndReady() -> Bool { - guard hasReadyLockFile() else { - return false - } - do { - let response = try sendCommandSync(action: "ping", params: [:]) - return response["success"] as? Bool == true - } catch { - return false - } - } + killMessages() + Thread.sleep(forTimeInterval: 1.0) - /// Ensure Messages.app is running with our dylib injected. - public func ensureRunning() throws { - if isInjectedAndReady() { return } - try launchInjectedMessages() - } + // Clean up stale IPC files + try? FileManager.default.removeItem(atPath: commandFile) + try? FileManager.default.removeItem(atPath: responseFile) + try? FileManager.default.removeItem(atPath: lockFile) - /// Ensure Messages.app is launched with the helper without touching legacy IPC. - public func ensureLaunched() throws { - if hasReadyLockFile() { return } - try launchInjectedMessages() - } + // Pre-create v2 RPC queue directories so the dylib can FSEvent-watch them + // immediately on startup (FSEventStream registration on a missing path + // silently fails to deliver events). + try ensureSecureQueueDirectory(bridgeInboxDirectory) + try ensureSecureQueueDirectory(bridgeOutboxDirectory) + try cleanQueueDirectory(bridgeInboxDirectory) + try cleanQueueDirectory(bridgeOutboxDirectory) - private func launchInjectedMessages() throws { - switch Self.currentSIPStatus() { - case .disabled: - break - case .enabled: - throw MessagesLauncherError.sipEnabled - case .unknown(let details): - throw MessagesLauncherError.sipStatusUnknown(details) + try launchWithInjection() + try waitForReady(timeout: 15.0) } - guard FileManager.default.fileExists(atPath: dylibPath) else { - throw MessagesLauncherError.dylibNotFound(dylibPath) - } - - killMessages() - Thread.sleep(forTimeInterval: 1.0) - - // Clean up stale IPC files - try? FileManager.default.removeItem(atPath: commandFile) - try? FileManager.default.removeItem(atPath: responseFile) - try? FileManager.default.removeItem(atPath: lockFile) - - // Pre-create v2 RPC queue directories so the dylib can FSEvent-watch them - // immediately on startup (FSEventStream registration on a missing path - // silently fails to deliver events). - try ensureSecureQueueDirectory(bridgeInboxDirectory) - try ensureSecureQueueDirectory(bridgeOutboxDirectory) - try cleanQueueDirectory(bridgeInboxDirectory) - try cleanQueueDirectory(bridgeOutboxDirectory) - - try launchWithInjection() - try waitForReady(timeout: 15.0) - } - - private func ensureSecureQueueDirectory(_ path: String) throws { - if SecurePath.hasSymlinkComponent(path) { - throw MessagesLauncherError.socketError("RPC queue path traverses a symlink: \(path)") - } - do { - try FileManager.default.createDirectory( - atPath: path, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700]) + private func ensureSecureQueueDirectory(_ path: String) throws { if SecurePath.hasSymlinkComponent(path) { - throw MessagesLauncherError.socketError( - "RPC queue path traverses a symlink (post-mkdir): \(path)") + throw MessagesLauncherError.socketError("RPC queue path traverses a symlink: \(path)") + } + do { + try FileManager.default.createDirectory( + atPath: path, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + if SecurePath.hasSymlinkComponent(path) { + throw MessagesLauncherError.socketError( + "RPC queue path traverses a symlink (post-mkdir): \(path)") + } + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], ofItemAtPath: path) + } catch let error as MessagesLauncherError { + throw error + } catch { + throw MessagesLauncherError.socketError("mkdir \(path): \(error.localizedDescription)") } - try FileManager.default.setAttributes( - [.posixPermissions: 0o700], ofItemAtPath: path) - } catch let error as MessagesLauncherError { - throw error - } catch { - throw MessagesLauncherError.socketError("mkdir \(path): \(error.localizedDescription)") } - } - private func cleanQueueDirectory(_ path: String) throws { - if SecurePath.hasSymlinkComponent(path) { - throw MessagesLauncherError.socketError("RPC queue path traverses a symlink: \(path)") + private func cleanQueueDirectory(_ path: String) throws { + if SecurePath.hasSymlinkComponent(path) { + throw MessagesLauncherError.socketError("RPC queue path traverses a symlink: \(path)") + } + let entries = try FileManager.default.contentsOfDirectory(atPath: path) + for entry in entries { + try FileManager.default.removeItem(atPath: (path as NSString).appendingPathComponent(entry)) + } } - let entries = try FileManager.default.contentsOfDirectory(atPath: path) - for entry in entries { - try FileManager.default.removeItem(atPath: (path as NSString).appendingPathComponent(entry)) + + /// Kill Messages.app if running. + public func killMessages() { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + task.arguments = ["Messages"] + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + try? task.run() + task.waitUntilExit() } - } - /// Kill Messages.app if running. - public func killMessages() { - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") - task.arguments = ["Messages"] - task.standardOutput = FileHandle.nullDevice - task.standardError = FileHandle.nullDevice - try? task.run() - task.waitUntilExit() - } - - /// Send a command asynchronously. - public func sendCommand( - action: String, params: [String: Any] - ) async throws -> [String: Any] { - try ensureRunning() - // Serialize params to JSON data to cross the Sendable boundary safely - let paramsData = try JSONSerialization.data(withJSONObject: params, options: []) - return try await withCheckedThrowingContinuation { - (continuation: CheckedContinuation<[String: Any], Error>) in - queue.async { - do { - let deserializedParams = - (try? JSONSerialization.jsonObject(with: paramsData, options: [])) - as? [String: Any] ?? [:] - let response = try self.sendCommandSync(action: action, params: deserializedParams) - continuation.resume(returning: response) - } catch { - continuation.resume(throwing: error) + /// Send a command asynchronously. + public func sendCommand( + action: String, params: [String: Any] + ) async throws -> [String: Any] { + try ensureRunning() + // Serialize params to JSON data to cross the Sendable boundary safely + let paramsData = try JSONSerialization.data(withJSONObject: params, options: []) + return try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation<[String: Any], Error>) in + queue.async { + do { + let deserializedParams = + (try? JSONSerialization.jsonObject(with: paramsData, options: [])) + as? [String: Any] ?? [:] + let response = try self.sendCommandSync(action: action, params: deserializedParams) + continuation.resume(returning: response) + } catch { + continuation.resume(throwing: error) + } } } } - } - // MARK: - Private + // MARK: - Private - private static func csrutilStatusOutput() -> String? { - let task = Process() - let output = Pipe() - task.executableURL = URL(fileURLWithPath: "/usr/bin/csrutil") - task.arguments = ["status"] - task.standardOutput = output - task.standardError = output - do { - try task.run() - } catch { - return nil - } - task.waitUntilExit() - let data = output.fileHandleForReading.readDataToEndOfFile() - guard let text = String(data: data, encoding: .utf8) else { return nil } - return text.trimmingCharacters(in: .whitespacesAndNewlines) - } - - public enum SIPStatus: Equatable, Sendable { - case enabled - case disabled - case unknown(String) - } - - public static func currentSIPStatus() -> SIPStatus { - guard let output = csrutilStatusOutput(), !output.isEmpty else { - return .unknown("Unable to run `csrutil status`.") - } - let lowered = output.lowercased() - if lowered.contains("disabled") { - return .disabled - } - if lowered.contains("enabled") { - return .enabled - } - return .unknown(output) - } - - private func launchWithInjection() throws { - let absoluteDylibPath = - dylibPath.hasPrefix("/") - ? dylibPath - : FileManager.default.currentDirectoryPath + "/" + dylibPath - - guard FileManager.default.fileExists(atPath: absoluteDylibPath) else { - throw MessagesLauncherError.dylibNotFound(absoluteDylibPath) + private static func csrutilStatusOutput() -> String? { + let task = Process() + let output = Pipe() + task.executableURL = URL(fileURLWithPath: "/usr/bin/csrutil") + task.arguments = ["status"] + task.standardOutput = output + task.standardError = output + do { + try task.run() + } catch { + return nil + } + task.waitUntilExit() + let data = output.fileHandleForReading.readDataToEndOfFile() + guard let text = String(data: data, encoding: .utf8) else { return nil } + return text.trimmingCharacters(in: .whitespacesAndNewlines) } - let task = Process() - task.executableURL = URL(fileURLWithPath: messagesAppPath) - - var environment = ProcessInfo.processInfo.environment - environment["DYLD_INSERT_LIBRARIES"] = absoluteDylibPath - task.environment = environment - - task.standardOutput = FileHandle.nullDevice - task.standardError = FileHandle.nullDevice - - do { - try task.run() - } catch { - throw MessagesLauncherError.launchFailed(error.localizedDescription) + public enum SIPStatus: Equatable, Sendable { + case enabled + case disabled + case unknown(String) } - } - private func waitForReady(timeout: TimeInterval) throws { - let deadline = Date().addingTimeInterval(timeout) + public static func currentSIPStatus() -> SIPStatus { + guard let output = csrutilStatusOutput(), !output.isEmpty else { + return .unknown("Unable to run `csrutil status`.") + } + let lowered = output.lowercased() + if lowered.contains("disabled") { + return .disabled + } + if lowered.contains("enabled") { + return .enabled + } + return .unknown(output) + } - while Date() < deadline { - if FileManager.default.fileExists(atPath: lockFile) { + private func launchWithInjection() throws { + let absoluteDylibPath = + dylibPath.hasPrefix("/") + ? dylibPath + : FileManager.default.currentDirectoryPath + "/" + dylibPath + + guard FileManager.default.fileExists(atPath: absoluteDylibPath) else { + throw MessagesLauncherError.dylibNotFound(absoluteDylibPath) + } + + let task = Process() + task.executableURL = URL(fileURLWithPath: messagesAppPath) + + var environment = ProcessInfo.processInfo.environment + environment["DYLD_INSERT_LIBRARIES"] = absoluteDylibPath + task.environment = environment + + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + + do { + try task.run() + } catch { + throw MessagesLauncherError.launchFailed(error.localizedDescription) + } + } + + private func waitForReady(timeout: TimeInterval) throws { + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + if FileManager.default.fileExists(atPath: lockFile) { + Thread.sleep(forTimeInterval: 0.5) + return + } Thread.sleep(forTimeInterval: 0.5) - return } - Thread.sleep(forTimeInterval: 0.5) + + throw MessagesLauncherError.socketTimeout } - throw MessagesLauncherError.socketTimeout - } + private func sendCommandSync( + action: String, params: [String: Any] + ) throws -> [String: Any] { + lock.lock() + defer { lock.unlock() } - private func sendCommandSync( - action: String, params: [String: Any] - ) throws -> [String: Any] { - lock.lock() - defer { lock.unlock() } + let command: [String: Any] = [ + "id": Int(Date().timeIntervalSince1970 * 1000), + "action": action, + "params": params, + ] - let command: [String: Any] = [ - "id": Int(Date().timeIntervalSince1970 * 1000), - "action": action, - "params": params, - ] + let jsonData = try JSONSerialization.data(withJSONObject: command, options: []) + try jsonData.write(to: URL(fileURLWithPath: commandFile)) - let jsonData = try JSONSerialization.data(withJSONObject: command, options: []) - try jsonData.write(to: URL(fileURLWithPath: commandFile)) + let deadline = Date().addingTimeInterval(10.0) + while Date() < deadline { + Thread.sleep(forTimeInterval: 0.05) - let deadline = Date().addingTimeInterval(10.0) - while Date() < deadline { - Thread.sleep(forTimeInterval: 0.05) - - guard - let responseData = try? Data(contentsOf: URL(fileURLWithPath: responseFile)), - responseData.count > 2 - else { continue } - - // Check if command file was cleared (indicates processing completed) - if let cmdData = try? Data(contentsOf: URL(fileURLWithPath: commandFile)), - cmdData.count <= 2 - { guard - let response = try? JSONSerialization.jsonObject(with: responseData, options: []) - as? [String: Any] - else { - throw MessagesLauncherError.invalidResponse + let responseData = try? Data(contentsOf: URL(fileURLWithPath: responseFile)), + responseData.count > 2 + else { continue } + + // Check if command file was cleared (indicates processing completed) + if let cmdData = try? Data(contentsOf: URL(fileURLWithPath: commandFile)), + cmdData.count <= 2 + { + guard + let response = try? JSONSerialization.jsonObject(with: responseData, options: []) + as? [String: Any] + else { + throw MessagesLauncherError.invalidResponse + } + // Clear response file + try? "".write(toFile: responseFile, atomically: true, encoding: .utf8) + return response } - // Clear response file - try? "".write(toFile: responseFile, atomically: true, encoding: .utf8) - return response } + + throw MessagesLauncherError.socketError("Timeout waiting for response") + } + } +#else + /// Non-macOS stub. Linux can read copied Messages databases, but there is no + /// Messages.app process, SIP state, or DYLD injection bridge to launch. + public final class MessagesLauncher: @unchecked Sendable { + public static let shared = MessagesLauncher() + + public var dylibPath: String = ".build/release/imsg-bridge-helper.dylib" + public var bridgeInboxDirectory: String { "/nonexistent/.imsg-rpc/in" } + public var bridgeOutboxDirectory: String { "/nonexistent/.imsg-rpc/out" } + public var bridgeEventsFile: String { "/nonexistent/.imsg-events.jsonl" } + + private init() {} + + public func hasReadyLockFile() -> Bool { false } + public func isInjectedAndReady() -> Bool { false } + + public func ensureRunning() throws { + throw MessagesLauncherError.launchFailed("Messages.app is only available on macOS.") } - throw MessagesLauncherError.socketError("Timeout waiting for response") + public func ensureLaunched() throws { + throw MessagesLauncherError.launchFailed("Messages.app is only available on macOS.") + } + + public func killMessages() {} + + public func sendCommand(action: String, params: [String: Any]) async throws -> [String: Any] { + _ = action + _ = params + throw MessagesLauncherError.launchFailed("Messages.app is only available on macOS.") + } + + public enum SIPStatus: Equatable, Sendable { + case enabled + case disabled + case unknown(String) + } + + public static func currentSIPStatus() -> SIPStatus { + .unknown("System Integrity Protection is a macOS-only concept.") + } } -} +#endif public enum MessagesLauncherError: Error, CustomStringConvertible { case dylibNotFound(String) diff --git a/Sources/IMsgCore/SecurePath.swift b/Sources/IMsgCore/SecurePath.swift index 05153a5..a27586b 100644 --- a/Sources/IMsgCore/SecurePath.swift +++ b/Sources/IMsgCore/SecurePath.swift @@ -1,6 +1,11 @@ -import Darwin import Foundation +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + /// Lexical-walk symlink detector. Used wherever we accept a filesystem path /// from outside the dylib (RPC inbox dir, attachment paths) and want to refuse /// any path that traverses a symbolic link, including parent components. diff --git a/Sources/IMsgCore/TypingIndicator.swift b/Sources/IMsgCore/TypingIndicator.swift index 07d2ada..6d9de64 100644 --- a/Sources/IMsgCore/TypingIndicator.swift +++ b/Sources/IMsgCore/TypingIndicator.swift @@ -1,314 +1,345 @@ import Foundation -/// Sends typing indicators for iMessage chats. -/// -/// Prefers the IMCore bridge (via DYLD injection into Messages.app) which -/// is reliable on stock macOS with SIP disabled. Falls back to direct -/// IMCore access via `dlopen` when the bridge is unavailable. -public struct TypingIndicator: Sendable { - private static let daemonConnectionTracker = DaemonConnectionTracker() +#if os(macOS) + /// Sends typing indicators for iMessage chats. + /// + /// Prefers the IMCore bridge (via DYLD injection into Messages.app) which + /// is reliable on stock macOS with SIP disabled. Falls back to direct + /// IMCore access via `dlopen` when the bridge is unavailable. + public struct TypingIndicator: Sendable { + private static let daemonConnectionTracker = DaemonConnectionTracker() - /// Start showing the typing indicator for a chat. - /// - Parameter chatIdentifier: e.g. `"iMessage;-;+14155551212"` or a chat GUID. - /// - Throws: `IMsgError.typingIndicatorFailed` if both bridge and direct IMCore fail. - public static func startTyping(chatIdentifier: String) throws { - try setTyping(chatIdentifier: chatIdentifier, isTyping: true) - } + /// Start showing the typing indicator for a chat. + /// - Parameter chatIdentifier: e.g. `"iMessage;-;+14155551212"` or a chat GUID. + /// - Throws: `IMsgError.typingIndicatorFailed` if both bridge and direct IMCore fail. + public static func startTyping(chatIdentifier: String) throws { + try setTyping(chatIdentifier: chatIdentifier, isTyping: true) + } - /// Stop showing the typing indicator for a chat. - /// - Parameter chatIdentifier: The chat identifier string. - /// - Throws: `IMsgError.typingIndicatorFailed` if both bridge and direct IMCore fail. - public static func stopTyping(chatIdentifier: String) throws { - try setTyping(chatIdentifier: chatIdentifier, isTyping: false) - } + /// Stop showing the typing indicator for a chat. + /// - Parameter chatIdentifier: The chat identifier string. + /// - Throws: `IMsgError.typingIndicatorFailed` if both bridge and direct IMCore fail. + public static func stopTyping(chatIdentifier: String) throws { + try setTyping(chatIdentifier: chatIdentifier, isTyping: false) + } - /// Show typing indicator for a duration, then automatically stop. - /// - Parameters: - /// - chatIdentifier: The chat identifier string. - /// - duration: Seconds to show the typing indicator. - public static func typeForDuration(chatIdentifier: String, duration: TimeInterval) async throws { - try await typeForDuration( - chatIdentifier: chatIdentifier, - duration: duration, - startTyping: { try startTyping(chatIdentifier: $0) }, - stopTyping: { try stopTyping(chatIdentifier: $0) }, - sleep: { try await Task.sleep(nanoseconds: $0) } - ) - } + /// Show typing indicator for a duration, then automatically stop. + /// - Parameters: + /// - chatIdentifier: The chat identifier string. + /// - duration: Seconds to show the typing indicator. + public static func typeForDuration(chatIdentifier: String, duration: TimeInterval) async throws + { + try await typeForDuration( + chatIdentifier: chatIdentifier, + duration: duration, + startTyping: { try startTyping(chatIdentifier: $0) }, + stopTyping: { try stopTyping(chatIdentifier: $0) }, + sleep: { try await Task.sleep(nanoseconds: $0) } + ) + } - // MARK: - Private + // MARK: - Private - private static func setTyping(chatIdentifier: String, isTyping: Bool) throws { - // Prefer the bridge (dylib injected into Messages.app) - let bridge = IMCoreBridge.shared - if bridge.isAvailable { - do { - try setTypingViaBridge(bridge: bridge, chatIdentifier: chatIdentifier, isTyping: isTyping) - return - } catch { - // Bridge failed — fall through to direct IMCore access + private static func setTyping(chatIdentifier: String, isTyping: Bool) throws { + // Prefer the bridge (dylib injected into Messages.app) + let bridge = IMCoreBridge.shared + if bridge.isAvailable { + do { + try setTypingViaBridge(bridge: bridge, chatIdentifier: chatIdentifier, isTyping: isTyping) + return + } catch { + // Bridge failed — fall through to direct IMCore access + } + } + + // Fallback: direct IMCore access (requires AMFI disabled + XPC plist) + try setTypingDirect(chatIdentifier: chatIdentifier, isTyping: isTyping) + } + + /// Synchronous wrapper for the async bridge call using a Sendable result box. + private static func setTypingViaBridge( + bridge: IMCoreBridge, chatIdentifier: String, isTyping: Bool + ) throws { + let semaphore = DispatchSemaphore(value: 0) + let box = BridgeResultBox() + Task { @Sendable in + do { + try await bridge.setTyping(for: chatIdentifier, typing: isTyping) + } catch { + box.setError(error) + } + semaphore.signal() + } + semaphore.wait() + if let error = box.error { + throw error } } - // Fallback: direct IMCore access (requires AMFI disabled + XPC plist) - try setTypingDirect(chatIdentifier: chatIdentifier, isTyping: isTyping) - } - - /// Synchronous wrapper for the async bridge call using a Sendable result box. - private static func setTypingViaBridge( - bridge: IMCoreBridge, chatIdentifier: String, isTyping: Bool - ) throws { - let semaphore = DispatchSemaphore(value: 0) - let box = BridgeResultBox() - Task { @Sendable in - do { - try await bridge.setTyping(for: chatIdentifier, typing: isTyping) - } catch { - box.setError(error) + private static func setTypingDirect(chatIdentifier: String, isTyping: Bool) throws { + let frameworkPath = "/System/Library/PrivateFrameworks/IMCore.framework/IMCore" + guard let handle = dlopen(frameworkPath, RTLD_LAZY) else { + let error = String(cString: dlerror()) + throw IMsgError.typingIndicatorFailed( + "Failed to load IMCore framework: \(error)") } - semaphore.signal() - } - semaphore.wait() - if let error = box.error { - throw error - } - } + defer { dlclose(handle) } - private static func setTypingDirect(chatIdentifier: String, isTyping: Bool) throws { - let frameworkPath = "/System/Library/PrivateFrameworks/IMCore.framework/IMCore" - guard let handle = dlopen(frameworkPath, RTLD_LAZY) else { - let error = String(cString: dlerror()) - throw IMsgError.typingIndicatorFailed( - "Failed to load IMCore framework: \(error)") - } - defer { dlclose(handle) } + try ensureDaemonConnection() + let chat = try lookupChat(identifier: chatIdentifier) - try ensureDaemonConnection() - let chat = try lookupChat(identifier: chatIdentifier) - - let selector = sel_registerName("setLocalUserIsTyping:") - guard let method = class_getInstanceMethod(object_getClass(chat), selector) else { - throw IMsgError.typingIndicatorFailed( - "setLocalUserIsTyping: method not found on IMChat") - } - let implementation = method_getImplementation(method) - - typealias SetTypingFunc = @convention(c) (AnyObject, Selector, Bool) -> Void - let setTypingFunc = unsafeBitCast(implementation, to: SetTypingFunc.self) - setTypingFunc(chat, selector, isTyping) - } - - static func typeForDuration( - chatIdentifier: String, - duration: TimeInterval, - startTyping: (String) throws -> Void, - stopTyping: (String) throws -> Void, - sleep: (UInt64) async throws -> Void - ) async throws { - try startTyping(chatIdentifier) - var stopped = false - defer { - if !stopped { - try? stopTyping(chatIdentifier) + let selector = sel_registerName("setLocalUserIsTyping:") + guard let method = class_getInstanceMethod(object_getClass(chat), selector) else { + throw IMsgError.typingIndicatorFailed( + "setLocalUserIsTyping: method not found on IMChat") } - } - try await sleep(UInt64(duration * 1_000_000_000)) - try stopTyping(chatIdentifier) - stopped = true - } + let implementation = method_getImplementation(method) - private static func ensureDaemonConnection() throws { - guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else { - throw IMsgError.typingIndicatorFailed("IMDaemonController class not found") + typealias SetTypingFunc = @convention(c) (AnyObject, Selector, Bool) -> Void + let setTypingFunc = unsafeBitCast(implementation, to: SetTypingFunc.self) + setTypingFunc(chat, selector, isTyping) } - let sharedSel = sel_registerName("sharedInstance") - guard controllerClass.responds(to: sharedSel) else { - throw IMsgError.typingIndicatorFailed("IMDaemonController.sharedInstance not available") + static func typeForDuration( + chatIdentifier: String, + duration: TimeInterval, + startTyping: (String) throws -> Void, + stopTyping: (String) throws -> Void, + sleep: (UInt64) async throws -> Void + ) async throws { + try startTyping(chatIdentifier) + var stopped = false + defer { + if !stopped { + try? stopTyping(chatIdentifier) + } + } + try await sleep(UInt64(duration * 1_000_000_000)) + try stopTyping(chatIdentifier) + stopped = true } - guard let controller = controllerClass.perform(sharedSel)?.takeUnretainedValue() else { - throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance") - } + private static func ensureDaemonConnection() throws { + guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else { + throw IMsgError.typingIndicatorFailed("IMDaemonController class not found") + } - if hasLiveDaemonConnection(controller) { - daemonConnectionTracker.lock.lock() - daemonConnectionTracker.hasAttemptedConnection = true - daemonConnectionTracker.connectionKnownUnavailable = false - daemonConnectionTracker.lock.unlock() - return - } + let sharedSel = sel_registerName("sharedInstance") + guard controllerClass.responds(to: sharedSel) else { + throw IMsgError.typingIndicatorFailed("IMDaemonController.sharedInstance not available") + } - daemonConnectionTracker.lock.lock() - let shouldAttemptConnection = !daemonConnectionTracker.hasAttemptedConnection - if shouldAttemptConnection { - daemonConnectionTracker.hasAttemptedConnection = true - } - daemonConnectionTracker.lock.unlock() - if !shouldAttemptConnection { return } + guard let controller = controllerClass.perform(sharedSel)?.takeUnretainedValue() else { + throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance") + } - let connectSel = sel_registerName("connectToDaemon") - if controller.responds(to: connectSel) { - _ = controller.perform(connectSel) - } - - let maxAttempts = 50 - for _ in 0.. Bool { - let isConnectedSel = sel_registerName("isConnected") - guard controller.responds(to: isConnectedSel) else { return false } - guard let value = controller.perform(isConnectedSel)?.takeUnretainedValue() else { + let connectSel = sel_registerName("connectToDaemon") + if controller.responds(to: connectSel) { + _ = controller.perform(connectSel) + } + + let maxAttempts = 50 + for _ in 0.. Bool { + let isConnectedSel = sel_registerName("isConnected") + guard controller.responds(to: isConnectedSel) else { return false } + guard let value = controller.perform(isConnectedSel)?.takeUnretainedValue() else { + return false + } + if let number = value as? NSNumber { + return number.boolValue + } return false } - if let number = value as? NSNumber { - return number.boolValue - } - return false - } - private static func lookupChat(identifier: String) throws -> NSObject { - guard let registryClass = objc_getClass("IMChatRegistry") as? NSObject.Type else { - throw IMsgError.typingIndicatorFailed("IMChatRegistry class not found") - } + private static func lookupChat(identifier: String) throws -> NSObject { + guard let registryClass = objc_getClass("IMChatRegistry") as? NSObject.Type else { + throw IMsgError.typingIndicatorFailed("IMChatRegistry class not found") + } - let sharedSel = sel_registerName("sharedInstance") - guard registryClass.responds(to: sharedSel) else { - throw IMsgError.typingIndicatorFailed("IMChatRegistry.sharedInstance not available") - } + let sharedSel = sel_registerName("sharedInstance") + guard registryClass.responds(to: sharedSel) else { + throw IMsgError.typingIndicatorFailed("IMChatRegistry.sharedInstance not available") + } - guard let registry = registryClass.perform(sharedSel)?.takeUnretainedValue() as? NSObject - else { - throw IMsgError.typingIndicatorFailed("Failed to get IMChatRegistry shared instance") - } + guard let registry = registryClass.perform(sharedSel)?.takeUnretainedValue() as? NSObject + else { + throw IMsgError.typingIndicatorFailed("Failed to get IMChatRegistry shared instance") + } - let candidates = chatLookupCandidates(for: identifier) + let candidates = chatLookupCandidates(for: identifier) - let guidSel = sel_registerName("existingChatWithGUID:") - if registry.responds(to: guidSel) { - for candidate in candidates { - if let chat = registry.perform(guidSel, with: candidate)?.takeUnretainedValue() - as? NSObject - { - return chat + let guidSel = sel_registerName("existingChatWithGUID:") + if registry.responds(to: guidSel) { + for candidate in candidates { + if let chat = registry.perform(guidSel, with: candidate)?.takeUnretainedValue() + as? NSObject + { + return chat + } } } - } - let identSel = sel_registerName("existingChatWithChatIdentifier:") - if registry.responds(to: identSel) { - for candidate in candidates { - if let chat = registry.perform(identSel, with: candidate)?.takeUnretainedValue() - as? NSObject - { - return chat + let identSel = sel_registerName("existingChatWithChatIdentifier:") + if registry.responds(to: identSel) { + for candidate in candidates { + if let chat = registry.perform(identSel, with: candidate)?.takeUnretainedValue() + as? NSObject + { + return chat + } } } - } - daemonConnectionTracker.lock.lock() - let connectionKnownUnavailable = daemonConnectionTracker.connectionKnownUnavailable - daemonConnectionTracker.lock.unlock() - if connectionKnownUnavailable { - throw IMsgError.typingIndicatorFailed(daemonUnavailableMessage()) - } - - throw IMsgError.typingIndicatorFailed( - "Chat not found for identifier: \(identifier). " - + "Make sure Messages.app has an active conversation with this contact.") - } - - static func daemonUnavailableMessage() -> String { - "Failed to connect to imagent (Messages daemon) for IMCore typing indicators. " - + "On macOS 26/Tahoe, imagent can reject third-party clients without " - + "Apple-private entitlements, and Messages.app may also block the injected " - + "bridge via library validation. Run 'imsg status' and 'imsg launch' to " - + "verify advanced feature setup. Normal 'send', 'history', and 'watch' " - + "commands do not use this IMCore path." - } - - static func chatLookupCandidates(for identifier: String) -> [String] { - let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - - let bareIdentifier = stripKnownChatPrefix(trimmed) ?? trimmed - var candidates = [trimmed] - if bareIdentifier != trimmed { - candidates.append(bareIdentifier) - } - for prefix in chatIdentifierPrefixes { - candidates.append(prefix + bareIdentifier) - } - return dedupe(candidates) - } - - private static let chatIdentifierPrefixes = [ - "iMessage;-;", - "iMessage;+;", - "SMS;-;", - "SMS;+;", - "any;-;", - "any;+;", - ] - - private static func stripKnownChatPrefix(_ value: String) -> String? { - for prefix in chatIdentifierPrefixes where value.hasPrefix(prefix) { - return String(value.dropFirst(prefix.count)) - } - return nil - } - - private static func dedupe(_ values: [String]) -> [String] { - var seen = Set() - var result: [String] = [] - for value in values where !value.isEmpty { - if seen.insert(value).inserted { - result.append(value) + daemonConnectionTracker.lock.lock() + let connectionKnownUnavailable = daemonConnectionTracker.connectionKnownUnavailable + daemonConnectionTracker.lock.unlock() + if connectionKnownUnavailable { + throw IMsgError.typingIndicatorFailed(daemonUnavailableMessage()) } + + throw IMsgError.typingIndicatorFailed( + "Chat not found for identifier: \(identifier). " + + "Make sure Messages.app has an active conversation with this contact.") + } + + static func daemonUnavailableMessage() -> String { + "Failed to connect to imagent (Messages daemon) for IMCore typing indicators. " + + "On macOS 26/Tahoe, imagent can reject third-party clients without " + + "Apple-private entitlements, and Messages.app may also block the injected " + + "bridge via library validation. Run 'imsg status' and 'imsg launch' to " + + "verify advanced feature setup. Normal 'send', 'history', and 'watch' " + + "commands do not use this IMCore path." + } + + static func chatLookupCandidates(for identifier: String) -> [String] { + let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let bareIdentifier = stripKnownChatPrefix(trimmed) ?? trimmed + var candidates = [trimmed] + if bareIdentifier != trimmed { + candidates.append(bareIdentifier) + } + for prefix in chatIdentifierPrefixes { + candidates.append(prefix + bareIdentifier) + } + return dedupe(candidates) + } + + private static let chatIdentifierPrefixes = [ + "iMessage;-;", + "iMessage;+;", + "SMS;-;", + "SMS;+;", + "any;-;", + "any;+;", + ] + + private static func stripKnownChatPrefix(_ value: String) -> String? { + for prefix in chatIdentifierPrefixes where value.hasPrefix(prefix) { + return String(value.dropFirst(prefix.count)) + } + return nil + } + + private static func dedupe(_ values: [String]) -> [String] { + var seen = Set() + var result: [String] = [] + for value in values where !value.isEmpty { + if seen.insert(value).inserted { + result.append(value) + } + } + return result } - return result } -} +#else + /// Non-macOS stub. Linux can read copied databases, but typing indicators + /// require private IMCore APIs inside Messages.app. + public struct TypingIndicator: Sendable { + public static func startTyping(chatIdentifier: String) throws { + _ = chatIdentifier + throw unsupported() + } -private final class DaemonConnectionTracker: @unchecked Sendable { - let lock = NSLock() - var hasAttemptedConnection = false - var connectionKnownUnavailable = false -} + public static func stopTyping(chatIdentifier: String) throws { + _ = chatIdentifier + throw unsupported() + } -/// Thread-safe box for passing an error out of a Task back to the calling thread. -private final class BridgeResultBox: @unchecked Sendable { - private let lock = NSLock() - private var _error: Error? + public static func typeForDuration(chatIdentifier: String, duration: TimeInterval) async throws + { + _ = chatIdentifier + _ = duration + throw unsupported() + } - var error: Error? { - lock.lock() - defer { lock.unlock() } - return _error + private static func unsupported() -> IMsgError { + IMsgError.typingIndicatorFailed( + "Typing indicators require Messages.app/IMCore and are only supported on macOS.") + } + } +#endif + +#if os(macOS) + private final class DaemonConnectionTracker: @unchecked Sendable { + let lock = NSLock() + var hasAttemptedConnection = false + var connectionKnownUnavailable = false } - func setError(_ error: Error) { - lock.lock() - _error = error - lock.unlock() + /// Thread-safe box for passing an error out of a Task back to the calling thread. + private final class BridgeResultBox: @unchecked Sendable { + private let lock = NSLock() + private var _error: Error? + + var error: Error? { + lock.lock() + defer { lock.unlock() } + return _error + } + + func setError(_ error: Error) { + lock.lock() + _error = error + lock.unlock() + } } -} +#endif diff --git a/TestsLinux/LinuxReadCoreTests.swift b/TestsLinux/LinuxReadCoreTests.swift new file mode 100644 index 0000000..eaba161 --- /dev/null +++ b/TestsLinux/LinuxReadCoreTests.swift @@ -0,0 +1,163 @@ +import Foundation +import SQLite +import Testing + +@testable import IMsgCore + +@Test +func readsMessageDatabaseFromCopiedFile() throws { + let databaseURL = try makeTemporaryDatabase() + try seedDatabase(at: databaseURL) + + let store = try MessageStore(path: databaseURL.path) + + let chats = try store.listChats(limit: 10) + #expect(chats.count == 1) + #expect(chats.first?.identifier == "+15551234567") + #expect(chats.first?.name == "Linux Fixture") + + let messages = try store.messages(chatID: 1, limit: 10) + #expect(messages.map(\.text) == ["reply from mac", "hello from linux"]) + #expect(messages.first?.isFromMe == true) + #expect(messages.last?.sender == "+15551234567") + #expect(messages.last?.isFromMe == false) + + let matches = try store.searchMessages(query: "reply", match: "contains", limit: 5) + #expect(matches.count == 1) + #expect(matches.first?.text == "reply from mac") +} + +@Test +func linuxContactResolverIsExplicitlyUnavailable() async { + let resolver = await ContactResolver.create(region: "US") + #expect(resolver.contactsUnavailable) + #expect(resolver.displayName(for: "+15551234567") == nil) + #expect(resolver.displayNames(for: ["+15551234567"]).isEmpty) + #expect(resolver.searchByName("Jane").isEmpty) +} + +@Test +func linuxSendFailsWithPlatformMessage() throws { + let sender = MessageSender() + + do { + try sender.send(MessageSendOptions(recipient: "+15551234567", text: "no-op")) + Issue.record("send unexpectedly succeeded on Linux") + } catch let error as IMsgError { + #expect(error.description.contains("only supported on macOS")) + } +} + +@Test +func linuxTypingIndicatorFailsWithPlatformMessage() throws { + do { + try TypingIndicator.startTyping(chatIdentifier: "iMessage;-;+15551234567") + Issue.record("typing unexpectedly succeeded on Linux") + } catch let error as IMsgError { + #expect(error.description.contains("only supported on macOS")) + } +} + +private func makeTemporaryDatabase() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "imsg-linux-tests-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent("chat.db") +} + +private func seedDatabase(at url: URL) throws { + let db = try Connection(url.path) + try createSchema(db) + + let now = Date() + try db.run( + """ + INSERT INTO chat( + ROWID, chat_identifier, guid, display_name, service_name, + account_id, account_login, last_addressed_handle + ) + VALUES ( + 1, '+15551234567', 'iMessage;+;linux-fixture', 'Linux Fixture', 'iMessage', + 'iMessage;+;me@example.com', 'me@example.com', '+15551234567' + ) + """ + ) + try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+15551234567'), (2, 'Me')") + try db.run("INSERT INTO chat_handle_join(chat_id, handle_id) VALUES (1, 1), (1, 2)") + + let rows: [(Int64, Int64, String, Bool, Date)] = [ + (1, 1, "hello from linux", false, now.addingTimeInterval(-60)), + (2, 2, "reply from mac", true, now), + ] + for row in rows { + try db.run( + """ + INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service) + VALUES (?, ?, ?, ?, ?, 'iMessage') + """, + row.0, + row.1, + row.2, + appleEpoch(row.4), + row.3 ? 1 : 0 + ) + try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, ?)", row.0) + } +} + +private func createSchema(_ db: Connection) throws { + try db.execute( + """ + CREATE TABLE message ( + ROWID INTEGER PRIMARY KEY, + handle_id INTEGER, + text TEXT, + guid TEXT, + associated_message_guid TEXT, + associated_message_type INTEGER, + date INTEGER, + is_from_me INTEGER, + service TEXT + ); + """ + ) + try db.execute( + """ + CREATE TABLE chat ( + ROWID INTEGER PRIMARY KEY, + chat_identifier TEXT, + guid TEXT, + display_name TEXT, + service_name TEXT, + account_id TEXT, + account_login TEXT, + last_addressed_handle TEXT + ); + """ + ) + try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);") + try db.execute("CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);") + try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);") + try db.execute( + """ + CREATE TABLE attachment ( + ROWID INTEGER PRIMARY KEY, + filename TEXT, + transfer_name TEXT, + uti TEXT, + mime_type TEXT, + total_bytes INTEGER, + is_sticker INTEGER + ); + """ + ) + try db.execute( + "CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);") +} + +private func appleEpoch(_ date: Date) -> Int64 { + let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset + return Int64(seconds * 1_000_000_000) +} diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 2fa24f0..11dc219 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -16,10 +16,13 @@ description: "Cutting an imsg release: changelog, version bump, signed/notarized 2. Ensure CI is green on `main` - `make lint` - `make test` + - GitHub Actions `linux-read-core` - `make format` (optional, if formatting changes are expected) 3. Build, sign, and notarize - Requires `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`. - `scripts/sign-and-notarize.sh` (outputs `/tmp/imsg-macos.zip` by default) + - Linux release archives are built by `.github/workflows/release.yml` with + `scripts/build-linux.sh` and uploaded as `imsg-linux-x86_64.tar.gz`. - Verify the zip contains required SwiftPM bundles (e.g. `PhoneNumberKit_PhoneNumberKit.bundle`). - Verify entitlements/signing: - `unzip -q /tmp/imsg-macos.zip -d /tmp/imsg-check` @@ -29,6 +32,9 @@ description: "Cutting an imsg release: changelog, version bump, signed/notarized - `git tag -a vX.Y.Z -m "vX.Y.Z"` - `git push origin vX.Y.Z` - `gh release create vX.Y.Z /tmp/imsg-macos.zip -t "vX.Y.Z" -F /tmp/release-notes.txt` + - Run `.github/workflows/release.yml` for the tag to upload the Linux archive + (`imsg-linux-x86_64.tar.gz`). Leave `include_macos` off unless you + intentionally want a manual macOS rebuild. - `gh release edit vX.Y.Z --notes-file /tmp/release-notes.txt` (if needed) 5. Update Homebrew tap - Run `scripts/update-homebrew.sh vX.Y.Z` to trigger the centralized formula updater. @@ -37,3 +43,14 @@ description: "Cutting an imsg release: changelog, version bump, signed/notarized ## What happens in CI - Release signing + notarization are done locally via `scripts/sign-and-notarize.sh`. - `.github/workflows/release.yml` is only for manual rebuilds, not the primary release path. + +## Linux support schedule +- The next patch release may include an experimental Linux `x86_64` archive, + but the user-facing docs should still describe Linux as read-only preview + support until install and packaging are proven on a tagged release. +- Linux work is staged as a read-only core pass: SwiftPM build, Linux-only tests, + release archive generation, and CI coverage for reading a copied Messages + database fixture. +- Promote Linux to full user docs only after the read-only command set is green + in CI, Crabbox has verified the same gate on Linux, and install instructions + exist for the published archive. diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh new file mode 100755 index 0000000..0dfa81f --- /dev/null +++ b/scripts/build-linux.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +APP_NAME="imsg" +OUTPUT_DIR="${OUTPUT_DIR:-${ROOT}/dist}" +BUILD_MODE=${BUILD_MODE:-release} +TARGET_TRIPLE=$(swift -print-target-info | python3 -c 'import json,sys; print(json.load(sys.stdin)["target"]["triple"])') +BUILD_DIR="${ROOT}/.build/${TARGET_TRIPLE}/${BUILD_MODE}" +ARCHIVE_NAME="${APP_NAME}-linux-x86_64.tar.gz" +DIST_DIR="$(mktemp -d "/tmp/${APP_NAME}-linux.XXXXXX")" + +cleanup() { + rm -rf "$DIST_DIR" +} +trap cleanup EXIT + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "scripts/build-linux.sh must run on Linux." >&2 + exit 1 +fi + +swift build -c "$BUILD_MODE" --product "$APP_NAME" + +cp "${BUILD_DIR}/${APP_NAME}" "${DIST_DIR}/${APP_NAME}" +for bundle in "${BUILD_DIR}"/*.bundle; do + if [[ -e "$bundle" ]]; then + cp -R "$bundle" "$DIST_DIR/" + fi +done + +mkdir -p "$OUTPUT_DIR" +tar -C "$DIST_DIR" -czf "${OUTPUT_DIR}/${ARCHIVE_NAME}" . + +echo "Built ${OUTPUT_DIR}/${ARCHIVE_NAME}"