feat: add linux read-only build (#106)
Some checks are pending
CI / macos (push) Waiting to run
CI / linux-read-core (push) Waiting to run
pages / Deploy docs (push) Waiting to run

This commit is contained in:
Peter Steinberger 2026-05-07 01:29:26 +01:00 committed by GitHub
parent 788f9f2a4b
commit e833e0c898
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1382 additions and 886 deletions

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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:

View File

@ -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
}()
)

View File

@ -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 =

View File

@ -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

View File

@ -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<Event>.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[..<nl]
pending.removeSubrange(...nl)
guard !line.isEmpty else { continue }
guard
let obj = try? JSONSerialization.jsonObject(with: line, options: [])
as? [String: Any]
else { continue }
let name = (obj["event"] as? String) ?? "unknown"
let ts = obj["ts"] as? String
let data = (obj["data"] as? [String: Any]) ?? [:]
let payloadData = (try? JSONSerialization.data(withJSONObject: data, options: [])) ?? Data()
continuation?.yield(Event(timestamp: ts, name: name, payloadJSON: payloadData))
private func processPending() {
while let nl = pending.firstIndex(of: 0x0A) {
let line = pending[..<nl]
pending.removeSubrange(...nl)
guard !line.isEmpty else { continue }
guard
let obj = try? JSONSerialization.jsonObject(with: line, options: [])
as? [String: Any]
else { continue }
let name = (obj["event"] as? String) ?? "unknown"
let ts = obj["ts"] as? String
let data = (obj["data"] as? [String: Any]) ?? [:]
let payloadData = (try? JSONSerialization.data(withJSONObject: data, options: [])) ?? Data()
continuation?.yield(Event(timestamp: ts, name: name, payloadJSON: payloadData))
}
}
}
#endif
}

View File

@ -1,6 +1,9 @@
import Carbon
import Foundation
#if os(macOS)
import Carbon
#endif
public enum MessageService: String, Sendable, CaseIterable {
case auto
case imessage
@ -62,20 +65,26 @@ public struct MessageSender {
}
public func send(_ options: MessageSendOptions) throws {
var resolved = options
let chatTarget = resolveChatTarget(&resolved)
let useChat = !chatTarget.isEmpty
if useChat == false {
if resolved.region.isEmpty { resolved.region = "US" }
resolved.recipient = normalizer.normalize(resolved.recipient, region: resolved.region)
if resolved.service == .auto { resolved.service = .imessage }
}
#if !os(macOS)
_ = options
throw IMsgError.appleScriptFailure(
"Sending requires Messages.app automation and is only supported on macOS.")
#else
var resolved = options
let chatTarget = resolveChatTarget(&resolved)
let useChat = !chatTarget.isEmpty
if useChat == false {
if resolved.region.isEmpty { resolved.region = "US" }
resolved.recipient = normalizer.normalize(resolved.recipient, region: resolved.region)
if resolved.service == .auto { resolved.service = .imessage }
}
if resolved.attachmentPath.isEmpty == false {
resolved.attachmentPath = try stageAttachment(at: resolved.attachmentPath)
}
if resolved.attachmentPath.isEmpty == false {
resolved.attachmentPath = try stageAttachment(at: resolved.attachmentPath)
}
try sendViaAppleScript(resolved, chatTarget: chatTarget, useChat: useChat)
try sendViaAppleScript(resolved, chatTarget: chatTarget, useChat: useChat)
#endif
}
private func stageAttachment(at path: String) throws -> 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 {

View File

@ -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 }

View File

@ -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 (`<uuid>.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 (`<uuid>.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 (`<uuid>.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 (`<uuid>.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)

View File

@ -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.

View File

@ -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..<maxAttempts {
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.hasAttemptedConnection = true
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
Thread.sleep(forTimeInterval: 0.1)
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01))
}
if !hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = true
let shouldAttemptConnection = !daemonConnectionTracker.hasAttemptedConnection
if shouldAttemptConnection {
daemonConnectionTracker.hasAttemptedConnection = true
}
daemonConnectionTracker.lock.unlock()
throw IMsgError.typingIndicatorFailed(
daemonUnavailableMessage()
)
}
}
if !shouldAttemptConnection { return }
private static func hasLiveDaemonConnection(_ controller: AnyObject) -> 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..<maxAttempts {
if hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = false
daemonConnectionTracker.lock.unlock()
return
}
Thread.sleep(forTimeInterval: 0.1)
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01))
}
if !hasLiveDaemonConnection(controller) {
daemonConnectionTracker.lock.lock()
daemonConnectionTracker.connectionKnownUnavailable = true
daemonConnectionTracker.lock.unlock()
throw IMsgError.typingIndicatorFailed(
daemonUnavailableMessage()
)
}
}
private static func hasLiveDaemonConnection(_ controller: AnyObject) -> 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<String>()
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<String>()
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

View File

@ -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)
}

View File

@ -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.

35
scripts/build-linux.sh Executable file
View File

@ -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}"