feat: add advanced message controls
This commit is contained in:
parent
c9fa1c2003
commit
bf2d02e5e7
62
.agents/skills/imsg/SKILL.md
Normal file
62
.agents/skills/imsg/SKILL.md
Normal file
@ -0,0 +1,62 @@
|
||||
---
|
||||
name: imsg
|
||||
description: Use for local iMessage/SMS archive reads, chat history, watch, and explicitly requested sends.
|
||||
---
|
||||
|
||||
# imsg
|
||||
|
||||
Use this for Messages.app history, chat lookup, streaming, and sends. Reading is local DB access; sending uses Messages automation and must be explicitly requested.
|
||||
|
||||
## Sources
|
||||
|
||||
- DB: `~/Library/Messages/chat.db`
|
||||
- Repo: `~/Projects/imsg`
|
||||
- CLI: `imsg`
|
||||
- JSON output is NDJSON; pipe to `jq -s` for arrays.
|
||||
|
||||
## Read Workflow
|
||||
|
||||
Check DB access:
|
||||
|
||||
```bash
|
||||
sqlite3 ~/Library/Messages/chat.db 'pragma quick_check;'
|
||||
```
|
||||
|
||||
List chats:
|
||||
|
||||
```bash
|
||||
imsg chats --json | jq -s
|
||||
```
|
||||
|
||||
Read a chat:
|
||||
|
||||
```bash
|
||||
imsg history --chat-id ID --json | jq -s
|
||||
```
|
||||
|
||||
Use `--attachments` when attachment metadata matters. Use `--start`/`--end` with absolute timestamps for date-scoped questions.
|
||||
|
||||
## Sends
|
||||
|
||||
Only send, react, mark read, or show typing when the user explicitly asks. Prefer dry wording in the final confirmation: recipient, service, and what was sent.
|
||||
|
||||
Common send command:
|
||||
|
||||
```bash
|
||||
imsg send --to "+15551234567" --text "message" --service auto
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
For repo edits:
|
||||
|
||||
```bash
|
||||
make test
|
||||
make build
|
||||
```
|
||||
|
||||
For live read proof:
|
||||
|
||||
```bash
|
||||
imsg chats --limit 3 --json | jq -s
|
||||
```
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -39,6 +39,7 @@ Package.resolved
|
||||
|
||||
# Build artifacts
|
||||
bin/
|
||||
slides/
|
||||
|
||||
# Node.js / pnpm
|
||||
pnpm-lock.yaml
|
||||
|
||||
29
Makefile
29
Makefile
@ -1,15 +1,16 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: help format lint test build imsg clean
|
||||
.PHONY: help format lint test build imsg clean build-dylib
|
||||
|
||||
help:
|
||||
@printf "%s\n" \
|
||||
"make format - swift format in-place" \
|
||||
"make lint - swift format lint + swiftlint" \
|
||||
"make test - sync version, patch deps, run swift test" \
|
||||
"make build - universal release build into bin/" \
|
||||
"make imsg - clean rebuild + run debug binary (ARGS=...)" \
|
||||
"make clean - swift package clean"
|
||||
"make format - swift format in-place" \
|
||||
"make lint - swift format lint + swiftlint" \
|
||||
"make test - sync version, patch deps, run swift test" \
|
||||
"make build - universal release build into bin/" \
|
||||
"make build-dylib - build injectable dylib for Messages.app" \
|
||||
"make imsg - clean rebuild + run debug binary (ARGS=...)" \
|
||||
"make clean - swift package clean"
|
||||
|
||||
format:
|
||||
swift format --in-place --recursive Sources Tests
|
||||
@ -30,6 +31,19 @@ build:
|
||||
scripts/patch-deps.sh
|
||||
scripts/build-universal.sh
|
||||
|
||||
# Build injectable dylib for Messages.app (DYLD_INSERT_LIBRARIES).
|
||||
# Uses arm64e architecture to match Messages.app on Apple Silicon.
|
||||
# Requires SIP disabled on the target machine to inject into system apps.
|
||||
build-dylib:
|
||||
@echo "Building imsg-bridge-helper.dylib (injectable)..."
|
||||
@mkdir -p .build/release
|
||||
@clang -dynamiclib -arch arm64e -fobjc-arc \
|
||||
-Wno-arc-performSelector-leaks \
|
||||
-framework Foundation \
|
||||
-o .build/release/imsg-bridge-helper.dylib \
|
||||
Sources/IMsgHelper/IMsgInjected.m
|
||||
@echo "Built .build/release/imsg-bridge-helper.dylib"
|
||||
|
||||
imsg:
|
||||
scripts/generate-version.sh
|
||||
swift package resolve
|
||||
@ -40,3 +54,4 @@ imsg:
|
||||
|
||||
clean:
|
||||
swift package clean
|
||||
@rm -f .build/release/imsg-bridge-helper.dylib
|
||||
|
||||
34
README.md
34
README.md
@ -10,6 +10,7 @@ A macOS Messages.app CLI to send, read, and stream iMessage/SMS (with attachment
|
||||
- Filters: participants, start/end time, JSON output for tooling.
|
||||
- Read-only DB access (`mode=ro`), no DB writes.
|
||||
- Event-driven watch via filesystem events.
|
||||
- Optional advanced IMCore features (`typing`, `launch`, `status`) behind explicit SIP-off setup.
|
||||
|
||||
## Requirements
|
||||
- macOS 14+ with Messages.app signed in.
|
||||
@ -28,6 +29,10 @@ make build
|
||||
- `imsg history --chat-id <id> [--limit 50] [--attachments] [--participants +15551234567,...] [--start 2025-01-01T00:00:00Z] [--end 2025-02-01T00:00:00Z] [--json]`
|
||||
- `imsg watch [--chat-id <id>] [--since-rowid <n>] [--debounce 250ms] [--attachments] [--participants …] [--start …] [--end …] [--json]`
|
||||
- `imsg send --to <handle> [--text "hi"] [--file /path/img.jpg] [--service imessage|sms|auto] [--region US]`
|
||||
- `imsg read --to <handle> [--chat-id <id> | --chat-identifier <id> | --chat-guid <guid>]`
|
||||
- `imsg typing --to <handle> [--duration 5s] [--stop true] [--service imessage|sms|auto]`
|
||||
- `imsg status [--json]` — advanced feature and SIP status
|
||||
- `imsg launch [--dylib <path>] [--kill-only] [--json]`
|
||||
|
||||
### Quick samples
|
||||
```
|
||||
@ -48,6 +53,18 @@ imsg watch --chat-id 1 --attachments --debounce 250ms
|
||||
|
||||
# send a picture
|
||||
imsg send --to "+14155551212" --text "hi" --file ~/Desktop/pic.jpg --service imessage
|
||||
|
||||
# mark a chat as read
|
||||
imsg read --to "+14155551212"
|
||||
|
||||
# advanced status check
|
||||
imsg status
|
||||
|
||||
# launch Messages with injection (SIP must be disabled first)
|
||||
imsg launch
|
||||
|
||||
# show typing indicator for 5s
|
||||
imsg typing --to "+14155551212" --duration 5s
|
||||
```
|
||||
|
||||
## Attachment notes
|
||||
@ -65,6 +82,23 @@ If you see “unable to open database file” or empty output:
|
||||
2) Ensure Messages.app is signed in and `~/Library/Messages/chat.db` exists.
|
||||
3) For send, allow the terminal under System Settings → Privacy & Security → Automation → Messages.
|
||||
|
||||
## Advanced Features (SIP-Off Only)
|
||||
Advanced features (`typing`, `launch`, IMCore bridge) require injecting a helper dylib into `Messages.app`.
|
||||
|
||||
Important:
|
||||
- This is opt-in only. Default send/history/watch flows do not need injection.
|
||||
- `imsg launch` refuses to inject when SIP is enabled.
|
||||
- `imsg status` is read-only and does not auto-launch or auto-inject.
|
||||
|
||||
Setup:
|
||||
1) Disable SIP from Recovery mode: `csrutil disable`
|
||||
2) Grant Full Disk Access to your terminal
|
||||
3) Build helper dylib: `make build-dylib`
|
||||
4) Launch with injection: `imsg launch`
|
||||
5) Verify: `imsg status`
|
||||
|
||||
To revert after testing, re-enable SIP in Recovery mode: `csrutil enable`.
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
make test
|
||||
|
||||
@ -6,6 +6,7 @@ public enum IMsgError: LocalizedError, Sendable {
|
||||
case invalidService(String)
|
||||
case invalidChatTarget(String)
|
||||
case appleScriptFailure(String)
|
||||
case typingIndicatorFailed(String)
|
||||
case invalidReaction(String)
|
||||
case chatNotFound(chatID: Int64)
|
||||
|
||||
@ -36,6 +37,8 @@ public enum IMsgError: LocalizedError, Sendable {
|
||||
return "Invalid chat target: \(value)"
|
||||
case .appleScriptFailure(let message):
|
||||
return "AppleScript failed: \(message)"
|
||||
case .typingIndicatorFailed(let message):
|
||||
return "Typing indicator failed: \(message)"
|
||||
case .invalidReaction(let value):
|
||||
return """
|
||||
Invalid reaction: \(value)
|
||||
|
||||
168
Sources/IMsgCore/IMCoreBridge.swift
Normal file
168
Sources/IMsgCore/IMCoreBridge.swift
Normal file
@ -0,0 +1,168 @@
|
||||
import Foundation
|
||||
|
||||
public enum IMCoreBridgeError: Error, CustomStringConvertible {
|
||||
case dylibNotFound
|
||||
case connectionFailed(String)
|
||||
case chatNotFound(String)
|
||||
case operationFailed(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .dylibNotFound:
|
||||
return "imsg-bridge-helper.dylib not found. Build with: make build-dylib"
|
||||
case .connectionFailed(let error):
|
||||
return "Connection to Messages.app failed: \(error)"
|
||||
case .chatNotFound(let id):
|
||||
return "Chat not found: \(id)"
|
||||
case .operationFailed(let reason):
|
||||
return "Operation failed: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge to IMCore via DYLD injection into Messages.app.
|
||||
///
|
||||
/// Communicates with an injected dylib inside Messages.app via file-based IPC.
|
||||
/// The dylib has full access to IMCore because it runs within the Messages.app
|
||||
/// context with proper entitlements.
|
||||
///
|
||||
/// Requires:
|
||||
/// - SIP disabled (for `DYLD_INSERT_LIBRARIES` on system apps)
|
||||
/// - The `imsg-bridge-helper.dylib` built via `make build-dylib`
|
||||
public final class IMCoreBridge: @unchecked Sendable {
|
||||
public static let shared = IMCoreBridge()
|
||||
|
||||
private let launcher = MessagesLauncher.shared
|
||||
|
||||
/// Whether the dylib exists on disk (does not check if Messages.app is running).
|
||||
public var isAvailable: Bool {
|
||||
let possiblePaths = [
|
||||
"/usr/local/lib/imsg-bridge-helper.dylib",
|
||||
".build/release/imsg-bridge-helper.dylib",
|
||||
".build/debug/imsg-bridge-helper.dylib",
|
||||
]
|
||||
return possiblePaths.contains { FileManager.default.fileExists(atPath: $0) }
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Commands
|
||||
|
||||
/// Set typing indicator for a conversation.
|
||||
public func setTyping(for handle: String, typing: Bool) async throws {
|
||||
let params: [String: Any] = [
|
||||
"handle": handle,
|
||||
"typing": typing,
|
||||
]
|
||||
_ = try await sendCommand(action: "typing", params: params)
|
||||
}
|
||||
|
||||
/// Mark all messages as read in a conversation.
|
||||
public func markAsRead(handle: String) async throws {
|
||||
_ = try await sendCommand(action: "read", params: ["handle": handle])
|
||||
}
|
||||
|
||||
/// List all available chats (for debugging).
|
||||
public func listChats() async throws -> [[String: Any]] {
|
||||
let response = try await sendCommand(action: "list_chats", params: [:])
|
||||
return response["chats"] as? [[String: Any]] ?? []
|
||||
}
|
||||
|
||||
/// Get detailed status from the injected helper.
|
||||
public func getStatus() async throws -> [String: Any] {
|
||||
return try await sendCommand(action: "status", params: [:])
|
||||
}
|
||||
|
||||
/// Check availability and return a diagnostic message.
|
||||
public func checkAvailability() -> (available: Bool, message: String) {
|
||||
let possiblePaths = [
|
||||
"/usr/local/lib/imsg-bridge-helper.dylib",
|
||||
".build/release/imsg-bridge-helper.dylib",
|
||||
".build/debug/imsg-bridge-helper.dylib",
|
||||
]
|
||||
|
||||
var dylibPath: String?
|
||||
for path in possiblePaths {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
dylibPath = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard dylibPath != nil else {
|
||||
return (
|
||||
false,
|
||||
"""
|
||||
imsg-bridge-helper.dylib not found. To build:
|
||||
1. make build-dylib
|
||||
2. Restart imsg
|
||||
|
||||
Note: Advanced features require:
|
||||
- SIP disabled (for DYLD injection)
|
||||
- Full Disk Access granted to Terminal
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
switch MessagesLauncher.currentSIPStatus() {
|
||||
case .enabled:
|
||||
return (
|
||||
false,
|
||||
"""
|
||||
System Integrity Protection (SIP) is enabled.
|
||||
Advanced IMCore features are intentionally disabled.
|
||||
|
||||
To enable advanced features:
|
||||
1. Disable SIP in Recovery mode (`csrutil disable`)
|
||||
2. Run `make build-dylib`
|
||||
3. Run `imsg launch`
|
||||
"""
|
||||
)
|
||||
case .unknown(let details):
|
||||
return (
|
||||
false,
|
||||
"""
|
||||
Unable to determine SIP status. Refusing to auto-inject Messages.app.
|
||||
Details: \(details)
|
||||
"""
|
||||
)
|
||||
case .disabled:
|
||||
break
|
||||
}
|
||||
|
||||
if launcher.isInjectedAndReady() {
|
||||
return (true, "Connected to Messages.app. IMCore features available.")
|
||||
}
|
||||
|
||||
return (
|
||||
false,
|
||||
"""
|
||||
SIP is disabled and the helper dylib is present, but Messages.app is not currently injected.
|
||||
Run `imsg launch` to enable advanced IMCore features.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func sendCommand(
|
||||
action: String, params: [String: Any]
|
||||
) async throws -> [String: Any] {
|
||||
do {
|
||||
let response = try await launcher.sendCommand(action: action, params: params)
|
||||
|
||||
if response["success"] as? Bool == true {
|
||||
return response
|
||||
}
|
||||
|
||||
let error = response["error"] as? String ?? "Unknown error"
|
||||
if error.contains("Chat not found") {
|
||||
let handle = params["handle"] as? String ?? "unknown"
|
||||
throw IMCoreBridgeError.chatNotFound(handle)
|
||||
}
|
||||
throw IMCoreBridgeError.operationFailed(error)
|
||||
} catch let error as MessagesLauncherError {
|
||||
throw IMCoreBridgeError.connectionFailed(error.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
288
Sources/IMsgCore/MessagesLauncher.swift
Normal file
288
Sources/IMsgCore/MessagesLauncher.swift
Normal file
@ -0,0 +1,288 @@
|
||||
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()
|
||||
|
||||
// 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 lockFile: String {
|
||||
containerPath + "/.imsg-bridge-ready"
|
||||
}
|
||||
|
||||
private var containerPath: String {
|
||||
NSHomeDirectory() + "/Library/Containers/com.apple.MobileSMS/Data"
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
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 is running with our dylib (lock file exists and responds to ping).
|
||||
public func isInjectedAndReady() -> Bool {
|
||||
guard FileManager.default.fileExists(atPath: lockFile) 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 }
|
||||
|
||||
switch Self.currentSIPStatus() {
|
||||
case .disabled:
|
||||
break
|
||||
case .enabled:
|
||||
throw MessagesLauncherError.sipEnabled
|
||||
case .unknown(let details):
|
||||
throw MessagesLauncherError.sipStatusUnknown(details)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
try launchWithInjection()
|
||||
try waitForReady(timeout: 15.0)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
throw MessagesLauncherError.socketTimeout
|
||||
}
|
||||
|
||||
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 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)
|
||||
|
||||
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
|
||||
}
|
||||
// Clear response file
|
||||
try? "".write(toFile: responseFile, atomically: true, encoding: .utf8)
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
throw MessagesLauncherError.socketError("Timeout waiting for response")
|
||||
}
|
||||
}
|
||||
|
||||
public enum MessagesLauncherError: Error, CustomStringConvertible {
|
||||
case dylibNotFound(String)
|
||||
case launchFailed(String)
|
||||
case sipEnabled
|
||||
case sipStatusUnknown(String)
|
||||
case socketTimeout
|
||||
case socketError(String)
|
||||
case invalidResponse
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .dylibNotFound(let path):
|
||||
return "imsg-bridge-helper.dylib not found at \(path). Build with: make build-dylib"
|
||||
case .launchFailed(let reason):
|
||||
return "Failed to launch Messages.app: \(reason)"
|
||||
case .sipEnabled:
|
||||
return
|
||||
"System Integrity Protection (SIP) is enabled. "
|
||||
+ "Refusing to inject into Messages.app. "
|
||||
+ "Disable SIP in Recovery mode before using `imsg launch`."
|
||||
case .sipStatusUnknown(let details):
|
||||
return
|
||||
"Unable to determine SIP status. "
|
||||
+ "Refusing to inject into Messages.app. "
|
||||
+ "Details: \(details)"
|
||||
case .socketTimeout:
|
||||
return
|
||||
"Timeout waiting for Messages.app to initialize. "
|
||||
+ "Ensure SIP is disabled and Messages.app has necessary permissions."
|
||||
case .socketError(let reason):
|
||||
return "IPC error: \(reason)"
|
||||
case .invalidResponse:
|
||||
return "Invalid response from Messages.app helper"
|
||||
}
|
||||
}
|
||||
}
|
||||
243
Sources/IMsgCore/TypingIndicator.swift
Normal file
243
Sources/IMsgCore/TypingIndicator.swift
Normal file
@ -0,0 +1,243 @@
|
||||
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()
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
try await sleep(UInt64(duration * 1_000_000_000))
|
||||
try stopTyping(chatIdentifier)
|
||||
stopped = true
|
||||
}
|
||||
|
||||
private static func ensureDaemonConnection() throws {
|
||||
guard let controllerClass = objc_getClass("IMDaemonController") as? NSObject.Type else {
|
||||
throw IMsgError.typingIndicatorFailed("IMDaemonController class not found")
|
||||
}
|
||||
|
||||
let sharedSel = sel_registerName("sharedInstance")
|
||||
guard controllerClass.responds(to: sharedSel) else {
|
||||
throw IMsgError.typingIndicatorFailed("IMDaemonController.sharedInstance not available")
|
||||
}
|
||||
|
||||
guard let controller = controllerClass.perform(sharedSel)?.takeUnretainedValue() else {
|
||||
throw IMsgError.typingIndicatorFailed("Failed to get IMDaemonController shared instance")
|
||||
}
|
||||
|
||||
if hasLiveDaemonConnection(controller) {
|
||||
daemonConnectionTracker.lock.lock()
|
||||
daemonConnectionTracker.hasAttemptedConnection = true
|
||||
daemonConnectionTracker.lock.unlock()
|
||||
return
|
||||
}
|
||||
|
||||
daemonConnectionTracker.lock.lock()
|
||||
let shouldAttemptConnection = !daemonConnectionTracker.hasAttemptedConnection
|
||||
if shouldAttemptConnection {
|
||||
daemonConnectionTracker.hasAttemptedConnection = true
|
||||
}
|
||||
daemonConnectionTracker.lock.unlock()
|
||||
if !shouldAttemptConnection { return }
|
||||
|
||||
let connectSel = sel_registerName("connectToDaemon")
|
||||
if controller.responds(to: connectSel) {
|
||||
_ = controller.perform(connectSel)
|
||||
}
|
||||
|
||||
let maxAttempts = 50
|
||||
for _ in 0..<maxAttempts {
|
||||
if hasLiveDaemonConnection(controller) {
|
||||
return
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01))
|
||||
}
|
||||
|
||||
if !hasLiveDaemonConnection(controller) {
|
||||
throw IMsgError.typingIndicatorFailed(
|
||||
"Failed to connect to imagent (iMessage daemon). "
|
||||
+ "This requires either SIP disabled with 'imsg launch', "
|
||||
+ "or system modifications (AMFI disabled + XPC plist). "
|
||||
+ "Run 'imsg status' for setup instructions."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
guard let registry = registryClass.perform(sharedSel)?.takeUnretainedValue() as? NSObject
|
||||
else {
|
||||
throw IMsgError.typingIndicatorFailed("Failed to get IMChatRegistry shared instance")
|
||||
}
|
||||
|
||||
let guidSel = sel_registerName("existingChatWithGUID:")
|
||||
if registry.responds(to: guidSel) {
|
||||
if let chat = registry.perform(guidSel, with: identifier)?.takeUnretainedValue() as? NSObject
|
||||
{
|
||||
return chat
|
||||
}
|
||||
}
|
||||
|
||||
let identSel = sel_registerName("existingChatWithChatIdentifier:")
|
||||
if registry.responds(to: identSel) {
|
||||
if let chat = registry.perform(identSel, with: identifier)?.takeUnretainedValue() as? NSObject
|
||||
{
|
||||
return chat
|
||||
}
|
||||
}
|
||||
|
||||
throw IMsgError.typingIndicatorFailed(
|
||||
"Chat not found for identifier: \(identifier). "
|
||||
+ "Make sure Messages.app has an active conversation with this contact.")
|
||||
}
|
||||
}
|
||||
|
||||
private final class DaemonConnectionTracker: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var hasAttemptedConnection = false
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
544
Sources/IMsgHelper/IMsgInjected.m
Normal file
544
Sources/IMsgHelper/IMsgInjected.m
Normal file
@ -0,0 +1,544 @@
|
||||
//
|
||||
// IMsgInjected.m
|
||||
// IMsgHelper - Injectable dylib for Messages.app
|
||||
//
|
||||
// This dylib is injected into Messages.app via DYLD_INSERT_LIBRARIES
|
||||
// to gain access to IMCore's chat registry and messaging functions.
|
||||
// It provides file-based IPC for the CLI to send commands.
|
||||
//
|
||||
// Requires SIP disabled for DYLD_INSERT_LIBRARIES to work on system apps.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <unistd.h>
|
||||
|
||||
#pragma mark - Constants
|
||||
|
||||
static NSString *kCommandFile = nil;
|
||||
static NSString *kResponseFile = nil;
|
||||
static NSString *kLockFile = nil;
|
||||
static NSTimer *fileWatchTimer = nil;
|
||||
static int lockFd = -1;
|
||||
|
||||
static void initFilePaths(void) {
|
||||
if (kCommandFile == nil) {
|
||||
// Messages.app runs in a container; NSHomeDirectory() resolves to
|
||||
// ~/Library/Containers/com.apple.MobileSMS/Data inside the sandbox.
|
||||
NSString *containerPath = NSHomeDirectory();
|
||||
kCommandFile = [containerPath stringByAppendingPathComponent:@".imsg-command.json"];
|
||||
kResponseFile = [containerPath stringByAppendingPathComponent:@".imsg-response.json"];
|
||||
kLockFile = [containerPath stringByAppendingPathComponent:@".imsg-bridge-ready"];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Forward Declarations for IMCore Classes
|
||||
|
||||
@interface IMChatRegistry : NSObject
|
||||
+ (instancetype)sharedInstance;
|
||||
- (id)existingChatWithGUID:(NSString *)guid;
|
||||
- (id)existingChatWithChatIdentifier:(NSString *)identifier;
|
||||
- (NSArray *)allExistingChats;
|
||||
@end
|
||||
|
||||
@interface IMChat : NSObject
|
||||
- (void)setLocalUserIsTyping:(BOOL)typing;
|
||||
- (void)markAllMessagesAsRead;
|
||||
- (NSArray *)participants;
|
||||
- (NSString *)guid;
|
||||
- (NSString *)chatIdentifier;
|
||||
@end
|
||||
|
||||
@interface IMHandle : NSObject
|
||||
- (NSString *)ID;
|
||||
@end
|
||||
|
||||
#pragma mark - JSON Response Helpers
|
||||
|
||||
static NSDictionary* successResponse(NSInteger requestId, NSDictionary *data) {
|
||||
NSMutableDictionary *response = [NSMutableDictionary dictionaryWithDictionary:data ?: @{}];
|
||||
response[@"id"] = @(requestId);
|
||||
response[@"success"] = @YES;
|
||||
response[@"timestamp"] = [[NSISO8601DateFormatter new] stringFromDate:[NSDate date]];
|
||||
return response;
|
||||
}
|
||||
|
||||
static NSDictionary* errorResponse(NSInteger requestId, NSString *error) {
|
||||
return @{
|
||||
@"id": @(requestId),
|
||||
@"success": @NO,
|
||||
@"error": error ?: @"Unknown error",
|
||||
@"timestamp": [[NSISO8601DateFormatter new] stringFromDate:[NSDate date]]
|
||||
};
|
||||
}
|
||||
|
||||
#pragma mark - Chat Resolution
|
||||
|
||||
/// Try multiple methods to find a chat, including GUID lookup, chat identifier,
|
||||
/// and participant matching with phone number normalization.
|
||||
static id findChat(NSString *identifier) {
|
||||
Class registryClass = NSClassFromString(@"IMChatRegistry");
|
||||
if (!registryClass) {
|
||||
NSLog(@"[imsg-bridge] IMChatRegistry class not found");
|
||||
return nil;
|
||||
}
|
||||
|
||||
id registry = [registryClass performSelector:@selector(sharedInstance)];
|
||||
if (!registry) {
|
||||
NSLog(@"[imsg-bridge] Could not get IMChatRegistry instance");
|
||||
return nil;
|
||||
}
|
||||
|
||||
id chat = nil;
|
||||
|
||||
// Method 1: Try existingChatWithGUID: with the identifier as-is (if it looks like a GUID)
|
||||
SEL guidSel = @selector(existingChatWithGUID:);
|
||||
if ([registry respondsToSelector:guidSel]) {
|
||||
if ([identifier containsString:@";"]) {
|
||||
chat = [registry performSelector:guidSel withObject:identifier];
|
||||
if (chat) {
|
||||
NSLog(@"[imsg-bridge] Found chat via existingChatWithGUID: %@", identifier);
|
||||
return chat;
|
||||
}
|
||||
}
|
||||
|
||||
// Try constructing GUIDs with common prefixes (iMessage, SMS, any)
|
||||
NSArray *prefixes = @[@"iMessage;-;", @"iMessage;+;", @"SMS;-;", @"SMS;+;", @"any;-;", @"any;+;"];
|
||||
for (NSString *prefix in prefixes) {
|
||||
NSString *fullGUID = [prefix stringByAppendingString:identifier];
|
||||
chat = [registry performSelector:guidSel withObject:fullGUID];
|
||||
if (chat) {
|
||||
NSLog(@"[imsg-bridge] Found chat via existingChatWithGUID: %@", fullGUID);
|
||||
return chat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Try existingChatWithChatIdentifier:
|
||||
SEL identSel = @selector(existingChatWithChatIdentifier:);
|
||||
if ([registry respondsToSelector:identSel]) {
|
||||
chat = [registry performSelector:identSel withObject:identifier];
|
||||
if (chat) {
|
||||
NSLog(@"[imsg-bridge] Found chat via existingChatWithChatIdentifier: %@", identifier);
|
||||
return chat;
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Iterate all chats and match by participant
|
||||
SEL allChatsSel = @selector(allExistingChats);
|
||||
if ([registry respondsToSelector:allChatsSel]) {
|
||||
NSArray *allChats = [registry performSelector:allChatsSel];
|
||||
if (!allChats) {
|
||||
NSLog(@"[imsg-bridge] allExistingChats returned nil");
|
||||
return nil;
|
||||
}
|
||||
NSLog(@"[imsg-bridge] Searching %lu chats for identifier: %@",
|
||||
(unsigned long)allChats.count, identifier);
|
||||
|
||||
// Normalize the search identifier for phone number matching
|
||||
NSString *normalizedIdentifier = nil;
|
||||
if ([identifier hasPrefix:@"+"] || [identifier hasPrefix:@"1"] ||
|
||||
[[NSCharacterSet decimalDigitCharacterSet]
|
||||
characterIsMember:[identifier characterAtIndex:0]]) {
|
||||
NSMutableString *digits = [NSMutableString string];
|
||||
for (NSUInteger i = 0; i < identifier.length; i++) {
|
||||
unichar c = [identifier characterAtIndex:i];
|
||||
if ([[NSCharacterSet decimalDigitCharacterSet] characterIsMember:c]) {
|
||||
[digits appendFormat:@"%C", c];
|
||||
}
|
||||
}
|
||||
normalizedIdentifier = [digits copy];
|
||||
}
|
||||
|
||||
for (id aChat in allChats) {
|
||||
// Check GUID
|
||||
if ([aChat respondsToSelector:@selector(guid)]) {
|
||||
NSString *chatGUID = [aChat performSelector:@selector(guid)];
|
||||
if ([chatGUID isEqualToString:identifier]) {
|
||||
NSLog(@"[imsg-bridge] Found chat by GUID exact match: %@", chatGUID);
|
||||
return aChat;
|
||||
}
|
||||
}
|
||||
|
||||
// Check chatIdentifier
|
||||
if ([aChat respondsToSelector:@selector(chatIdentifier)]) {
|
||||
NSString *chatId = [aChat performSelector:@selector(chatIdentifier)];
|
||||
if ([chatId isEqualToString:identifier]) {
|
||||
NSLog(@"[imsg-bridge] Found chat by chatIdentifier exact match: %@", chatId);
|
||||
return aChat;
|
||||
}
|
||||
}
|
||||
|
||||
// Check participants
|
||||
if ([aChat respondsToSelector:@selector(participants)]) {
|
||||
NSArray *participants = [aChat performSelector:@selector(participants)];
|
||||
if (!participants) continue;
|
||||
for (id handle in participants) {
|
||||
if ([handle respondsToSelector:@selector(ID)]) {
|
||||
NSString *handleID = [handle performSelector:@selector(ID)];
|
||||
if ([handleID isEqualToString:identifier]) {
|
||||
NSLog(@"[imsg-bridge] Found chat by participant exact match: %@", handleID);
|
||||
return aChat;
|
||||
}
|
||||
// Normalized phone number match
|
||||
if (normalizedIdentifier && normalizedIdentifier.length >= 10) {
|
||||
NSMutableString *handleDigits = [NSMutableString string];
|
||||
for (NSUInteger i = 0; i < handleID.length; i++) {
|
||||
unichar c = [handleID characterAtIndex:i];
|
||||
if ([[NSCharacterSet decimalDigitCharacterSet] characterIsMember:c]) {
|
||||
[handleDigits appendFormat:@"%C", c];
|
||||
}
|
||||
}
|
||||
if (handleDigits.length >= 10 &&
|
||||
([handleDigits hasSuffix:normalizedIdentifier] ||
|
||||
[normalizedIdentifier hasSuffix:handleDigits])) {
|
||||
NSLog(@"[imsg-bridge] Found chat by normalized phone match: %@ ~ %@",
|
||||
handleID, identifier);
|
||||
return aChat;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NSLog(@"[imsg-bridge] Chat not found for identifier: %@", identifier);
|
||||
return nil;
|
||||
}
|
||||
|
||||
#pragma mark - Command Handlers
|
||||
|
||||
static NSDictionary* handleTyping(NSInteger requestId, NSDictionary *params) {
|
||||
NSString *handle = params[@"handle"];
|
||||
NSNumber *state = params[@"typing"] ?: params[@"state"];
|
||||
|
||||
if (!handle) {
|
||||
return errorResponse(requestId, @"Missing required parameter: handle");
|
||||
}
|
||||
|
||||
BOOL typing = [state boolValue];
|
||||
id chat = findChat(handle);
|
||||
|
||||
if (!chat) {
|
||||
return errorResponse(requestId,
|
||||
[NSString stringWithFormat:@"Chat not found: %@", handle]);
|
||||
}
|
||||
|
||||
@try {
|
||||
// Gather diagnostic info
|
||||
NSString *chatGUID = @"unknown";
|
||||
NSString *chatIdent = @"unknown";
|
||||
NSString *chatClass = NSStringFromClass([chat class]);
|
||||
BOOL supportsTyping = YES;
|
||||
|
||||
if ([chat respondsToSelector:@selector(guid)]) {
|
||||
chatGUID = [chat performSelector:@selector(guid)] ?: @"nil";
|
||||
}
|
||||
if ([chat respondsToSelector:@selector(chatIdentifier)]) {
|
||||
chatIdent = [chat performSelector:@selector(chatIdentifier)] ?: @"nil";
|
||||
}
|
||||
|
||||
SEL supportsSel = @selector(supportsSendingTypingIndicators);
|
||||
if ([chat respondsToSelector:supportsSel]) {
|
||||
supportsTyping = ((BOOL (*)(id, SEL))objc_msgSend)(chat, supportsSel);
|
||||
}
|
||||
|
||||
NSLog(@"[imsg-bridge] Chat found: class=%@, guid=%@, identifier=%@, supportsTyping=%@",
|
||||
chatClass, chatGUID, chatIdent, supportsTyping ? @"YES" : @"NO");
|
||||
|
||||
SEL typingSel = @selector(setLocalUserIsTyping:);
|
||||
if ([chat respondsToSelector:typingSel]) {
|
||||
NSMethodSignature *sig = [chat methodSignatureForSelector:typingSel];
|
||||
if (!sig) {
|
||||
return errorResponse(requestId,
|
||||
@"Could not get method signature for setLocalUserIsTyping:");
|
||||
}
|
||||
NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig];
|
||||
[inv setSelector:typingSel];
|
||||
[inv setTarget:chat];
|
||||
[inv setArgument:&typing atIndex:2];
|
||||
[inv invoke];
|
||||
|
||||
NSLog(@"[imsg-bridge] Called setLocalUserIsTyping:%@ for %@",
|
||||
typing ? @"YES" : @"NO", handle);
|
||||
return successResponse(requestId, @{
|
||||
@"handle": handle,
|
||||
@"typing": @(typing)
|
||||
});
|
||||
}
|
||||
|
||||
return errorResponse(requestId, @"setLocalUserIsTyping: method not available");
|
||||
} @catch (NSException *exception) {
|
||||
return errorResponse(requestId,
|
||||
[NSString stringWithFormat:@"Failed to set typing: %@", exception.reason]);
|
||||
}
|
||||
}
|
||||
|
||||
static NSDictionary* handleRead(NSInteger requestId, NSDictionary *params) {
|
||||
NSString *handle = params[@"handle"];
|
||||
|
||||
if (!handle) {
|
||||
return errorResponse(requestId, @"Missing required parameter: handle");
|
||||
}
|
||||
|
||||
id chat = findChat(handle);
|
||||
|
||||
if (!chat) {
|
||||
return errorResponse(requestId,
|
||||
[NSString stringWithFormat:@"Chat not found: %@", handle]);
|
||||
}
|
||||
|
||||
@try {
|
||||
SEL readSel = @selector(markAllMessagesAsRead);
|
||||
if ([chat respondsToSelector:readSel]) {
|
||||
[chat performSelector:readSel];
|
||||
NSLog(@"[imsg-bridge] Marked all messages as read for %@", handle);
|
||||
return successResponse(requestId, @{
|
||||
@"handle": handle,
|
||||
@"marked_as_read": @YES
|
||||
});
|
||||
} else {
|
||||
return errorResponse(requestId, @"markAllMessagesAsRead method not available");
|
||||
}
|
||||
} @catch (NSException *exception) {
|
||||
return errorResponse(requestId,
|
||||
[NSString stringWithFormat:@"Failed to mark as read: %@", exception.reason]);
|
||||
}
|
||||
}
|
||||
|
||||
static NSDictionary* handleStatus(NSInteger requestId, NSDictionary *params) {
|
||||
Class registryClass = NSClassFromString(@"IMChatRegistry");
|
||||
BOOL hasRegistry = (registryClass != nil);
|
||||
NSUInteger chatCount = 0;
|
||||
|
||||
if (hasRegistry) {
|
||||
id registry = [registryClass performSelector:@selector(sharedInstance)];
|
||||
if ([registry respondsToSelector:@selector(allExistingChats)]) {
|
||||
NSArray *chats = [registry performSelector:@selector(allExistingChats)];
|
||||
chatCount = chats.count;
|
||||
}
|
||||
}
|
||||
|
||||
return successResponse(requestId, @{
|
||||
@"injected": @YES,
|
||||
@"registry_available": @(hasRegistry),
|
||||
@"chat_count": @(chatCount),
|
||||
@"typing_available": @(hasRegistry),
|
||||
@"read_available": @(hasRegistry)
|
||||
});
|
||||
}
|
||||
|
||||
static NSDictionary* handleListChats(NSInteger requestId, NSDictionary *params) {
|
||||
Class registryClass = NSClassFromString(@"IMChatRegistry");
|
||||
if (!registryClass) {
|
||||
return errorResponse(requestId, @"IMChatRegistry not available");
|
||||
}
|
||||
|
||||
id registry = [registryClass performSelector:@selector(sharedInstance)];
|
||||
if (!registry) {
|
||||
return errorResponse(requestId, @"Could not get IMChatRegistry instance");
|
||||
}
|
||||
|
||||
NSMutableArray *chatList = [NSMutableArray array];
|
||||
|
||||
if ([registry respondsToSelector:@selector(allExistingChats)]) {
|
||||
NSArray *allChats = [registry performSelector:@selector(allExistingChats)];
|
||||
for (id chat in allChats) {
|
||||
NSMutableDictionary *chatInfo = [NSMutableDictionary dictionary];
|
||||
|
||||
if ([chat respondsToSelector:@selector(guid)]) {
|
||||
chatInfo[@"guid"] = [chat performSelector:@selector(guid)] ?: @"";
|
||||
}
|
||||
if ([chat respondsToSelector:@selector(chatIdentifier)]) {
|
||||
chatInfo[@"identifier"] = [chat performSelector:@selector(chatIdentifier)] ?: @"";
|
||||
}
|
||||
if ([chat respondsToSelector:@selector(participants)]) {
|
||||
NSMutableArray *handles = [NSMutableArray array];
|
||||
NSArray *participants = [chat performSelector:@selector(participants)];
|
||||
for (id handle in participants) {
|
||||
if ([handle respondsToSelector:@selector(ID)]) {
|
||||
[handles addObject:[handle performSelector:@selector(ID)] ?: @""];
|
||||
}
|
||||
}
|
||||
chatInfo[@"participants"] = handles;
|
||||
}
|
||||
|
||||
[chatList addObject:chatInfo];
|
||||
}
|
||||
}
|
||||
|
||||
return successResponse(requestId, @{
|
||||
@"chats": chatList,
|
||||
@"count": @(chatList.count)
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark - Command Router
|
||||
|
||||
static NSDictionary* processCommand(NSDictionary *command) {
|
||||
NSNumber *requestIdNum = command[@"id"];
|
||||
NSInteger requestId = requestIdNum ? [requestIdNum integerValue] : 0;
|
||||
NSString *action = command[@"action"];
|
||||
NSDictionary *params = command[@"params"] ?: @{};
|
||||
|
||||
NSLog(@"[imsg-bridge] Processing command: %@ (id=%ld)", action, (long)requestId);
|
||||
|
||||
if ([action isEqualToString:@"typing"]) {
|
||||
return handleTyping(requestId, params);
|
||||
} else if ([action isEqualToString:@"read"]) {
|
||||
return handleRead(requestId, params);
|
||||
} else if ([action isEqualToString:@"status"]) {
|
||||
return handleStatus(requestId, params);
|
||||
} else if ([action isEqualToString:@"list_chats"]) {
|
||||
return handleListChats(requestId, params);
|
||||
} else if ([action isEqualToString:@"ping"]) {
|
||||
return successResponse(requestId, @{@"pong": @YES});
|
||||
} else {
|
||||
return errorResponse(requestId,
|
||||
[NSString stringWithFormat:@"Unknown action: %@", action]);
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - File-based IPC
|
||||
|
||||
static void processCommandFile(void) {
|
||||
@autoreleasepool {
|
||||
initFilePaths();
|
||||
|
||||
NSError *error = nil;
|
||||
NSData *commandData = [NSData dataWithContentsOfFile:kCommandFile options:0 error:&error];
|
||||
if (!commandData || error) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSDictionary *command = [NSJSONSerialization JSONObjectWithData:commandData
|
||||
options:0
|
||||
error:&error];
|
||||
if (error || ![command isKindOfClass:[NSDictionary class]]) {
|
||||
NSDictionary *response = errorResponse(0, @"Invalid JSON in command file");
|
||||
NSData *responseData = [NSJSONSerialization dataWithJSONObject:response
|
||||
options:NSJSONWritingPrettyPrinted
|
||||
error:nil];
|
||||
[responseData writeToFile:kResponseFile atomically:YES];
|
||||
return;
|
||||
}
|
||||
|
||||
NSDictionary *result = processCommand(command);
|
||||
|
||||
if (result != nil) {
|
||||
NSData *responseData = [NSJSONSerialization dataWithJSONObject:result
|
||||
options:NSJSONWritingPrettyPrinted
|
||||
error:nil];
|
||||
[responseData writeToFile:kResponseFile atomically:YES];
|
||||
|
||||
// Clear command file to signal processing is complete
|
||||
[@"" writeToFile:kCommandFile atomically:YES encoding:NSUTF8StringEncoding error:nil];
|
||||
|
||||
NSLog(@"[imsg-bridge] Processed command, wrote response");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void startFileWatcher(void) {
|
||||
initFilePaths();
|
||||
|
||||
NSLog(@"[imsg-bridge] Starting file-based IPC");
|
||||
NSLog(@"[imsg-bridge] Command file: %@", kCommandFile);
|
||||
NSLog(@"[imsg-bridge] Response file: %@", kResponseFile);
|
||||
|
||||
// Create/clear IPC files
|
||||
[@"" writeToFile:kCommandFile atomically:YES encoding:NSUTF8StringEncoding error:nil];
|
||||
[@"" writeToFile:kResponseFile atomically:YES encoding:NSUTF8StringEncoding error:nil];
|
||||
|
||||
// Create lock file with PID to indicate we're ready
|
||||
lockFd = open(kLockFile.UTF8String, O_CREAT | O_WRONLY, 0644);
|
||||
if (lockFd >= 0) {
|
||||
NSString *pidStr = [NSString stringWithFormat:@"%d", getpid()];
|
||||
write(lockFd, pidStr.UTF8String, pidStr.length);
|
||||
}
|
||||
|
||||
// Poll command file via NSTimer on the main run loop.
|
||||
// NSTimer survives reliably in injected dylib contexts (dispatch_source timers
|
||||
// can get deallocated).
|
||||
__block NSDate *lastModified = nil;
|
||||
NSTimer *timer = [NSTimer timerWithTimeInterval:0.1 repeats:YES block:^(NSTimer *t) {
|
||||
@autoreleasepool {
|
||||
NSDictionary *attrs = [[NSFileManager defaultManager]
|
||||
attributesOfItemAtPath:kCommandFile error:nil];
|
||||
NSDate *modDate = attrs[NSFileModificationDate];
|
||||
|
||||
if (modDate && ![modDate isEqualToDate:lastModified]) {
|
||||
NSData *data = [NSData dataWithContentsOfFile:kCommandFile];
|
||||
if (data && data.length > 2) {
|
||||
lastModified = modDate;
|
||||
processCommandFile();
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
|
||||
fileWatchTimer = timer;
|
||||
|
||||
NSLog(@"[imsg-bridge] File watcher started, ready for commands");
|
||||
}
|
||||
|
||||
#pragma mark - Dylib Entry Point
|
||||
|
||||
__attribute__((constructor))
|
||||
static void injectedInit(void) {
|
||||
NSLog(@"[imsg-bridge] Dylib injected into %@", [[NSProcessInfo processInfo] processName]);
|
||||
|
||||
// Connect to IMDaemon for full IMCore access
|
||||
Class daemonClass = NSClassFromString(@"IMDaemonController");
|
||||
if (daemonClass) {
|
||||
id daemon = [daemonClass performSelector:@selector(sharedInstance)];
|
||||
if (daemon && [daemon respondsToSelector:@selector(connectToDaemon)]) {
|
||||
[daemon performSelector:@selector(connectToDaemon)];
|
||||
NSLog(@"[imsg-bridge] Connected to IMDaemon");
|
||||
} else {
|
||||
NSLog(@"[imsg-bridge] IMDaemonController available but couldn't connect");
|
||||
}
|
||||
} else {
|
||||
NSLog(@"[imsg-bridge] IMDaemonController class not found");
|
||||
}
|
||||
|
||||
// Delay initialization to let Messages.app fully start
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 2 * NSEC_PER_SEC), dispatch_get_main_queue(), ^{
|
||||
NSLog(@"[imsg-bridge] Initializing after delay...");
|
||||
|
||||
// Log IMCore status
|
||||
Class registryClass = NSClassFromString(@"IMChatRegistry");
|
||||
if (registryClass) {
|
||||
id registry = [registryClass performSelector:@selector(sharedInstance)];
|
||||
if ([registry respondsToSelector:@selector(allExistingChats)]) {
|
||||
NSArray *chats = [registry performSelector:@selector(allExistingChats)];
|
||||
NSLog(@"[imsg-bridge] IMChatRegistry available with %lu chats",
|
||||
(unsigned long)chats.count);
|
||||
}
|
||||
} else {
|
||||
NSLog(@"[imsg-bridge] IMChatRegistry NOT available");
|
||||
}
|
||||
|
||||
startFileWatcher();
|
||||
});
|
||||
}
|
||||
|
||||
__attribute__((destructor))
|
||||
static void injectedCleanup(void) {
|
||||
NSLog(@"[imsg-bridge] Cleaning up...");
|
||||
|
||||
if (fileWatchTimer) {
|
||||
[fileWatchTimer invalidate];
|
||||
fileWatchTimer = nil;
|
||||
}
|
||||
|
||||
if (lockFd >= 0) {
|
||||
close(lockFd);
|
||||
lockFd = -1;
|
||||
}
|
||||
|
||||
initFilePaths();
|
||||
[[NSFileManager defaultManager] removeItemAtPath:kLockFile error:nil];
|
||||
}
|
||||
@ -57,4 +57,16 @@ enum ChatTargetResolver {
|
||||
chatGUID: resolvedGUID
|
||||
)
|
||||
}
|
||||
|
||||
static func directTypingIdentifier(
|
||||
recipient: String,
|
||||
serviceRaw: String,
|
||||
invalidServiceError: (String) -> Error
|
||||
) throws -> String {
|
||||
guard let service = MessageService(rawValue: serviceRaw.lowercased()) else {
|
||||
throw invalidServiceError(serviceRaw)
|
||||
}
|
||||
let prefix = service == .sms ? "SMS" : "iMessage"
|
||||
return "\(prefix);-;\(recipient)"
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,10 @@ struct CommandRouter {
|
||||
WatchCommand.spec,
|
||||
SendCommand.spec,
|
||||
ReactCommand.spec,
|
||||
ReadCommand.spec,
|
||||
TypingCommand.spec,
|
||||
LaunchCommand.spec,
|
||||
StatusCommand.spec,
|
||||
RpcCommand.spec,
|
||||
]
|
||||
let descriptor = CommandDescriptor(
|
||||
|
||||
155
Sources/imsg/Commands/LaunchCommand.swift
Normal file
155
Sources/imsg/Commands/LaunchCommand.swift
Normal file
@ -0,0 +1,155 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum LaunchCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "launch",
|
||||
abstract: "Launch Messages.app with dylib injection",
|
||||
discussion: """
|
||||
Kills any running Messages.app instance, then relaunches it with
|
||||
DYLD_INSERT_LIBRARIES set to inject the imsg bridge helper dylib.
|
||||
This enables advanced features like typing indicators and read receipts
|
||||
that require IMCore framework access.
|
||||
|
||||
Requires SIP (System Integrity Protection) to be disabled.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: [
|
||||
.make(
|
||||
label: "dylib", names: [.long("dylib")],
|
||||
help: "Custom path to imsg-bridge-helper.dylib")
|
||||
],
|
||||
flags: [
|
||||
.make(
|
||||
label: "killOnly", names: [.long("kill-only")],
|
||||
help: "Only kill Messages.app, don't relaunch")
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg launch",
|
||||
"imsg launch --kill-only",
|
||||
"imsg launch --dylib /path/to/dylib",
|
||||
"imsg launch --json",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
let killOnly = values.flags.contains("killOnly")
|
||||
let customDylib = values.option("dylib")
|
||||
|
||||
let launcher = MessagesLauncher.shared
|
||||
|
||||
if killOnly {
|
||||
if !runtime.jsonOutput {
|
||||
StdoutWriter.writeLine("Killing Messages.app...")
|
||||
}
|
||||
launcher.killMessages()
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "killed", "message": "Messages.app terminated"])
|
||||
} else {
|
||||
StdoutWriter.writeLine("Messages.app terminated")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch MessagesLauncher.currentSIPStatus() {
|
||||
case .enabled:
|
||||
let message =
|
||||
"SIP is enabled. Refusing to inject into Messages.app. "
|
||||
+ "Disable SIP in Recovery mode (`csrutil disable`) before running `imsg launch`."
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "error", "error": "sip_enabled", "message": message])
|
||||
} else {
|
||||
StdoutWriter.writeLine(message)
|
||||
}
|
||||
throw IMsgError.typingIndicatorFailed(message)
|
||||
case .unknown(let details):
|
||||
let message =
|
||||
"Unable to determine SIP status. Refusing to inject into Messages.app. Details: \(details)"
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "error", "error": "sip_unknown", "message": message])
|
||||
} else {
|
||||
StdoutWriter.writeLine(message)
|
||||
}
|
||||
throw IMsgError.typingIndicatorFailed(message)
|
||||
case .disabled:
|
||||
break
|
||||
}
|
||||
|
||||
let dylibPath = resolveDylibPath(custom: customDylib)
|
||||
|
||||
guard let resolvedPath = dylibPath else {
|
||||
let error =
|
||||
"imsg-bridge-helper.dylib not found. Searched:\n"
|
||||
+ " - /usr/local/lib/imsg-bridge-helper.dylib\n"
|
||||
+ " - .build/release/imsg-bridge-helper.dylib\n"
|
||||
+ "Run 'make build-dylib' or specify --dylib <path>"
|
||||
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "error", "error": "dylib_not_found", "message": error])
|
||||
} else {
|
||||
StdoutWriter.writeLine(error)
|
||||
}
|
||||
throw IMsgError.typingIndicatorFailed("dylib not found")
|
||||
}
|
||||
|
||||
launcher.dylibPath = resolvedPath
|
||||
|
||||
if !runtime.jsonOutput {
|
||||
StdoutWriter.writeLine("Using dylib: \(resolvedPath)")
|
||||
StdoutWriter.writeLine("Launching Messages.app with injection...")
|
||||
}
|
||||
|
||||
do {
|
||||
try launcher.ensureRunning()
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print([
|
||||
"status": "launched",
|
||||
"dylib": resolvedPath,
|
||||
"message": "Messages.app launched with dylib injection",
|
||||
])
|
||||
} else {
|
||||
StdoutWriter.writeLine("Messages.app launched with dylib injection")
|
||||
}
|
||||
} catch {
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print([
|
||||
"status": "error",
|
||||
"dylib": resolvedPath,
|
||||
"error": "\(error)",
|
||||
])
|
||||
} else {
|
||||
StdoutWriter.writeLine("Failed to launch: \(error)")
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveDylibPath(custom: String?) -> String? {
|
||||
if let custom = custom {
|
||||
if FileManager.default.fileExists(atPath: custom) {
|
||||
return custom
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
let searchPaths = [
|
||||
"/usr/local/lib/imsg-bridge-helper.dylib",
|
||||
".build/release/imsg-bridge-helper.dylib",
|
||||
]
|
||||
|
||||
for path in searchPaths {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
99
Sources/imsg/Commands/ReadCommand.swift
Normal file
99
Sources/imsg/Commands/ReadCommand.swift
Normal file
@ -0,0 +1,99 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum ReadCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "read",
|
||||
abstract: "Mark messages as read for a chat",
|
||||
discussion: """
|
||||
Marks messages as read via IMCore advanced features.
|
||||
Requires SIP disabled and Messages launched with `imsg launch`.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(
|
||||
label: "to",
|
||||
names: [.long("to"), .aliasLong("handle")],
|
||||
help: "phone number or email"),
|
||||
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid"),
|
||||
.make(
|
||||
label: "chatIdentifier", names: [.long("chat-identifier")],
|
||||
help: "chat identifier (e.g. iMessage;-;+14155551212)"),
|
||||
.make(label: "chatGUID", names: [.long("chat-guid")], help: "chat guid"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg read --to +14155551212",
|
||||
"imsg read --handle steipete@gmail.com",
|
||||
"imsg read --chat-id 1",
|
||||
"imsg read --chat-identifier \"iMessage;-;+14155551212\"",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(
|
||||
values: ParsedValues,
|
||||
runtime: RuntimeOptions,
|
||||
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
|
||||
markAsRead: @escaping (String) async throws -> Void = {
|
||||
try await IMCoreBridge.shared.markAsRead(handle: $0)
|
||||
}
|
||||
) async throws {
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let input = ChatTargetInput(
|
||||
recipient: values.option("to") ?? "",
|
||||
chatID: values.optionInt64("chatID"),
|
||||
chatIdentifier: values.option("chatIdentifier") ?? "",
|
||||
chatGUID: values.option("chatGUID") ?? ""
|
||||
)
|
||||
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: input,
|
||||
mixedTargetError: ParsedValuesError.invalidOption("to"),
|
||||
missingRecipientError: ParsedValuesError.missingOption("to")
|
||||
)
|
||||
|
||||
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in
|
||||
let store = try storeFactory(dbPath)
|
||||
return try store.chatInfo(chatID: chatID)
|
||||
},
|
||||
unknownChatError: { chatID in
|
||||
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
|
||||
}
|
||||
)
|
||||
let resolvedIdentifier: String
|
||||
if let preferred = resolvedTarget.preferredIdentifier {
|
||||
resolvedIdentifier = preferred
|
||||
} else if input.hasChatTarget {
|
||||
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
|
||||
} else {
|
||||
resolvedIdentifier = input.recipient
|
||||
}
|
||||
|
||||
try await markAsRead(resolvedIdentifier)
|
||||
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(ReadResult(success: true, handle: resolvedIdentifier, markedAsRead: true))
|
||||
} else {
|
||||
Swift.print("marked as read: \(resolvedIdentifier)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReadResult: Codable {
|
||||
let success: Bool
|
||||
let handle: String
|
||||
let markedAsRead: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case success
|
||||
case handle
|
||||
case markedAsRead = "marked_as_read"
|
||||
}
|
||||
}
|
||||
106
Sources/imsg/Commands/StatusCommand.swift
Normal file
106
Sources/imsg/Commands/StatusCommand.swift
Normal file
@ -0,0 +1,106 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum StatusCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "status",
|
||||
abstract: "Check availability of imsg advanced features",
|
||||
discussion: """
|
||||
Display the current status of imsg features and permissions.
|
||||
Shows which advanced features (typing indicators, read receipts) are
|
||||
available and provides setup instructions if needed.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(CommandSignature()),
|
||||
usageExamples: [
|
||||
"imsg status",
|
||||
"imsg status --json",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(values: ParsedValues, runtime: RuntimeOptions) async throws {
|
||||
let bridge = IMCoreBridge.shared
|
||||
let availability = bridge.checkAvailability()
|
||||
let sipStatus: String = {
|
||||
switch MessagesLauncher.currentSIPStatus() {
|
||||
case .enabled:
|
||||
return "enabled"
|
||||
case .disabled:
|
||||
return "disabled"
|
||||
case .unknown:
|
||||
return "unknown"
|
||||
}
|
||||
}()
|
||||
|
||||
if runtime.jsonOutput {
|
||||
let payload = StatusPayload(
|
||||
basicFeatures: true,
|
||||
advancedFeatures: availability.available,
|
||||
typingIndicators: availability.available,
|
||||
readReceipts: availability.available,
|
||||
sip: sipStatus,
|
||||
message: availability.message
|
||||
)
|
||||
try JSONLines.print(payload)
|
||||
} else {
|
||||
StdoutWriter.writeLine("imsg Status Report")
|
||||
StdoutWriter.writeLine("==================")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("Basic features (send, receive, history):")
|
||||
StdoutWriter.writeLine(" Available")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("System Integrity Protection (SIP):")
|
||||
StdoutWriter.writeLine(" \(sipStatus)")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("Advanced features (typing, read receipts):")
|
||||
if availability.available {
|
||||
StdoutWriter.writeLine(" Available - IMCore bridge connected")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("Available commands:")
|
||||
StdoutWriter.writeLine(" imsg read --to <handle>")
|
||||
StdoutWriter.writeLine(" imsg typing --to <handle>")
|
||||
StdoutWriter.writeLine(" imsg launch")
|
||||
StdoutWriter.writeLine(" imsg status")
|
||||
} else {
|
||||
StdoutWriter.writeLine(" Not available")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("To enable advanced features:")
|
||||
StdoutWriter.writeLine(" 1. Disable System Integrity Protection (SIP)")
|
||||
StdoutWriter.writeLine(" - Restart Mac holding Cmd+R")
|
||||
StdoutWriter.writeLine(" - Open Terminal from Utilities menu")
|
||||
StdoutWriter.writeLine(" - Run: csrutil disable")
|
||||
StdoutWriter.writeLine(" - Restart normally")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine(" 2. Grant Full Disk Access")
|
||||
StdoutWriter.writeLine(" - System Settings > Privacy & Security > Full Disk Access")
|
||||
StdoutWriter.writeLine(" - Add Terminal or your terminal app")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine(" 3. Build and launch:")
|
||||
StdoutWriter.writeLine(" make build-dylib")
|
||||
StdoutWriter.writeLine(" imsg launch")
|
||||
StdoutWriter.writeLine("")
|
||||
StdoutWriter.writeLine("Note: Basic messaging features work without these steps.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusPayload: Encodable {
|
||||
let basicFeatures: Bool
|
||||
let advancedFeatures: Bool
|
||||
let typingIndicators: Bool
|
||||
let readReceipts: Bool
|
||||
let sip: String
|
||||
let message: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case basicFeatures = "basic_features"
|
||||
case advancedFeatures = "advanced_features"
|
||||
case typingIndicators = "typing_indicators"
|
||||
case readReceipts = "read_receipts"
|
||||
case sip
|
||||
case message
|
||||
}
|
||||
}
|
||||
141
Sources/imsg/Commands/TypingCommand.swift
Normal file
141
Sources/imsg/Commands/TypingCommand.swift
Normal file
@ -0,0 +1,141 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import IMsgCore
|
||||
|
||||
enum TypingCommand {
|
||||
static let spec = CommandSpec(
|
||||
name: "typing",
|
||||
abstract: "Send typing indicator to a chat",
|
||||
discussion: """
|
||||
Sends typing indicators via IMCore advanced features.
|
||||
Requires SIP disabled and Messages launched with `imsg launch`.
|
||||
""",
|
||||
signature: CommandSignatures.withRuntimeFlags(
|
||||
CommandSignature(
|
||||
options: CommandSignatures.baseOptions() + [
|
||||
.make(label: "to", names: [.long("to")], help: "phone number or email"),
|
||||
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid"),
|
||||
.make(
|
||||
label: "chatIdentifier", names: [.long("chat-identifier")],
|
||||
help: "chat identifier (e.g. iMessage;-;+14155551212)"),
|
||||
.make(label: "chatGUID", names: [.long("chat-guid")], help: "chat guid"),
|
||||
.make(
|
||||
label: "duration", names: [.long("duration")],
|
||||
help: "how long to show typing (e.g. 5s, 3000ms); omit for start-only"),
|
||||
.make(
|
||||
label: "stop", names: [.long("stop")],
|
||||
help: "stop typing indicator instead of starting"),
|
||||
.make(
|
||||
label: "service", names: [.long("service")],
|
||||
help: "service to use: imessage|sms|auto"),
|
||||
]
|
||||
)
|
||||
),
|
||||
usageExamples: [
|
||||
"imsg typing --to +14155551212",
|
||||
"imsg typing --to +14155551212 --duration 5s",
|
||||
"imsg typing --to +14155551212 --stop true",
|
||||
"imsg typing --chat-identifier \"iMessage;-;+14155551212\"",
|
||||
]
|
||||
) { values, runtime in
|
||||
try await run(values: values, runtime: runtime)
|
||||
}
|
||||
|
||||
static func run(
|
||||
values: ParsedValues,
|
||||
runtime: RuntimeOptions,
|
||||
storeFactory: @escaping (String) throws -> MessageStore = { try MessageStore(path: $0) },
|
||||
startTyping: @escaping (String) throws -> Void = {
|
||||
try TypingIndicator.startTyping(chatIdentifier: $0)
|
||||
},
|
||||
stopTyping: @escaping (String) throws -> Void = {
|
||||
try TypingIndicator.stopTyping(chatIdentifier: $0)
|
||||
},
|
||||
typeForDuration: @escaping (String, TimeInterval) async throws -> Void = {
|
||||
try await TypingIndicator.typeForDuration(chatIdentifier: $0, duration: $1)
|
||||
}
|
||||
) async throws {
|
||||
let dbPath = values.option("db") ?? MessageStore.defaultPath
|
||||
let input = ChatTargetInput(
|
||||
recipient: values.option("to") ?? "",
|
||||
chatID: values.optionInt64("chatID"),
|
||||
chatIdentifier: values.option("chatIdentifier") ?? "",
|
||||
chatGUID: values.option("chatGUID") ?? ""
|
||||
)
|
||||
let stopFlag = try parseStopFlag(values.option("stop"))
|
||||
let durationRaw = values.option("duration") ?? ""
|
||||
let serviceRaw = values.option("service") ?? "imessage"
|
||||
|
||||
try ChatTargetResolver.validateRecipientRequirements(
|
||||
input: input,
|
||||
mixedTargetError: ParsedValuesError.invalidOption("to"),
|
||||
missingRecipientError: ParsedValuesError.missingOption("to")
|
||||
)
|
||||
|
||||
let resolvedTarget = try await ChatTargetResolver.resolveChatTarget(
|
||||
input: input,
|
||||
lookupChat: { chatID in
|
||||
let store = try storeFactory(dbPath)
|
||||
return try store.chatInfo(chatID: chatID)
|
||||
},
|
||||
unknownChatError: { chatID in
|
||||
IMsgError.invalidChatTarget("Unknown chat id \(chatID)")
|
||||
}
|
||||
)
|
||||
let resolvedIdentifier: String
|
||||
if let preferred = resolvedTarget.preferredIdentifier {
|
||||
resolvedIdentifier = preferred
|
||||
} else if input.hasChatTarget {
|
||||
throw IMsgError.invalidChatTarget("Missing chat identifier or guid")
|
||||
} else {
|
||||
resolvedIdentifier = try ChatTargetResolver.directTypingIdentifier(
|
||||
recipient: input.recipient,
|
||||
serviceRaw: serviceRaw,
|
||||
invalidServiceError: { IMsgError.invalidService($0) }
|
||||
)
|
||||
}
|
||||
|
||||
if stopFlag {
|
||||
try stopTyping(resolvedIdentifier)
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "stopped"])
|
||||
} else {
|
||||
Swift.print("typing indicator stopped")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !durationRaw.isEmpty {
|
||||
let seconds = try parseDurationToSeconds(durationRaw)
|
||||
try await typeForDuration(resolvedIdentifier, seconds)
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "completed", "duration_s": "\(seconds)"])
|
||||
} else {
|
||||
Swift.print("typing indicator shown for \(durationRaw)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try startTyping(resolvedIdentifier)
|
||||
if runtime.jsonOutput {
|
||||
try JSONLines.print(["status": "started"])
|
||||
} else {
|
||||
Swift.print("typing indicator started")
|
||||
}
|
||||
}
|
||||
|
||||
private static func parseStopFlag(_ raw: String?) throws -> Bool {
|
||||
guard let raw else { return false }
|
||||
if raw == "true" { return true }
|
||||
if raw == "false" { return false }
|
||||
throw ParsedValuesError.invalidOption("stop")
|
||||
}
|
||||
|
||||
private static func parseDurationToSeconds(_ raw: String) throws -> TimeInterval {
|
||||
guard let seconds = DurationParser.parse(raw), seconds > 0 else {
|
||||
throw IMsgError.typingIndicatorFailed(
|
||||
"Invalid duration: \(raw). Use e.g. 5s, 3000ms, 1m, or 1h")
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
}
|
||||
66
Tests/IMsgCoreTests/IMCoreBridgeTests.swift
Normal file
66
Tests/IMsgCoreTests/IMCoreBridgeTests.swift
Normal file
@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
@Test
|
||||
func imCoreBridgeIsNotAvailableWithoutDylib() {
|
||||
// In the test environment there's no dylib built, so isAvailable should be false
|
||||
// unless one happens to exist at a search path. We test the shared instance exists.
|
||||
let bridge = IMCoreBridge.shared
|
||||
// Just verify the API exists and doesn't crash
|
||||
_ = bridge.isAvailable
|
||||
}
|
||||
|
||||
@Test
|
||||
func imCoreBridgeCheckAvailabilityReturnsDiagnostic() {
|
||||
let bridge = IMCoreBridge.shared
|
||||
let (_, message) = bridge.checkAvailability()
|
||||
// Should return a non-empty diagnostic message regardless of availability
|
||||
#expect(!message.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesLauncherSharedInstanceExists() {
|
||||
let launcher = MessagesLauncher.shared
|
||||
// Verify the launcher can be accessed
|
||||
#expect(launcher.dylibPath.contains("imsg-bridge-helper.dylib"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesLauncherIsNotReadyWithoutInjection() {
|
||||
let launcher = MessagesLauncher.shared
|
||||
// Without actually launching Messages.app with injection, this should return false
|
||||
// (unless Messages happens to be running with our dylib, which is unlikely in CI)
|
||||
_ = launcher.isInjectedAndReady()
|
||||
// Just verify it doesn't crash
|
||||
}
|
||||
|
||||
@Test
|
||||
func messagesLauncherErrorDescriptions() {
|
||||
let errors: [MessagesLauncherError] = [
|
||||
.dylibNotFound("/fake/path"),
|
||||
.launchFailed("test reason"),
|
||||
.socketTimeout,
|
||||
.socketError("test error"),
|
||||
.invalidResponse,
|
||||
]
|
||||
|
||||
for error in errors {
|
||||
#expect(!error.description.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func imCoreBridgeErrorDescriptions() {
|
||||
let errors: [IMCoreBridgeError] = [
|
||||
.dylibNotFound,
|
||||
.connectionFailed("test"),
|
||||
.chatNotFound("test-handle"),
|
||||
.operationFailed("test reason"),
|
||||
]
|
||||
|
||||
for error in errors {
|
||||
#expect(!error.description.isEmpty)
|
||||
}
|
||||
}
|
||||
43
Tests/IMsgCoreTests/TypingIndicatorTests.swift
Normal file
43
Tests/IMsgCoreTests/TypingIndicatorTests.swift
Normal file
@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
|
||||
@Test
|
||||
func typingIndicatorStopsOnCancellation() async {
|
||||
var events: [String] = []
|
||||
|
||||
do {
|
||||
try await TypingIndicator.typeForDuration(
|
||||
chatIdentifier: "iMessage;+;chat123",
|
||||
duration: 1,
|
||||
startTyping: { _ in events.append("start") },
|
||||
stopTyping: { _ in events.append("stop") },
|
||||
sleep: { _ in throw CancellationError() }
|
||||
)
|
||||
#expect(Bool(false))
|
||||
} catch is CancellationError {
|
||||
#expect(Bool(true))
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
|
||||
#expect(events == ["start", "stop"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func typingIndicatorStopsAfterNormalDuration() async throws {
|
||||
var events: [String] = []
|
||||
var didSleep = false
|
||||
|
||||
try await TypingIndicator.typeForDuration(
|
||||
chatIdentifier: "iMessage;+;chat123",
|
||||
duration: 1,
|
||||
startTyping: { _ in events.append("start") },
|
||||
stopTyping: { _ in events.append("stop") },
|
||||
sleep: { _ in didSleep = true }
|
||||
)
|
||||
|
||||
#expect(didSleep == true)
|
||||
#expect(events == ["start", "stop"])
|
||||
}
|
||||
59
Tests/imsgTests/LaunchStatusCommandTests.swift
Normal file
59
Tests/imsgTests/LaunchStatusCommandTests.swift
Normal file
@ -0,0 +1,59 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
@Test
|
||||
func commandRouterIncludesLaunchCommand() async {
|
||||
let router = CommandRouter()
|
||||
let names = router.specs.map(\.name)
|
||||
#expect(names.contains("launch"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func commandRouterIncludesReadCommand() async {
|
||||
let router = CommandRouter()
|
||||
let names = router.specs.map(\.name)
|
||||
#expect(names.contains("read"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func commandRouterIncludesStatusCommand() async {
|
||||
let router = CommandRouter()
|
||||
let names = router.specs.map(\.name)
|
||||
#expect(names.contains("status"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func statusCommandProducesJsonOutput() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: [:],
|
||||
flags: ["jsonOutput"]
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
|
||||
let (output, _) = await StdoutCapture.capture {
|
||||
try? await StatusCommand.run(values: values, runtime: runtime)
|
||||
}
|
||||
// JSON output should contain expected keys
|
||||
#expect(output.contains("basic_features"))
|
||||
#expect(output.contains("advanced_features"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func statusCommandProducesTextOutput() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: [:],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
|
||||
let (output, _) = await StdoutCapture.capture {
|
||||
try? await StatusCommand.run(values: values, runtime: runtime)
|
||||
}
|
||||
#expect(output.contains("imsg Status Report"))
|
||||
}
|
||||
71
Tests/imsgTests/ReadCommandTests.swift
Normal file
71
Tests/imsgTests/ReadCommandTests.swift
Normal file
@ -0,0 +1,71 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
@Test
|
||||
func readCommandRejectsChatAndRecipient() async {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["+15551234567"], "chatIdentifier": ["iMessage;+;chat123"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await ReadCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
markAsRead: { _ in }
|
||||
)
|
||||
#expect(Bool(false))
|
||||
} catch let error as ParsedValuesError {
|
||||
#expect(error.description == "Invalid value for option: --to")
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func readCommandRunsWithRecipient() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["+15551234567"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
var capturedHandle: String?
|
||||
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await ReadCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
markAsRead: { handle in capturedHandle = handle }
|
||||
)
|
||||
}
|
||||
|
||||
#expect(capturedHandle == "+15551234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
func readCommandResolvesChatID() async throws {
|
||||
let path = try CommandTestDatabase.makePath()
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["db": [path], "chatID": ["1"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
var capturedHandle: String?
|
||||
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await ReadCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
markAsRead: { handle in capturedHandle = handle }
|
||||
)
|
||||
}
|
||||
|
||||
#expect(capturedHandle == "iMessage;+;chat123")
|
||||
}
|
||||
125
Tests/imsgTests/TypingCommandTests.swift
Normal file
125
Tests/imsgTests/TypingCommandTests.swift
Normal file
@ -0,0 +1,125 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import IMsgCore
|
||||
@testable import imsg
|
||||
|
||||
@Test
|
||||
func typingCommandRejectsChatAndRecipient() async {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["+15551234567"], "chatIdentifier": ["iMessage;+;chat123"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await TypingCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
startTyping: { _ in },
|
||||
stopTyping: { _ in },
|
||||
typeForDuration: { _, _ in }
|
||||
)
|
||||
#expect(Bool(false))
|
||||
} catch let error as ParsedValuesError {
|
||||
#expect(error.description == "Invalid value for option: --to")
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func typingCommandRejectsInvalidService() async {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["+15551234567"], "service": ["fax"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await TypingCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
startTyping: { _ in },
|
||||
stopTyping: { _ in },
|
||||
typeForDuration: { _, _ in }
|
||||
)
|
||||
#expect(Bool(false))
|
||||
} catch let error as IMsgError {
|
||||
switch error {
|
||||
case .invalidService(let value):
|
||||
#expect(value == "fax")
|
||||
default:
|
||||
#expect(Bool(false))
|
||||
}
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func typingCommandRejectsInvalidStopOption() async {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["+15551234567"], "stop": ["1"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
do {
|
||||
try await TypingCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
startTyping: { _ in },
|
||||
stopTyping: { _ in },
|
||||
typeForDuration: { _, _ in }
|
||||
)
|
||||
#expect(Bool(false))
|
||||
} catch let error as ParsedValuesError {
|
||||
#expect(error.description == "Invalid value for option: --stop")
|
||||
} catch {
|
||||
#expect(Bool(false))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func typingCommandUsesSMSIdentifierForRecipient() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["+15551234567"], "service": ["sms"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
var startedIdentifier: String?
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await TypingCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
startTyping: { identifier in startedIdentifier = identifier },
|
||||
stopTyping: { _ in },
|
||||
typeForDuration: { _, _ in }
|
||||
)
|
||||
}
|
||||
#expect(startedIdentifier == "SMS;-;+15551234567")
|
||||
}
|
||||
|
||||
@Test
|
||||
func typingCommandParsesMinuteDuration() async throws {
|
||||
let values = ParsedValues(
|
||||
positional: [],
|
||||
options: ["to": ["+15551234567"], "duration": ["1m"]],
|
||||
flags: []
|
||||
)
|
||||
let runtime = RuntimeOptions(parsedValues: values)
|
||||
var capturedDuration: TimeInterval?
|
||||
_ = try await StdoutCapture.capture {
|
||||
try await TypingCommand.run(
|
||||
values: values,
|
||||
runtime: runtime,
|
||||
startTyping: { _ in },
|
||||
stopTyping: { _ in },
|
||||
typeForDuration: { _, duration in capturedDuration = duration }
|
||||
)
|
||||
}
|
||||
#expect(capturedDuration == 60)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user