feat: add linux read-only build (#106)
This commit is contained in:
parent
788f9f2a4b
commit
e833e0c898
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
60
.github/workflows/release.yml
vendored
60
.github/workflows/release.yml
vendored
@ -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 }}
|
||||
|
||||
@ -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
|
||||
|
||||
4
Makefile
4
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:
|
||||
|
||||
127
Package.swift
127
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
|
||||
}()
|
||||
)
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
163
TestsLinux/LinuxReadCoreTests.swift
Normal file
163
TestsLinux/LinuxReadCoreTests.swift
Normal 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)
|
||||
}
|
||||
@ -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
35
scripts/build-linux.sh
Executable 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}"
|
||||
Loading…
Reference in New Issue
Block a user