feat: swift 6 rewrite

This commit is contained in:
Peter Steinberger 2025-12-28 17:17:40 +01:00
parent cc616a5ae3
commit e3581d2b20
51 changed files with 1960 additions and 1665 deletions

View File

@ -10,20 +10,19 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
# Keep CI aligned with the Go toolchain defined in go.mod
go-version-file: go.mod
- name: Go fmt
run: |
diff=$(gofmt -l ./)
if [ -n "$diff" ]; then
echo "gofmt found issues:" && echo "$diff" && exit 1
fi
- name: Go test
run: go test ./...
- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
- name: golangci-lint
run: golangci-lint run --out-format=colored-line-number
- name: Swift version
run: swift --version
- name: Install SwiftLint
run: brew install swiftlint
- name: Resolve packages
run: swift package resolve
- name: Patch dependencies
run: scripts/patch-deps.sh
- name: Swift format lint
run: swift format lint --recursive Sources Tests
- name: SwiftLint
run: swiftlint
- name: Swift test
run: swift test
- name: Swift build
run: swift build -c release --product imsg

View File

@ -15,8 +15,8 @@ permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
release:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@ -33,25 +33,29 @@ jobs:
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Stash GoReleaser config
run: cp .goreleaser.yaml /tmp/.goreleaser.yaml
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
run: git checkout ${{ inputs.tag }}
- name: GoReleaser
uses: goreleaser/goreleaser-action@v6
- name: Resolve packages
run: swift package resolve
- name: Patch dependencies
run: scripts/patch-deps.sh
- name: Build
run: swift build -c release --product imsg
- name: Package artifact
run: |
mkdir -p dist
cp .build/release/imsg dist/imsg
(cd dist && zip -r imsg-macos.zip imsg)
- name: Publish release assets
uses: softprops/action-gh-release@v2
with:
distribution: goreleaser
version: "~> v2"
args: release --clean --config /tmp/.goreleaser.yaml
files: dist/imsg-macos.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

9
.gitignore vendored
View File

@ -30,3 +30,12 @@ go.work.sum
# Editor/IDE
# .idea/
# .vscode/
# SwiftPM
.build/
.swiftpm/
Package.resolved
*.xcodeproj
# Build artifacts
bin/

View File

@ -1,16 +0,0 @@
run:
timeout: 5m
tests: true
linters:
enable:
- govet
- staticcheck
- revive
- gofmt
- goimports
- gocritic
- errcheck
- ineffassign
- gosimple
issues:
exclude-use-default: false

View File

@ -1,29 +0,0 @@
version: 2
project_name: imsg
changelog:
disable: true
builds:
- id: imsg
main: ./cmd/imsg
binary: imsg
ldflags:
- -s -w -X main.version={{ .Version }}
env:
- CGO_ENABLED=0
goos:
- darwin
goarch:
- amd64
- arm64
archives:
- builds:
- imsg
format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
checksum:
name_template: checksums.txt

20
.swiftlint.yml Normal file
View File

@ -0,0 +1,20 @@
only_rules:
- trailing_whitespace
- trailing_newline
- line_length
- file_length
- type_body_length
line_length: 140
file_length:
warning: 500
error: 600
type_body_length:
warning: 300
error: 400
excluded:
- .build
- .swiftpm
- Packages
- .git

View File

@ -2,6 +2,15 @@
## Unreleased
## 0.2.0 - 2025-12-28
- feat: Swift 6 rewrite with reusable IMsgCore library target
- feat: Commander-based CLI with SwiftPM build/test workflow
- feat: event-driven watch using filesystem events (no polling)
- feat: SQLite.swift + PhoneNumberKit + NSAppleScript integration
- chore: SwiftLint + swift-format linting
- change: JSON attachment keys now snake_case
- deprecation note: `--interval` replaced by `--debounce` (no compatibility)
## 0.1.1 - 2025-12-27
- feat: `imsg chats --json`
- fix: drop sqlite `immutable` flag so new messages/replies show up (thanks @zleman1593)

41
Package.swift Normal file
View File

@ -0,0 +1,41 @@
// swift-tools-version: 6.0
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.0"),
.package(url: "https://github.com/stephencelis/SQLite.swift.git", from: "0.15.4"),
.package(url: "https://github.com/marmelroy/PhoneNumberKit.git", from: "4.2.2"),
],
targets: [
.target(
name: "IMsgCore",
dependencies: [
.product(name: "SQLite", package: "SQLite.swift"),
.product(name: "PhoneNumberKit", package: "PhoneNumberKit"),
],
linkerSettings: [
.linkedFramework("ScriptingBridge"),
]
),
.executableTarget(
name: "imsg",
dependencies: [
"IMsgCore",
.product(name: "Commander", package: "Commander"),
]
),
.testTarget(
name: "IMsgCoreTests",
dependencies: [
"IMsgCore",
]
),
]
)

View File

@ -1,30 +1,32 @@
# 💬 imsg — Send, read, stream iMessage & SMS
A macOS Messages.app CLI to send, read, and stream iMessage/SMS (with attachment metadata). Read-only for receives; send uses AppleScript to drive Messages.app.
A macOS Messages.app CLI to send, read, and stream iMessage/SMS (with attachment metadata). Read-only for receives; send uses AppleScript (no private APIs).
## Features
- List chats, view history, or tail new messages (`watch`).
- List chats, view history, or stream new messages (`watch`).
- Send text and attachments via iMessage or SMS (AppleScript, no private APIs).
- Phone normalization to E.164 for reliable buddy lookup (`--region`, default US).
- Optional attachment metadata output (mime, name, path, missing flag).
- Filters: participants, start/end time, JSON output for tooling.
- Read-only DB access (`mode=ro`), no DB writes.
- Event-driven watch via filesystem events.
## Requirements
- macOS with Messages.app signed in.
- macOS 14+ with Messages.app signed in.
- Full Disk Access for your terminal to read `~/Library/Messages/chat.db`.
- Automation permission for your terminal to control Messages.app (for sending).
- For SMS relay, enable “Text Message Forwarding” on your iPhone to this Mac.
## Install
```bash
go install github.com/steipete/imsg/cmd/imsg@latest
pnpm build
# binary at ./bin/imsg
```
## Commands
- `imsg chats [--limit 20] [--json]` — list recent conversations.
- `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>] [--interval 2s] [--attachments] [--participants …] [--start …] [--end …] [--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]`
### Quick samples
@ -42,27 +44,18 @@ imsg history --chat-id 1 --limit 10 --attachments
imsg history --chat-id 1 --start 2025-01-01T00:00:00Z --json
# live stream a chat
imsg watch --chat-id 1 --attachments --interval 2s
imsg watch --chat-id 1 --attachments --debounce 250ms
# send a picture
imsg send --to "+14155551212" --text "hi" --file ~/Desktop/pic.jpg --service imessage
```
## Examples
```bash
imsg chats --limit 5
imsg chats --limit 5 --json
imsg history --chat-id 1 --attachments --start 2025-01-01T00:00:00Z --json
imsg watch --chat-id 1 --attachments --participants +15551234567
imsg send --to "+14155551212" --text "ping" --file ~/Desktop/pic.png --service imessage
```
## Attachment notes
`--attachments` prints per-attachment lines with name, MIME, missing flag, and resolved path (tilde expanded). Only metadata is shown; files arent copied.
## JSON output
`imsg chats --json` emits one JSON object per chat with fields: `id`, `name`, `identifier`, `service`, `last_message_at`.
`imsg history --json` and `imsg watch --json` emit one JSON object per message with fields: `id`, `chat_id`, `sender`, `is_from_me`, `text`, `created_at`, `attachments` (array of metadata).
`imsg history --json` and `imsg watch --json` emit one JSON object per message with fields: `id`, `chat_id`, `sender`, `is_from_me`, `text`, `created_at`, `attachments` (array of metadata with `filename`, `transfer_name`, `uti`, `mime_type`, `total_bytes`, `is_sticker`, `original_path`, `missing`).
## Permissions troubleshooting
If you see “unable to open database file” or empty output:
@ -72,10 +65,16 @@ If you see “unable to open database file” or empty output:
## Testing
```bash
go test ./...
pnpm test
```
## Limitations
- Requires a logged-in macOS user session (osascript needs UI access).
- No attachment export yet (metadata only).
- Polling-based watch (default 2s) — not event driven.
Note: pnpm scripts apply a small patch to SQLite.swift to silence a SwiftPM warning about `PrivacyInfo.xcprivacy`.
## Linting & formatting
```bash
pnpm lint
pnpm format
```
## Core library
The reusable Swift core lives in `Sources/IMsgCore` and is consumed by the CLI target. Apps can depend on the `IMsgCore` library target directly.

View File

@ -0,0 +1,17 @@
import Foundation
enum AttachmentResolver {
static func resolve(_ path: String) -> (resolved: String, missing: Bool) {
guard !path.isEmpty else { return ("", true) }
let expanded = (path as NSString).expandingTildeInPath
var isDir: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: expanded, isDirectory: &isDir)
return (expanded, !(exists && !isDir.boolValue))
}
static func displayName(filename: String, transferName: String) -> String {
if !transferName.isEmpty { return transferName }
if !filename.isEmpty { return filename }
return "(unknown)"
}
}

View File

@ -0,0 +1,36 @@
import Foundation
public enum IMsgError: LocalizedError, Sendable {
case permissionDenied(path: String, underlying: Error)
case invalidISODate(String)
case invalidService(String)
case appleScriptFailure(String)
public var errorDescription: String? {
switch self {
case .permissionDenied(let path, let underlying):
return """
\(underlying)
Permission Error: Cannot access Messages database
The Messages database at \(path) requires Full Disk Access permission.
To fix:
1. Open System Settings Privacy & Security Full Disk Access
2. Add your terminal application (Terminal.app, iTerm, etc.)
3. Restart your terminal
4. Try again
Note: This is required because macOS protects the Messages database.
For more details, see: https://github.com/steipete/imsg#permissions-troubleshooting
"""
case .invalidISODate(let value):
return "Invalid ISO8601 date: \(value)"
case .invalidService(let value):
return "Invalid service: \(value)"
case .appleScriptFailure(let message):
return "AppleScript failed: \(message)"
}
}
}

View File

@ -0,0 +1,21 @@
import Foundation
enum ISO8601Parser {
static func parse(_ value: String) -> Date? {
if value.isEmpty { return nil }
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = fractional.date(from: value) {
return date
}
let standard = ISO8601DateFormatter()
standard.formatOptions = [.withInternetDateTime]
return standard.date(from: value)
}
static func format(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: date)
}
}

View File

@ -0,0 +1,43 @@
import Foundation
public struct MessageFilter: Sendable, Equatable {
public let participants: [String]
public let startDate: Date?
public let endDate: Date?
public init(participants: [String] = [], startDate: Date? = nil, endDate: Date? = nil) {
self.participants = participants
self.startDate = startDate
self.endDate = endDate
}
public static func fromISO(participants: [String], startISO: String?, endISO: String?) throws
-> MessageFilter
{
let start = startISO.flatMap { ISO8601Parser.parse($0) }
if let startISO, start == nil {
throw IMsgError.invalidISODate(startISO)
}
let end = endISO.flatMap { ISO8601Parser.parse($0) }
if let endISO, end == nil {
throw IMsgError.invalidISODate(endISO)
}
return MessageFilter(participants: participants, startDate: start, endDate: end)
}
public func allows(_ message: Message) -> Bool {
if let startDate, message.date < startDate { return false }
if let endDate, message.date >= endDate { return false }
if !participants.isEmpty {
var match = false
for participant in participants {
if participant.caseInsensitiveCompare(message.sender) == .orderedSame {
match = true
break
}
}
if !match { return false }
}
return true
}
}

View File

@ -0,0 +1,111 @@
import Carbon
import Foundation
import ScriptingBridge
public enum MessageService: String, Sendable, CaseIterable {
case auto
case imessage
case sms
}
public struct MessageSendOptions: Sendable {
public var recipient: String
public var text: String
public var attachmentPath: String
public var service: MessageService
public var region: String
public init(
recipient: String,
text: String = "",
attachmentPath: String = "",
service: MessageService = .auto,
region: String = "US"
) {
self.recipient = recipient
self.text = text
self.attachmentPath = attachmentPath
self.service = service
self.region = region
}
}
public struct MessageSender {
private let normalizer = PhoneNumberNormalizer()
public init() {}
public func send(_ options: MessageSendOptions) throws {
var resolved = options
if resolved.region.isEmpty { resolved.region = "US" }
resolved.recipient = normalizer.normalize(resolved.recipient, region: resolved.region)
if resolved.service == .auto { resolved.service = .imessage }
let script = appleScript()
let arguments = [
resolved.recipient,
resolved.text,
resolved.service.rawValue,
resolved.attachmentPath,
resolved.attachmentPath.isEmpty ? "0" : "1",
]
try runAppleScript(source: script, arguments: arguments)
}
private func appleScript() -> String {
return """
on run argv
set theRecipient to item 1 of argv
set theMessage to item 2 of argv
set theService to item 3 of argv
set theFilePath to item 4 of argv
set useAttachment to item 5 of argv
tell application "Messages"
if theService is "sms" then
set targetService to first service whose service type is SMS
else
set targetService to first service whose service type is iMessage
end if
set targetBuddy to buddy theRecipient of targetService
if theMessage is not "" then
send theMessage to targetBuddy
end if
if useAttachment is "1" then
set theFile to POSIX file theFilePath as alias
send theFile to targetBuddy
end if
end tell
end run
"""
}
private 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 {
let message =
(errorInfo[NSAppleScript.errorMessage] as? String) ?? "Unknown AppleScript error"
throw IMsgError.appleScriptFailure(message)
}
}
}

View File

@ -0,0 +1,279 @@
import Foundation
import SQLite
public final class MessageStore: @unchecked Sendable {
public static let appleEpochOffset: TimeInterval = 978_307_200
public static var defaultPath: String {
let home = FileManager.default.homeDirectoryForCurrentUser.path
return NSString(string: home).appendingPathComponent("Library/Messages/chat.db")
}
public let path: String
private let connection: Connection
private let queue: DispatchQueue
private let queueKey = DispatchSpecificKey<Void>()
private let hasAttributedBody: Bool
public init(path: String = MessageStore.defaultPath) throws {
let normalized = NSString(string: path).expandingTildeInPath
self.path = normalized
self.queue = DispatchQueue(label: "imsg.db", qos: .userInitiated)
self.queue.setSpecific(key: queueKey, value: ())
do {
let uri = URL(fileURLWithPath: normalized).absoluteString
let location = Connection.Location.uri(uri, parameters: [.mode(.readOnly)])
self.connection = try Connection(location, readonly: true)
self.connection.busyTimeout = 5
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: self.connection)
} catch {
throw MessageStore.enhance(error: error, path: normalized)
}
}
init(connection: Connection, path: String, hasAttributedBody: Bool? = nil) throws {
self.path = path
self.queue = DispatchQueue(label: "imsg.db.test", qos: .userInitiated)
self.queue.setSpecific(key: queueKey, value: ())
self.connection = connection
self.connection.busyTimeout = 5
if let hasAttributedBody {
self.hasAttributedBody = hasAttributedBody
} else {
self.hasAttributedBody = MessageStore.detectAttributedBody(connection: connection)
}
}
public func listChats(limit: Int) throws -> [Chat] {
let sql = """
SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, c.service_name,
MAX(m.date) AS last_date
FROM chat c
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
JOIN message m ON m.ROWID = cmj.message_id
GROUP BY c.ROWID
ORDER BY last_date DESC
LIMIT ?
"""
return try withConnection { db in
var chats: [Chat] = []
for row in try db.prepare(sql, limit) {
let id = int64Value(row[0]) ?? 0
let name = stringValue(row[1])
let identifier = stringValue(row[2])
let service = stringValue(row[3])
let lastDate = appleDate(from: int64Value(row[4]))
chats.append(
Chat(
id: id, identifier: identifier, name: name, service: service, lastMessageAt: lastDate))
}
return chats
}
}
public func messages(chatID: Int64, limit: Int) throws -> [Message] {
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
let sql = """
SELECT m.ROWID, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
\(bodyColumn) AS body
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE cmj.chat_id = ?
ORDER BY m.date DESC
LIMIT ?
"""
return try withConnection { db in
var messages: [Message] = []
for row in try db.prepare(sql, chatID, limit) {
let rowID = int64Value(row[0]) ?? 0
let handleID = int64Value(row[1])
let sender = stringValue(row[2])
let text = stringValue(row[3])
let date = appleDate(from: int64Value(row[4]))
let isFromMe = boolValue(row[5])
let service = stringValue(row[6])
let attachments = intValue(row[7]) ?? 0
let body = dataValue(row[8])
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
messages.append(
Message(
rowID: rowID,
chatID: chatID,
sender: sender,
text: resolvedText,
date: date,
isFromMe: isFromMe,
service: service,
handleID: handleID,
attachmentsCount: attachments
))
}
return messages
}
}
public func messagesAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws -> [Message] {
let bodyColumn = hasAttributedBody ? "m.attributedBody" : "NULL"
var sql = """
SELECT m.ROWID, cmj.chat_id, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
\(bodyColumn) AS body
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE m.ROWID > ?
"""
var bindings: [Binding?] = [afterRowID]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID)
}
sql += " ORDER BY m.ROWID ASC LIMIT ?"
bindings.append(limit)
return try withConnection { db in
var messages: [Message] = []
for row in try db.prepare(sql, bindings) {
let rowID = int64Value(row[0]) ?? 0
let resolvedChatID = int64Value(row[1]) ?? chatID ?? 0
let handleID = int64Value(row[2])
let sender = stringValue(row[3])
let text = stringValue(row[4])
let date = appleDate(from: int64Value(row[5]))
let isFromMe = boolValue(row[6])
let service = stringValue(row[7])
let attachments = intValue(row[8]) ?? 0
let body = dataValue(row[9])
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
messages.append(
Message(
rowID: rowID,
chatID: resolvedChatID,
sender: sender,
text: resolvedText,
date: date,
isFromMe: isFromMe,
service: service,
handleID: handleID,
attachmentsCount: attachments
))
}
return messages
}
}
public func attachments(for messageID: Int64) throws -> [AttachmentMeta] {
let sql = """
SELECT a.filename, a.transfer_name, a.uti, a.mime_type, a.total_bytes, a.is_sticker
FROM message_attachment_join maj
JOIN attachment a ON a.ROWID = maj.attachment_id
WHERE maj.message_id = ?
"""
return try withConnection { db in
var metas: [AttachmentMeta] = []
for row in try db.prepare(sql, messageID) {
let filename = stringValue(row[0])
let transferName = stringValue(row[1])
let uti = stringValue(row[2])
let mimeType = stringValue(row[3])
let totalBytes = int64Value(row[4]) ?? 0
let isSticker = boolValue(row[5])
let resolved = AttachmentResolver.resolve(filename)
metas.append(
AttachmentMeta(
filename: filename,
transferName: transferName,
uti: uti,
mimeType: mimeType,
totalBytes: totalBytes,
isSticker: isSticker,
originalPath: resolved.resolved,
missing: resolved.missing
))
}
return metas
}
}
public func maxRowID() throws -> Int64 {
return try withConnection { db in
let value = try db.scalar("SELECT MAX(ROWID) FROM message")
return int64Value(value) ?? 0
}
}
private func withConnection<T>(_ block: (Connection) throws -> T) throws -> T {
if DispatchQueue.getSpecific(key: queueKey) != nil {
return try block(connection)
}
return try queue.sync {
try block(connection)
}
}
private static func detectAttributedBody(connection: Connection) -> Bool {
do {
let rows = try connection.prepare("PRAGMA table_info(message)")
for row in rows {
if let name = row[1] as? String,
name.caseInsensitiveCompare("attributedBody") == .orderedSame
{
return true
}
}
} catch {
return false
}
return false
}
private static func enhance(error: Error, path: String) -> Error {
let message = String(describing: error).lowercased()
if message.contains("out of memory (14)") || message.contains("authorization denied")
|| message.contains("unable to open database") || message.contains("cannot open")
{
return IMsgError.permissionDenied(path: path, underlying: error)
}
return error
}
private func appleDate(from value: Int64?) -> Date {
guard let value else { return Date(timeIntervalSince1970: MessageStore.appleEpochOffset) }
return Date(
timeIntervalSince1970: (Double(value) / 1_000_000_000) + MessageStore.appleEpochOffset)
}
private func stringValue(_ binding: Binding?) -> String {
return binding as? String ?? ""
}
private func int64Value(_ binding: Binding?) -> Int64? {
if let value = binding as? Int64 { return value }
if let value = binding as? Int { return Int64(value) }
if let value = binding as? Double { return Int64(value) }
return nil
}
private func intValue(_ binding: Binding?) -> Int? {
if let value = binding as? Int { return value }
if let value = binding as? Int64 { return Int(value) }
if let value = binding as? Double { return Int(value) }
return nil
}
private func boolValue(_ binding: Binding?) -> Bool {
if let value = binding as? Bool { return value }
if let value = intValue(binding) { return value != 0 }
return false
}
private func dataValue(_ binding: Binding?) -> Data {
if let blob = binding as? Blob {
return Data(blob.bytes)
}
return Data()
}
}

View File

@ -0,0 +1,143 @@
import Darwin
import Foundation
public struct MessageWatcherConfiguration: Sendable, Equatable {
public var debounceInterval: TimeInterval
public var batchLimit: Int
public init(debounceInterval: TimeInterval = 0.25, batchLimit: Int = 100) {
self.debounceInterval = debounceInterval
self.batchLimit = batchLimit
}
}
public final class MessageWatcher: @unchecked Sendable {
private let store: MessageStore
public init(store: MessageStore) {
self.store = store
}
public func stream(
chatID: Int64? = nil,
sinceRowID: Int64? = nil,
configuration: MessageWatcherConfiguration = MessageWatcherConfiguration()
) -> AsyncThrowingStream<Message, Error> {
AsyncThrowingStream { continuation in
let state = WatchState(
store: store,
chatID: chatID,
sinceRowID: sinceRowID,
configuration: configuration,
continuation: continuation
)
state.start()
continuation.onTermination = { _ in
state.stop()
}
}
}
}
private final class WatchState: @unchecked Sendable {
private let store: MessageStore
private let chatID: Int64?
private let configuration: MessageWatcherConfiguration
private let continuation: AsyncThrowingStream<Message, Error>.Continuation
private let queue = DispatchQueue(label: "imsg.watch", qos: .userInitiated)
private var cursor: Int64
private var sources: [DispatchSourceFileSystemObject] = []
private var pending = false
init(
store: MessageStore,
chatID: Int64?,
sinceRowID: Int64?,
configuration: MessageWatcherConfiguration,
continuation: AsyncThrowingStream<Message, Error>.Continuation
) {
self.store = store
self.chatID = chatID
self.configuration = configuration
self.continuation = continuation
self.cursor = sinceRowID ?? 0
}
func start() {
queue.async {
do {
if self.cursor == 0 {
self.cursor = try self.store.maxRowID()
}
self.poll()
} catch {
self.continuation.finish(throwing: error)
}
}
let paths = [store.path, store.path + "-wal", store.path + "-shm"]
for path in paths {
if let source = makeSource(path: path) {
sources.append(source)
}
}
}
func stop() {
queue.async {
for source in self.sources {
source.cancel()
}
self.sources.removeAll()
}
}
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
}
private func schedulePoll() {
if pending { return }
pending = true
let delay = configuration.debounceInterval
queue.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else { return }
self.pending = false
self.poll()
}
}
private func poll() {
do {
let messages = try store.messagesAfter(
afterRowID: cursor,
chatID: chatID,
limit: configuration.batchLimit
)
for message in messages {
continuation.yield(message)
if message.rowID > cursor {
cursor = message.rowID
}
}
} catch {
continuation.finish(throwing: error)
}
}
}

View File

@ -0,0 +1,82 @@
import Foundation
public struct Chat: Sendable, Equatable {
public let id: Int64
public let identifier: String
public let name: String
public let service: String
public let lastMessageAt: Date
public init(id: Int64, identifier: String, name: String, service: String, lastMessageAt: Date) {
self.id = id
self.identifier = identifier
self.name = name
self.service = service
self.lastMessageAt = lastMessageAt
}
}
public struct Message: Sendable, Equatable {
public let rowID: Int64
public let chatID: Int64
public let sender: String
public let text: String
public let date: Date
public let isFromMe: Bool
public let service: String
public let handleID: Int64?
public let attachmentsCount: Int
public init(
rowID: Int64,
chatID: Int64,
sender: String,
text: String,
date: Date,
isFromMe: Bool,
service: String,
handleID: Int64?,
attachmentsCount: Int
) {
self.rowID = rowID
self.chatID = chatID
self.sender = sender
self.text = text
self.date = date
self.isFromMe = isFromMe
self.service = service
self.handleID = handleID
self.attachmentsCount = attachmentsCount
}
}
public struct AttachmentMeta: Sendable, Equatable {
public let filename: String
public let transferName: String
public let uti: String
public let mimeType: String
public let totalBytes: Int64
public let isSticker: Bool
public let originalPath: String
public let missing: Bool
public init(
filename: String,
transferName: String,
uti: String,
mimeType: String,
totalBytes: Int64,
isSticker: Bool,
originalPath: String,
missing: Bool
) {
self.filename = filename
self.transferName = transferName
self.uti = uti
self.mimeType = mimeType
self.totalBytes = totalBytes
self.isSticker = isSticker
self.originalPath = originalPath
self.missing = missing
}
}

View File

@ -0,0 +1,15 @@
import Foundation
import PhoneNumberKit
final class PhoneNumberNormalizer {
private let phoneNumberUtility = PhoneNumberUtility()
func normalize(_ input: String, region: String) -> String {
do {
let number = try phoneNumberUtility.parse(input, withRegion: region, ignoreType: true)
return phoneNumberUtility.format(number, toType: .e164)
} catch {
return input
}
}
}

View File

@ -0,0 +1,36 @@
import Foundation
enum TypedStreamParser {
static func parseAttributedBody(_ data: Data) -> String {
guard !data.isEmpty else { return "" }
var bytes = [UInt8](data)
let start = [UInt8(0x01), UInt8(0x2b)]
let end = [UInt8(0x86), UInt8(0x84)]
if let startIndex = bytes.firstIndex(of: start[0]) {
if startIndex + 1 < bytes.count, bytes[startIndex + 1] == start[1] {
bytes = Array(bytes[(startIndex + 2)...])
}
}
if let endIndex = bytes.firstIndex(of: end[0]) {
if endIndex + 1 < bytes.count, bytes[endIndex + 1] == end[1] {
bytes = Array(bytes[..<endIndex])
}
}
let text = String(decoding: bytes, as: UTF8.self)
return text.trimmingLeadingControlCharacters()
}
}
extension String {
fileprivate func trimmingLeadingControlCharacters() -> String {
var scalars = unicodeScalars
while let first = scalars.first,
CharacterSet.controlCharacters.contains(first) || first == "\n" || first == "\r"
{
scalars.removeFirst()
}
return String(String.UnicodeScalarView(scalars))
}
}

View File

@ -0,0 +1,11 @@
import IMsgCore
func pluralSuffix(for count: Int) -> String {
count == 1 ? "" : "s"
}
func displayName(for meta: AttachmentMeta) -> String {
if !meta.transferName.isEmpty { return meta.transferName }
if !meta.filename.isEmpty { return meta.filename }
return "(unknown)"
}

View File

@ -0,0 +1,97 @@
import Commander
import Foundation
struct CommandRouter {
let rootName = "imsg"
let version: String
let specs: [CommandSpec]
let program: Program
init() {
self.version = ProcessInfo.processInfo.environment["IMSG_VERSION"] ?? "dev"
self.specs = [
ChatsCommand.spec,
HistoryCommand.spec,
WatchCommand.spec,
SendCommand.spec,
]
let descriptor = CommandDescriptor(
name: rootName,
abstract: "Send and read iMessage / SMS from the terminal",
discussion: nil,
signature: CommandSignature(),
subcommands: specs.map { $0.descriptor }
)
self.program = Program(descriptors: [descriptor])
}
func run() async -> Int32 {
let argv = normalizeArguments(CommandLine.arguments)
if argv.contains("--version") || argv.contains("-V") {
Swift.print(version)
return 0
}
if argv.count <= 1 || argv.contains("--help") || argv.contains("-h") {
printHelp(for: argv)
return 0
}
do {
let invocation = try program.resolve(argv: argv)
guard let commandName = invocation.path.last,
let spec = specs.first(where: { $0.name == commandName })
else {
Swift.print("Unknown command")
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
return 1
}
let runtime = RuntimeOptions(parsedValues: invocation.parsedValues)
do {
try await spec.run(invocation.parsedValues, runtime)
return 0
} catch {
Swift.print(error)
return 1
}
} catch let error as CommanderProgramError {
Swift.print(error.description)
if case .missingSubcommand = error {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
}
return 1
} catch {
Swift.print(error)
return 1
}
}
private func normalizeArguments(_ argv: [String]) -> [String] {
guard !argv.isEmpty else { return argv }
var copy = argv
copy[0] = URL(fileURLWithPath: argv[0]).lastPathComponent
return copy
}
private func printHelp(for argv: [String]) {
let path = helpPath(from: argv)
if path.count <= 1 {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
return
}
if let spec = specs.first(where: { $0.name == path[1] }) {
HelpPrinter.printCommand(rootName: rootName, spec: spec)
} else {
HelpPrinter.printRoot(version: version, rootName: rootName, commands: specs)
}
}
private func helpPath(from argv: [String]) -> [String] {
var path: [String] = []
for token in argv {
if token == "--help" || token == "-h" { continue }
if token.hasPrefix("-") { break }
path.append(token)
}
return path
}
}

View File

@ -0,0 +1,18 @@
import Commander
import IMsgCore
enum CommandSignatures {
static func baseOptions() -> [OptionDefinition] {
[
.make(
label: "db",
names: [.long("db")],
help: "Path to chat.db (defaults to ~/Library/Messages/chat.db)"
)
]
}
static func withRuntimeFlags(_ signature: CommandSignature) -> CommandSignature {
signature.withStandardRuntimeFlags()
}
}

View File

@ -0,0 +1,19 @@
import Commander
struct CommandSpec: @unchecked Sendable {
let name: String
let abstract: String
let discussion: String?
let signature: CommandSignature
let usageExamples: [String]
let run: (ParsedValues, RuntimeOptions) async throws -> Void
var descriptor: CommandDescriptor {
CommandDescriptor(
name: name,
abstract: abstract,
discussion: discussion,
signature: signature
)
}
}

View File

@ -0,0 +1,39 @@
import Commander
import Foundation
import IMsgCore
enum ChatsCommand {
static let spec = CommandSpec(
name: "chats",
abstract: "List recent conversations",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "limit", names: [.long("limit")], help: "Number of chats to list")
]
)
),
usageExamples: [
"imsg chats --limit 5",
"imsg chats --limit 5 --json",
]
) { values, runtime in
let dbPath = values.option("db") ?? MessageStore.defaultPath
let limit = values.optionInt("limit") ?? 20
let store = try MessageStore(path: dbPath)
let chats = try store.listChats(limit: limit)
if runtime.jsonOutput {
for chat in chats {
try JSONLines.print(ChatPayload(chat: chat))
}
return
}
for chat in chats {
let last = CLIISO8601.format(chat.lastMessageAt)
Swift.print("[\(chat.id)] \(chat.name) (\(chat.identifier)) last=\(last)")
}
}
}

View File

@ -0,0 +1,81 @@
import Commander
import Foundation
import IMsgCore
enum HistoryCommand {
static let spec = CommandSpec(
name: "history",
abstract: "Show recent messages for a chat",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chatID", names: [.long("chat-id")], help: "chat rowid from 'imsg chats'"),
.make(label: "limit", names: [.long("limit")], help: "Number of messages to show"),
.make(
label: "participants", names: [.long("participants")],
help: "filter by participant handles", parsing: .upToNextOption),
.make(label: "start", names: [.long("start")], help: "ISO8601 start (inclusive)"),
.make(label: "end", names: [.long("end")], help: "ISO8601 end (exclusive)"),
],
flags: [
.make(
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
)
]
)
),
usageExamples: [
"imsg history --chat-id 1 --limit 10 --attachments",
"imsg history --chat-id 1 --start 2025-01-01T00:00:00Z --json",
]
) { values, runtime in
guard let chatID = values.optionInt64("chatID") else {
throw ParsedValuesError.missingOption("chat-id")
}
let dbPath = values.option("db") ?? MessageStore.defaultPath
let limit = values.optionInt("limit") ?? 50
let showAttachments = values.flag("attachments")
let participants = values.optionValues("participants")
.flatMap { $0.split(separator: ",").map { String($0) } }
.filter { !$0.isEmpty }
let filter = try MessageFilter.fromISO(
participants: participants,
startISO: values.option("start"),
endISO: values.option("end")
)
let store = try MessageStore(path: dbPath)
let messages = try store.messages(chatID: chatID, limit: limit)
let filtered = messages.filter { filter.allows($0) }
if runtime.jsonOutput {
for message in filtered {
let attachments = try store.attachments(for: message.rowID)
try JSONLines.print(MessagePayload(message: message, attachments: attachments))
}
return
}
for message in filtered {
let direction = message.isFromMe ? "sent" : "recv"
let timestamp = CLIISO8601.format(message.date)
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
if message.attachmentsCount > 0 {
if showAttachments {
let metas = try store.attachments(for: message.rowID)
for meta in metas {
let name = displayName(for: meta)
Swift.print(
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
)
}
} else {
Swift.print(
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
)
}
}
}
}
}

View File

@ -0,0 +1,57 @@
import Commander
import Foundation
import IMsgCore
enum SendCommand {
static let spec = CommandSpec(
name: "send",
abstract: "Send a message (text and/or attachment)",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "to", names: [.long("to")], help: "phone number or email"),
.make(label: "text", names: [.long("text")], help: "message body"),
.make(label: "file", names: [.long("file")], help: "path to attachment"),
.make(
label: "service", names: [.long("service")], help: "service to use: imessage|sms|auto"),
.make(
label: "region", names: [.long("region")],
help: "default region for phone normalization"),
]
)
),
usageExamples: [
"imsg send --to +14155551212 --text \"hi\"",
"imsg send --to +14155551212 --text \"hi\" --file ~/Desktop/pic.jpg --service imessage",
]
) { values, runtime in
let recipient = try values.optionRequired("to")
let text = values.option("text") ?? ""
let file = values.option("file") ?? ""
if text.isEmpty && file.isEmpty {
throw ParsedValuesError.missingOption("text or file")
}
let serviceRaw = values.option("service") ?? "auto"
guard let service = MessageService(rawValue: serviceRaw) else {
throw IMsgError.invalidService(serviceRaw)
}
let region = values.option("region") ?? "US"
let sender = MessageSender()
try sender.send(
MessageSendOptions(
recipient: recipient,
text: text,
attachmentPath: file,
service: service,
region: region
))
if runtime.jsonOutput {
try JSONLines.print(["status": "sent"])
} else {
Swift.print("sent")
}
}
}

View File

@ -0,0 +1,93 @@
import Commander
import Foundation
import IMsgCore
enum WatchCommand {
static let spec = CommandSpec(
name: "watch",
abstract: "Stream incoming messages",
discussion: nil,
signature: CommandSignatures.withRuntimeFlags(
CommandSignature(
options: CommandSignatures.baseOptions() + [
.make(label: "chatID", names: [.long("chat-id")], help: "limit to chat rowid"),
.make(
label: "debounce", names: [.long("debounce")],
help: "debounce interval for filesystem events (e.g. 250ms)"),
.make(
label: "sinceRowID", names: [.long("since-rowid")],
help: "start watching after this rowid"),
.make(
label: "participants", names: [.long("participants")],
help: "filter by participant handles", parsing: .upToNextOption),
.make(label: "start", names: [.long("start")], help: "ISO8601 start (inclusive)"),
.make(label: "end", names: [.long("end")], help: "ISO8601 end (exclusive)"),
],
flags: [
.make(
label: "attachments", names: [.long("attachments")], help: "include attachment metadata"
)
]
)
),
usageExamples: [
"imsg watch --chat-id 1 --attachments --debounce 250ms",
"imsg watch --chat-id 1 --participants +15551234567",
]
) { values, runtime in
let dbPath = values.option("db") ?? MessageStore.defaultPath
let chatID = values.optionInt64("chatID")
let debounceString = values.option("debounce") ?? "250ms"
guard let debounceInterval = DurationParser.parse(debounceString) else {
throw ParsedValuesError.invalidOption("debounce")
}
let sinceRowID = values.optionInt64("sinceRowID")
let showAttachments = values.flag("attachments")
let participants = values.optionValues("participants")
.flatMap { $0.split(separator: ",").map { String($0) } }
.filter { !$0.isEmpty }
let filter = try MessageFilter.fromISO(
participants: participants,
startISO: values.option("start"),
endISO: values.option("end")
)
let store = try MessageStore(path: dbPath)
let watcher = MessageWatcher(store: store)
let config = MessageWatcherConfiguration(
debounceInterval: debounceInterval,
batchLimit: 100
)
for try await message in watcher.stream(
chatID: chatID, sinceRowID: sinceRowID, configuration: config)
{
if !filter.allows(message) {
continue
}
if runtime.jsonOutput {
let attachments = try store.attachments(for: message.rowID)
try JSONLines.print(MessagePayload(message: message, attachments: attachments))
continue
}
let direction = message.isFromMe ? "sent" : "recv"
let timestamp = CLIISO8601.format(message.date)
Swift.print("\(timestamp) [\(direction)] \(message.sender): \(message.text)")
if message.attachmentsCount > 0 {
if showAttachments {
let metas = try store.attachments(for: message.rowID)
for meta in metas {
let name = displayName(for: meta)
Swift.print(
" attachment: name=\(name) mime=\(meta.mimeType) missing=\(meta.missing) path=\(meta.originalPath)"
)
}
} else {
Swift.print(
" (\(message.attachmentsCount) attachment\(pluralSuffix(for: message.attachmentsCount)))"
)
}
}
}
}
}

View File

@ -0,0 +1,28 @@
import Foundation
enum DurationParser {
static func parse(_ value: String) -> TimeInterval? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let units: [(suffix: String, multiplier: Double)] = [
("ms", 0.001),
("s", 1),
("m", 60),
("h", 3600),
]
for unit in units {
if trimmed.hasSuffix(unit.suffix) {
let number = String(trimmed.dropLast(unit.suffix.count))
if let value = Double(number) {
return value * unit.multiplier
}
return nil
}
}
if let value = Double(trimmed) {
return value
}
return nil
}
}

View File

@ -0,0 +1,91 @@
import Commander
import Foundation
struct HelpPrinter {
static func printRoot(version: String, rootName: String, commands: [CommandSpec]) {
Swift.print("\(rootName) \(version)")
Swift.print("Send and read iMessage / SMS from the terminal")
Swift.print("")
Swift.print("Usage:")
Swift.print(" \(rootName) <command> [options]")
Swift.print("")
Swift.print("Commands:")
for command in commands {
Swift.print(" \(command.name)\t\(command.abstract)")
}
Swift.print("")
Swift.print("Run '\(rootName) <command> --help' for details.")
}
static func printCommand(rootName: String, spec: CommandSpec) {
Swift.print("\(rootName) \(spec.name)")
Swift.print(spec.abstract)
if let discussion = spec.discussion, !discussion.isEmpty {
Swift.print("\n\(discussion)")
}
Swift.print("")
Swift.print("Usage:")
Swift.print(" \(rootName) \(spec.name) \(usageFragment(for: spec.signature))")
Swift.print("")
if !spec.signature.arguments.isEmpty {
Swift.print("Arguments:")
for arg in spec.signature.arguments {
let optionalMark = arg.isOptional ? "?" : ""
Swift.print(" \(arg.label)\(optionalMark)\t\(arg.help ?? "")")
}
Swift.print("")
}
let options = spec.signature.options
let flags = spec.signature.flags
if !options.isEmpty || !flags.isEmpty {
Swift.print("Options:")
for option in options {
let names = formatNames(option.names, expectsValue: true)
Swift.print(" \(names)\t\(option.help ?? "")")
}
for flag in flags {
let names = formatNames(flag.names, expectsValue: false)
Swift.print(" \(names)\t\(flag.help ?? "")")
}
Swift.print("")
}
if !spec.usageExamples.isEmpty {
Swift.print("Examples:")
for example in spec.usageExamples {
Swift.print(" \(example)")
}
}
}
private static func usageFragment(for signature: CommandSignature) -> String {
var parts: [String] = []
for argument in signature.arguments {
let token = argument.isOptional ? "[\(argument.label)]" : "<\(argument.label)>"
parts.append(token)
}
if !signature.options.isEmpty || !signature.flags.isEmpty {
parts.append("[options]")
}
return parts.joined(separator: " ")
}
private static func formatNames(_ names: [CommanderName], expectsValue: Bool) -> String {
let parts = names.map { name -> String in
switch name {
case .short(let char):
return "-\(char)"
case .long(let value):
return "--\(value)"
case .aliasShort(let char):
return "-\(char)"
case .aliasLong(let value):
return "--\(value)"
}
}
let suffix = expectsValue ? " <value>" : ""
return parts.joined(separator: ", ") + suffix
}
}

View File

@ -0,0 +1,12 @@
import Foundation
@main
struct IMsgCLI {
static func main() async {
let router = CommandRouter()
let status = await router.run()
if status != 0 {
exit(status)
}
}
}

View File

@ -0,0 +1,16 @@
import Foundation
enum JSONLines {
private static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
return encoder
}()
static func print<T: Encodable>(_ value: T) throws {
let data = try encoder.encode(value)
if let line = String(data: data, encoding: .utf8) {
Swift.print(line)
}
}
}

View File

@ -0,0 +1,97 @@
import Foundation
import IMsgCore
struct ChatPayload: Codable {
let id: Int64
let name: String
let identifier: String
let service: String
let lastMessageAt: String
init(chat: Chat) {
self.id = chat.id
self.name = chat.name
self.identifier = chat.identifier
self.service = chat.service
self.lastMessageAt = CLIISO8601.format(chat.lastMessageAt)
}
enum CodingKeys: String, CodingKey {
case id
case name
case identifier
case service
case lastMessageAt = "last_message_at"
}
}
struct MessagePayload: Codable {
let id: Int64
let chatID: Int64
let sender: String
let isFromMe: Bool
let text: String
let createdAt: String
let attachments: [AttachmentPayload]
init(message: Message, attachments: [AttachmentMeta]) {
self.id = message.rowID
self.chatID = message.chatID
self.sender = message.sender
self.isFromMe = message.isFromMe
self.text = message.text
self.createdAt = CLIISO8601.format(message.date)
self.attachments = attachments.map { AttachmentPayload(meta: $0) }
}
enum CodingKeys: String, CodingKey {
case id
case chatID = "chat_id"
case sender
case isFromMe = "is_from_me"
case text
case createdAt = "created_at"
case attachments
}
}
struct AttachmentPayload: Codable {
let filename: String
let transferName: String
let uti: String
let mimeType: String
let totalBytes: Int64
let isSticker: Bool
let originalPath: String
let missing: Bool
init(meta: AttachmentMeta) {
self.filename = meta.filename
self.transferName = meta.transferName
self.uti = meta.uti
self.mimeType = meta.mimeType
self.totalBytes = meta.totalBytes
self.isSticker = meta.isSticker
self.originalPath = meta.originalPath
self.missing = meta.missing
}
enum CodingKeys: String, CodingKey {
case filename = "filename"
case transferName = "transfer_name"
case uti = "uti"
case mimeType = "mime_type"
case totalBytes = "total_bytes"
case isSticker = "is_sticker"
case originalPath = "original_path"
case missing = "missing"
}
}
enum CLIISO8601 {
static func format(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: date)
}
}

View File

@ -0,0 +1,54 @@
import Commander
enum ParsedValuesError: Error, CustomStringConvertible {
case missingOption(String)
case invalidOption(String)
case missingArgument(String)
var description: String {
switch self {
case .missingOption(let name):
return "Missing required option: --\(name)"
case .invalidOption(let name):
return "Invalid value for option: --\(name)"
case .missingArgument(let name):
return "Missing required argument: \(name)"
}
}
}
extension ParsedValues {
func flag(_ label: String) -> Bool {
flags.contains(label)
}
func option(_ label: String) -> String? {
options[label]?.last
}
func optionValues(_ label: String) -> [String] {
options[label] ?? []
}
func optionInt(_ label: String) -> Int? {
guard let value = option(label) else { return nil }
return Int(value)
}
func optionInt64(_ label: String) -> Int64? {
guard let value = option(label) else { return nil }
return Int64(value)
}
func optionRequired(_ label: String) throws -> String {
guard let value = option(label), !value.isEmpty else {
throw ParsedValuesError.missingOption(label)
}
return value
}
func argument(_ index: Int) -> String? {
guard positional.indices.contains(index) else { return nil }
return positional[index]
}
}

View File

@ -0,0 +1,13 @@
import Commander
struct RuntimeOptions: Sendable {
let jsonOutput: Bool
let verbose: Bool
let logLevel: String?
init(parsedValues: ParsedValues) {
self.jsonOutput = parsedValues.flags.contains("jsonOutput")
self.verbose = parsedValues.flags.contains("verbose")
self.logLevel = parsedValues.options["logLevel"]?.last
}
}

View File

@ -0,0 +1,284 @@
import Foundation
import SQLite
import Testing
@testable import IMsgCore
private enum TestDatabase {
static func appleEpoch(_ date: Date) -> Int64 {
let seconds = date.timeIntervalSince1970 - MessageStore.appleEpochOffset
return Int64(seconds * 1_000_000_000)
}
static func makeStore(includeAttributedBody: Bool = false) throws -> MessageStore {
let db = try Connection(.inMemory)
if includeAttributedBody {
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
attributedBody BLOB,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
} else {
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
}
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
display_name TEXT,
service_name TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
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
);
"""
)
let now = Date()
try db.run(
"""
INSERT INTO chat(ROWID, chat_identifier, display_name, service_name)
VALUES (1, '+123', 'Test Chat', 'iMessage')
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'Me')")
let messageRows: [(Int64, Int64, String?, Bool, Date, Int)] = [
(1, 1, "hello", false, now.addingTimeInterval(-600), 0),
(2, 2, "hi back", true, now.addingTimeInterval(-500), 1),
(3, 1, "photo", false, now.addingTimeInterval(-60), 0),
]
for row in messageRows {
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service)
VALUES (?,?,?,?,?,?)
""",
row.0,
row.1,
row.2,
appleEpoch(row.4),
row.3 ? 1 : 0,
"iMessage"
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, ?)", row.0)
if row.5 > 0 {
try db.run(
"""
INSERT INTO attachment(
ROWID,
filename,
transfer_name,
uti,
mime_type,
total_bytes,
is_sticker
)
VALUES (1, '~/Library/Messages/Attachments/test.dat', 'test.dat', 'public.data', 'application/octet-stream', 123, 0)
"""
)
try db.run(
"""
INSERT INTO message_attachment_join(message_id, attachment_id)
VALUES (?, 1)
""",
row.0
)
}
}
return try MessageStore(connection: db, path: ":memory:")
}
}
@Test
func listChatsReturnsChat() throws {
let store = try TestDatabase.makeStore()
let chats = try store.listChats(limit: 5)
#expect(chats.count == 1)
#expect(chats.first?.identifier == "+123")
}
@Test
func messagesByChatReturnsMessages() throws {
let store = try TestDatabase.makeStore()
let messages = try store.messages(chatID: 1, limit: 10)
#expect(messages.count == 3)
#expect(messages[1].isFromMe)
#expect(messages[0].attachmentsCount == 0)
}
@Test
func messagesAfterReturnsMessages() throws {
let store = try TestDatabase.makeStore()
let messages = try store.messagesAfter(afterRowID: 1, chatID: nil, limit: 10)
#expect(messages.count == 2)
#expect(messages.first?.rowID == 2)
}
@Test
func messagesByChatUsesAttributedBodyFallback() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
attributedBody BLOB,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
display_name TEXT,
service_name TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"""
CREATE TABLE message_attachment_join (
message_id INTEGER,
attachment_id INTEGER
);
"""
)
let now = Date()
let bodyBytes = [UInt8(0x01), UInt8(0x2b)] + Array("fallback text".utf8) + [0x86, 0x84]
let body = Blob(bytes: bodyBytes)
try db.run(
"""
INSERT INTO chat(ROWID, chat_identifier, display_name, service_name)
VALUES (1, '+123', 'Test Chat', 'iMessage')
"""
)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, attributedBody, date, is_from_me, service)
VALUES (1, 1, NULL, ?, ?, 0, 'iMessage')
""",
body,
TestDatabase.appleEpoch(now)
)
try db.run("INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)")
let store = try MessageStore(connection: db, path: ":memory:")
let messages = try store.messages(chatID: 1, limit: 10)
#expect(messages.count == 1)
#expect(messages.first?.text == "fallback text")
}
@Test
func messagesAfterUsesAttributedBodyFallback() throws {
let db = try Connection(.inMemory)
try db.execute(
"""
CREATE TABLE message (
ROWID INTEGER PRIMARY KEY,
handle_id INTEGER,
text TEXT,
attributedBody BLOB,
date INTEGER,
is_from_me INTEGER,
service TEXT
);
"""
)
try db.execute(
"""
CREATE TABLE chat (
ROWID INTEGER PRIMARY KEY,
chat_identifier TEXT,
display_name TEXT,
service_name TEXT
);
"""
)
try db.execute("CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);")
try db.execute("CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);")
try db.execute(
"""
CREATE TABLE message_attachment_join (
message_id INTEGER,
attachment_id INTEGER
);
"""
)
let now = Date()
let bodyBytes = [UInt8(0x01), UInt8(0x2b)] + Array("fallback text".utf8) + [0x86, 0x84]
let body = Blob(bytes: bodyBytes)
try db.run("INSERT INTO handle(ROWID, id) VALUES (1, '+123')")
try db.run(
"""
INSERT INTO message(ROWID, handle_id, text, attributedBody, date, is_from_me, service)
VALUES (1, 1, NULL, ?, ?, 0, 'iMessage')
""",
body,
TestDatabase.appleEpoch(now)
)
let store = try MessageStore(connection: db, path: ":memory:")
let messages = try store.messagesAfter(afterRowID: 0, chatID: nil, limit: 10)
#expect(messages.count == 1)
#expect(messages.first?.text == "fallback text")
}
@Test
func attachmentsByMessageReturnsMetadata() throws {
let store = try TestDatabase.makeStore()
let attachments = try store.attachments(for: 2)
#expect(attachments.count == 1)
#expect(attachments.first?.mimeType == "application/octet-stream")
}

View File

@ -1,383 +0,0 @@
// Command imsg is a CLI for interacting with macOS Messages.
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/steipete/imsg/internal/db"
"github.com/steipete/imsg/internal/send"
"github.com/steipete/imsg/internal/watch"
)
var (
dbPath string
version = "dev"
)
func main() {
root := &cobra.Command{
Use: "imsg",
Short: "Send and read iMessage / SMS from the terminal",
Long: `Examples:
imsg chats --limit 5
imsg history --chat-id 1 --limit 10 --attachments
imsg history --chat-id 1 --start 2025-01-01T00:00:00Z --json
imsg watch --chat-id 1 --attachments --interval 2s
imsg send --to "+14155551212" --text "hi" --file ~/Desktop/pic.jpg --service imessage
`,
Version: version,
}
root.PersistentFlags().StringVar(&dbPath, "db", db.DefaultPath(), "Path to chat.db (defaults to ~/Library/Messages/chat.db)")
root.PersistentFlags().BoolP("version", "V", false, "Show version")
root.SetVersionTemplate("{{.Version}}\n")
root.AddCommand(newChatsCmd())
root.AddCommand(newHistoryCmd())
root.AddCommand(newWatchCmd())
root.AddCommand(newSendCmd())
if err := root.Execute(); err != nil {
log.Fatal(err)
}
}
func newChatsCmd() *cobra.Command {
var limit int
var jsonOut bool
cmd := &cobra.Command{
Use: "chats",
Short: "List recent conversations",
RunE: func(cmd *cobra.Command, args []string) error {
_ = args
ctx := cmd.Context()
store, err := db.Open(ctx, dbPath)
if err != nil {
return err
}
defer func() { _ = store.Close() }()
chats, err := db.ListChats(ctx, store, limit)
if err != nil {
return err
}
if jsonOut {
enc := json.NewEncoder(os.Stdout)
for _, c := range chats {
if err := enc.Encode(map[string]any{
"id": c.ID,
"name": c.Name,
"identifier": c.Identifier,
"service": c.Service,
"last_message_at": c.LastMessageAt.Format(time.RFC3339),
}); err != nil {
return err
}
}
return nil
}
for _, c := range chats {
fmt.Printf("[%d] %s (%s) last=%s\n", c.ID, c.Name, c.Identifier, c.LastMessageAt.Format(time.RFC3339))
}
return nil
},
}
cmd.Flags().IntVar(&limit, "limit", 20, "Number of chats to list")
cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON objects instead of plain text")
return cmd
}
func newHistoryCmd() *cobra.Command {
var (
chatID int64
limit int
showAttachments bool
participants []string
startISO string
endISO string
jsonOut bool
)
cmd := &cobra.Command{
Use: "history",
Short: "Show recent messages for a chat",
RunE: func(cmd *cobra.Command, args []string) error {
_ = args
if chatID == 0 {
return fmt.Errorf("--chat-id is required")
}
ctx := cmd.Context()
store, err := db.Open(ctx, dbPath)
if err != nil {
return err
}
defer func() { _ = store.Close() }()
messages, err := db.MessagesByChat(ctx, store, chatID, limit)
if err != nil {
return err
}
filtered := filterMessages(messages, participants, startISO, endISO)
if jsonOut {
return printJSON(filtered, func(m db.Message, metas []db.AttachmentMeta) map[string]any {
return map[string]any{
"id": m.RowID,
"chat_id": m.ChatID,
"sender": m.Sender,
"is_from_me": m.IsFromMe,
"text": m.Text,
"created_at": m.Date.Format(time.RFC3339),
"attachments": metas,
}
})
}
for _, m := range filtered {
direction := "recv"
if m.IsFromMe {
direction = "sent"
}
fmt.Printf("%s [%s] %s: %s\n", m.Date.Format(time.RFC3339), direction, m.Sender, m.Text)
if m.Attachments > 0 {
if showAttachments {
metas, err := db.AttachmentsByMessage(ctx, store, m.RowID)
if err != nil {
return err
}
for _, meta := range metas {
fmt.Printf(" attachment: name=%s mime=%s missing=%t path=%s\n", displayName(meta), meta.MimeType, meta.Missing, meta.OriginalPath)
}
} else {
fmt.Printf(" (%d attachment%c)\n", m.Attachments, plural(m.Attachments))
}
}
}
return nil
},
}
cmd.Flags().Int64Var(&chatID, "chat-id", 0, "chat rowid from 'imsg chats'")
cmd.Flags().IntVar(&limit, "limit", 50, "Number of messages to show")
cmd.Flags().BoolVar(&showAttachments, "attachments", false, "include attachment metadata")
cmd.Flags().StringSliceVar(&participants, "participants", nil, "filter by participant handles (E.164 or email)")
cmd.Flags().StringVar(&startISO, "start", "", "ISO8601 start (inclusive), e.g. 2025-01-01T00:00:00Z")
cmd.Flags().StringVar(&endISO, "end", "", "ISO8601 end (exclusive)")
cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON objects instead of plain text")
return cmd
}
func newWatchCmd() *cobra.Command {
var (
chatID int64
interval time.Duration
since int64
showAttachments bool
participants []string
startISO string
endISO string
jsonOut bool
)
cmd := &cobra.Command{
Use: "watch",
Short: "Stream incoming messages",
RunE: func(cmd *cobra.Command, args []string) error {
_ = args
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
store, err := db.Open(ctx, dbPath)
if err != nil {
return err
}
defer func() { _ = store.Close() }()
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
go func() {
<-sig
cancel()
}()
startRowID := since
if startRowID == 0 {
startRowID, err = db.MaxRowID(ctx, store)
if err != nil {
return err
}
}
return watch.Run(ctx, store, chatID, startRowID, interval, func(msg db.Message) {
direction := "recv"
if msg.IsFromMe {
direction = "sent"
}
if !passesFilters(msg, participants, startISO, endISO) {
return
}
if jsonOut {
metas, _ := db.AttachmentsByMessage(ctx, store, msg.RowID)
entry := map[string]any{
"id": msg.RowID,
"chat_id": msg.ChatID,
"sender": msg.Sender,
"is_from_me": msg.IsFromMe,
"text": msg.Text,
"created_at": msg.Date.Format(time.RFC3339),
"attachments": metas,
}
printJSONSingle(entry)
return
}
fmt.Printf("%s [%s] %s: %s\n", msg.Date.Format(time.RFC3339), direction, msg.Sender, msg.Text)
if msg.Attachments > 0 {
if showAttachments {
metas, err := db.AttachmentsByMessage(ctx, store, msg.RowID)
if err == nil {
for _, meta := range metas {
fmt.Printf(" attachment: name=%s mime=%s missing=%t path=%s\n", displayName(meta), meta.MimeType, meta.Missing, meta.OriginalPath)
}
}
} else {
fmt.Printf(" (%d attachment%c)\n", msg.Attachments, plural(msg.Attachments))
}
}
})
},
}
cmd.Flags().Int64Var(&chatID, "chat-id", 0, "limit to chat rowid")
cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "polling interval")
cmd.Flags().Int64Var(&since, "since-rowid", 0, "start watching after this rowid (defaults to current max)")
cmd.Flags().BoolVar(&showAttachments, "attachments", false, "include attachment metadata")
cmd.Flags().StringSliceVar(&participants, "participants", nil, "filter by participant handles (E.164 or email)")
cmd.Flags().StringVar(&startISO, "start", "", "ISO8601 start (inclusive), e.g. 2025-01-01T00:00:00Z")
cmd.Flags().StringVar(&endISO, "end", "", "ISO8601 end (exclusive)")
cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON objects instead of plain text")
return cmd
}
func newSendCmd() *cobra.Command {
var opts send.Options
cmd := &cobra.Command{
Use: "send",
Short: "Send a message (text and/or attachment)",
RunE: func(cmd *cobra.Command, args []string) error {
_ = args
if opts.Recipient == "" {
return fmt.Errorf("--to is required")
}
if opts.Text == "" && opts.AttachmentPath == "" {
return fmt.Errorf("set --text and/or --file")
}
ctx := cmd.Context()
if err := send.Send(ctx, opts); err != nil {
return err
}
fmt.Println("sent")
return nil
},
}
cmd.Flags().StringVar(&opts.Recipient, "to", "", "phone number or email")
cmd.Flags().StringVar(&opts.Text, "text", "", "message body")
cmd.Flags().StringVar(&opts.AttachmentPath, "file", "", "path to attachment")
cmd.Flags().StringVar((*string)(&opts.Service), "service", string(send.ServiceAuto), "service to use: imessage|sms|auto")
cmd.Flags().StringVar(&opts.Region, "region", "US", "default region for phone normalization (ISO 3166-1 alpha-2)")
return cmd
}
func plural(n int) rune {
if n == 1 {
return ' '
}
return 's'
}
func parseISO(ts string) (time.Time, bool) {
if ts == "" {
return time.Time{}, false
}
if t, err := time.Parse(time.RFC3339, ts); err == nil {
return t, true
}
return time.Time{}, false
}
func filterMessages(msgs []db.Message, participants []string, startISO, endISO string) []db.Message {
start, hasStart := parseISO(startISO)
end, hasEnd := parseISO(endISO)
filters := func(m db.Message) bool {
if hasStart && m.Date.Before(start) {
return false
}
if hasEnd && !m.Date.Before(end) {
return false
}
if len(participants) > 0 {
match := false
for _, p := range participants {
if strings.EqualFold(m.Sender, p) {
match = true
break
}
}
if !match {
return false
}
}
return true
}
out := make([]db.Message, 0, len(msgs))
for _, m := range msgs {
if filters(m) {
out = append(out, m)
}
}
return out
}
func passesFilters(m db.Message, participants []string, startISO, endISO string) bool {
return len(filterMessages([]db.Message{m}, participants, startISO, endISO)) > 0
}
func printJSON(msgs []db.Message, fn func(db.Message, []db.AttachmentMeta) map[string]any) error {
enc := json.NewEncoder(os.Stdout)
for _, m := range msgs {
metas, _ := db.AttachmentsByMessage(context.Background(), mustOpenDB(), m.RowID)
if err := enc.Encode(fn(m, metas)); err != nil {
return err
}
}
return nil
}
func printJSONSingle(entry map[string]any) {
enc := json.NewEncoder(os.Stdout)
_ = enc.Encode(entry)
}
// mustOpenDB reuses dbPath to fetch attachments when printing JSON inside watchers.
func mustOpenDB() *sql.DB {
ctx := context.Background()
store, _ := db.Open(ctx, dbPath)
return store
}
func displayName(meta db.AttachmentMeta) string {
if meta.TransferName != "" {
return meta.TransferName
}
if meta.Filename != "" {
return meta.Filename
}
return "(unknown)"
}

View File

@ -1,166 +0,0 @@
package main
import (
"bufio"
"context"
"database/sql"
"encoding/json"
"io"
"os"
"strings"
"testing"
"time"
)
const appleEpochOffset = 978307200
func appleFromTime(t time.Time) int64 {
return t.Add(-time.Duration(appleEpochOffset) * time.Second).UnixNano()
}
func buildTempDB(t *testing.T) string {
t.Helper()
dbfile := t.TempDir() + "/chat.db"
dbConn, err := sql.Open("sqlite", "file:"+dbfile)
if err != nil {
t.Fatalf("open: %v", err)
}
stmts := []string{
`CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, display_name TEXT, service_name TEXT);`,
`CREATE TABLE message (ROWID INTEGER PRIMARY KEY, handle_id INTEGER, text TEXT, date INTEGER, is_from_me INTEGER, service TEXT);`,
`CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);`,
`CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);`,
`CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);`,
}
for _, s := range stmts {
if _, err := dbConn.Exec(s); err != nil {
t.Fatalf("exec %s: %v", s, err)
}
}
now := time.Now().UTC()
// seed data
_, _ = dbConn.Exec(`INSERT INTO chat(ROWID, chat_identifier, display_name, service_name) VALUES (1, '+123', 'Test Chat', 'iMessage')`)
_, _ = dbConn.Exec(`INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'Me')`)
msgs := []struct {
id int
handle int
text string
fromMe bool
date time.Time
}{
{1, 1, "hello", false, now.Add(-5 * time.Minute)},
{2, 2, "hi back", true, now.Add(-4 * time.Minute)},
}
for _, m := range msgs {
if _, err := dbConn.Exec(`INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service) VALUES (?,?,?,?,?,?)`, m.id, m.handle, m.text, appleFromTime(m.date), boolToInt(m.fromMe), "iMessage"); err != nil {
t.Fatalf("insert message: %v", err)
}
if _, err := dbConn.Exec(`INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, ?)`, m.id); err != nil {
t.Fatalf("insert cmj: %v", err)
}
}
_ = dbConn.Close()
return dbfile
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func TestChatsCommandPrintsChats(t *testing.T) {
dbPath = buildTempDB(t)
cmd := newChatsCmd()
cmd.SetContext(context.Background())
cmd.SetArgs([]string{"--limit", "5"})
out := captureOutput(t, func() {
_ = cmd.Execute()
})
if strings.TrimSpace(out) == "" {
t.Fatalf("expected output, got empty")
}
if !strings.Contains(out, "Test Chat") {
t.Fatalf("expected chat name in output, got %s", out)
}
}
func TestChatsCommandPrintsJSON(t *testing.T) {
dbPath = buildTempDB(t)
cmd := newChatsCmd()
cmd.SetContext(context.Background())
cmd.SetArgs([]string{"--limit", "5", "--json"})
out := captureOutput(t, func() {
if err := cmd.Execute(); err != nil {
t.Fatalf("execute chats: %v", err)
}
})
scanner := bufio.NewScanner(strings.NewReader(out))
if !scanner.Scan() {
t.Fatalf("expected JSON output, got empty")
}
var payload map[string]any
if err := json.Unmarshal(scanner.Bytes(), &payload); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if payload["name"] != "Test Chat" {
t.Fatalf("expected name, got %#v", payload["name"])
}
if payload["identifier"] != "+123" {
t.Fatalf("expected identifier, got %#v", payload["identifier"])
}
if payload["last_message_at"] == "" {
t.Fatalf("expected last_message_at, got empty")
}
}
func TestHistoryCommandRequiresChatID(t *testing.T) {
dbPath = buildTempDB(t)
cmd := newHistoryCmd()
cmd.SetContext(context.Background())
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when chat-id missing")
}
}
func TestHistoryCommandPrintsMessages(t *testing.T) {
dbPath = buildTempDB(t)
cmd := newHistoryCmd()
cmd.SetContext(context.Background())
cmd.SetArgs([]string{"--chat-id", "1", "--limit", "10"})
out := captureOutput(t, func() {
if err := cmd.Execute(); err != nil {
t.Fatalf("execute history: %v", err)
}
})
if !strings.Contains(out, "hello") || !strings.Contains(out, "hi back") {
t.Fatalf("missing messages in output: %s", out)
}
}
func captureOutput(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
os.Stdout = w
fn()
_ = w.Close()
os.Stdout = old
outBytes, err := io.ReadAll(r)
if err != nil {
t.Fatalf("read: %v", err)
}
return string(outBytes)
}

View File

@ -11,11 +11,11 @@
2. Ensure CI is green on `main`
- `pnpm lint`
- `pnpm test`
- `gh run list --branch main --limit 1`
- `pnpm format` (optional, if formatting changes are expected)
3. Tag and push
- `git tag -a vX.Y.Z -m "vX.Y.Z"`
- `git push origin vX.Y.Z`
## What happens in CI
- `.github/workflows/release.yml` runs GoReleaser on tag push.
- `.github/workflows/release.yml` builds a macOS release binary and uploads `imsg-macos.zip`.
- After assets upload, the workflow updates the GitHub Release body using the `CHANGELOG.md` section for `X.Y.Z`.

26
go.mod
View File

@ -1,26 +0,0 @@
module github.com/steipete/imsg
go 1.24.0
require (
github.com/nyaruka/phonenumbers v1.6.7
github.com/spf13/cobra v1.10.2
modernc.org/sqlite v1.41.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.67.2 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

80
go.sum
View File

@ -1,80 +0,0 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nyaruka/phonenumbers v1.6.7 h1:WmebT8TNEzNaui5QlrGqbccRC6dZkEkYc+MGQoILSSo=
github.com/nyaruka/phonenumbers v1.6.7/go.mod h1:7gjs+Lchqm49adhAKB5cdcng5ZXgt6x7Jgvi0ZorUtU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.2 h1:ZbNmly1rcbjhot5jlOZG0q4p5VwFfjwWqZ5rY2xxOXo=
modernc.org/libc v1.67.2/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.41.0 h1:bJXddp4ZpsqMsNN1vS0jWo4IJTZzb8nWpcgvyCFG9Ck=
modernc.org/sqlite v1.41.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

BIN
imsg

Binary file not shown.

View File

@ -1,374 +0,0 @@
// Package db provides read-only access to the macOS Messages SQLite store.
package db
import (
"bytes"
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"time"
// modernc sqlite provides a pure-Go sqlite driver for CI/macOS without CGO.
_ "modernc.org/sqlite"
)
// AppleEpochOffset is the number of seconds between 1970-01-01 and 2001-01-01.
const AppleEpochOffset = 978307200
// Chat represents a conversation.
type Chat struct {
ID int64
Identifier string
Name string
Service string
LastMessageAt time.Time
}
// Message represents a single message row.
type Message struct {
RowID int64
ChatID int64
Sender string
Text string
Date time.Time
IsFromMe bool
Service string
HandleID sql.NullInt64
Attachments int
}
// AttachmentMeta represents attachment metadata for a message.
type AttachmentMeta struct {
Filename string
TransferName string
UTI string
MimeType string
TotalBytes int64
IsSticker bool
OriginalPath string
Missing bool
}
// DefaultPath returns the default location of chat.db for the current user.
func DefaultPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, "Library", "Messages", "chat.db")
}
// Open opens chat.db read-only with sensible pragmas for concurrent access.
func Open(ctx context.Context, path string) (*sql.DB, error) {
// Note: Do NOT use immutable=1 here - it caches the database state and
// prevents seeing new messages (especially threaded replies) added after connection.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&mode=ro", filepath.Clean(path))
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, enhanceError(err, path)
}
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, enhanceError(err, path)
}
return db, nil
}
// enhanceError adds helpful context for common permission/access errors.
func enhanceError(err error, path string) error {
errStr := err.Error()
// SQLite error 14 (SQLITE_CANTOPEN) and "authorization denied" both indicate permission issues
if strings.Contains(errStr, "out of memory (14)") ||
strings.Contains(errStr, "authorization denied") ||
strings.Contains(errStr, "unable to open database") {
return fmt.Errorf(`%w
Permission Error: Cannot access Messages database
The Messages database at %s requires Full Disk Access permission.
To fix:
1. Open System Settings Privacy & Security Full Disk Access
2. Add your terminal application (Terminal.app, iTerm, etc.)
3. Restart your terminal
4. Try again
Note: This is required because macOS protects the Messages database.
For more details, see: https://github.com/steipete/imsg#permissions-troubleshooting`, err, path)
}
return err
}
// ListChats returns chats ordered by most recent activity.
func ListChats(ctx context.Context, db *sql.DB, limit int) ([]Chat, error) {
const q = `
SELECT c.ROWID, IFNULL(c.display_name, c.chat_identifier) AS name, c.chat_identifier, c.service_name,
MAX(m.date) AS last_date
FROM chat c
JOIN chat_message_join cmj ON c.ROWID = cmj.chat_id
JOIN message m ON m.ROWID = cmj.message_id
GROUP BY c.ROWID
ORDER BY last_date DESC
LIMIT ?`
rows, err := db.QueryContext(ctx, q, limit)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
chats := []Chat{}
for rows.Next() {
var (
id int64
name sql.NullString
ident sql.NullString
svc sql.NullString
lastNs sql.NullInt64
)
if err := rows.Scan(&id, &name, &ident, &svc, &lastNs); err != nil {
return nil, err
}
chats = append(chats, Chat{
ID: id,
Name: name.String,
Identifier: ident.String,
Service: svc.String,
LastMessageAt: appleTime(lastNs.Int64),
})
}
return chats, rows.Err()
}
// MessagesByChat returns recent messages for a chat ordered newest first.
func MessagesByChat(ctx context.Context, db *sql.DB, chatID int64, limit int) ([]Message, error) {
bodyCol := "''"
if columnExists(ctx, db, "message", "attributedBody") {
bodyCol = "m.attributedBody"
}
q := fmt.Sprintf(`
SELECT m.ROWID, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
%s as body
FROM message m
JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE cmj.chat_id = ?
ORDER BY m.date DESC
LIMIT ?`, bodyCol)
rows, err := db.QueryContext(ctx, q, chatID, limit)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
msgs := []Message{}
for rows.Next() {
var (
rowID int64
handleID sql.NullInt64
sender sql.NullString
text sql.NullString
dateNs sql.NullInt64
isFromMe bool
service sql.NullString
attachments int
body []byte
)
if err := rows.Scan(&rowID, &handleID, &sender, &text, &dateNs, &isFromMe, &service, &attachments, &body); err != nil {
return nil, err
}
resolvedText := text.String
if resolvedText == "" {
resolvedText = parseStreamTyped(body)
}
msgs = append(msgs, Message{
RowID: rowID,
ChatID: chatID,
Sender: sender.String,
Text: resolvedText,
Date: appleTime(dateNs.Int64),
IsFromMe: isFromMe,
Service: service.String,
HandleID: handleID,
Attachments: attachments,
})
}
return msgs, rows.Err()
}
// AttachmentsByMessage returns attachment metadata for a given message rowid.
func AttachmentsByMessage(ctx context.Context, db *sql.DB, messageID int64) ([]AttachmentMeta, error) {
const q = `
SELECT a.filename, a.transfer_name, a.uti, a.mime_type, a.total_bytes, a.is_sticker
FROM message_attachment_join maj
JOIN attachment a ON a.ROWID = maj.attachment_id
WHERE maj.message_id = ?`
rows, err := db.QueryContext(ctx, q, messageID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var out []AttachmentMeta
for rows.Next() {
var meta AttachmentMeta
if err := rows.Scan(&meta.Filename, &meta.TransferName, &meta.UTI, &meta.MimeType, &meta.TotalBytes, &meta.IsSticker); err != nil {
return nil, err
}
meta.OriginalPath, meta.Missing = resolvePath(meta.Filename)
out = append(out, meta)
}
return out, rows.Err()
}
// MessagesAfter returns messages after a given rowid (strictly greater).
func MessagesAfter(ctx context.Context, db *sql.DB, afterRowID int64, chatIDFilter int64, limit int) ([]Message, error) {
bodyCol := "''"
if columnExists(ctx, db, "message", "attributedBody") {
bodyCol = "m.attributedBody"
}
base := fmt.Sprintf(`
SELECT m.ROWID, cmj.chat_id, m.handle_id, h.id, IFNULL(m.text, '') AS text, m.date, m.is_from_me, m.service,
(SELECT COUNT(*) FROM message_attachment_join maj WHERE maj.message_id = m.ROWID) AS attachments,
%s as body
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
WHERE m.ROWID > ?`, bodyCol)
args := []any{afterRowID}
if chatIDFilter != 0 {
base += " AND cmj.chat_id = ?"
args = append(args, chatIDFilter)
}
base += " ORDER BY m.ROWID ASC LIMIT ?"
args = append(args, limit)
rows, err := db.QueryContext(ctx, base, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
msgs := []Message{}
for rows.Next() {
var (
rowID int64
chatID sql.NullInt64
handleID sql.NullInt64
sender sql.NullString
text sql.NullString
dateNs sql.NullInt64
isFromMe bool
service sql.NullString
attachments int
body []byte
)
if err := rows.Scan(&rowID, &chatID, &handleID, &sender, &text, &dateNs, &isFromMe, &service, &attachments, &body); err != nil {
return nil, err
}
resolvedText := text.String
if resolvedText == "" {
resolvedText = parseStreamTyped(body)
}
msgs = append(msgs, Message{
RowID: rowID,
ChatID: chatID.Int64,
Sender: sender.String,
Text: resolvedText,
Date: appleTime(dateNs.Int64),
IsFromMe: isFromMe,
Service: service.String,
HandleID: handleID,
Attachments: attachments,
})
}
return msgs, rows.Err()
}
// MaxRowID returns the current highest message rowid.
func MaxRowID(ctx context.Context, db *sql.DB) (int64, error) {
var maxID sql.NullInt64
if err := db.QueryRowContext(ctx, "SELECT MAX(ROWID) FROM message").Scan(&maxID); err != nil {
return 0, err
}
return maxID.Int64, nil
}
func appleTime(ns int64) time.Time {
// ns is nanoseconds since 2001-01-01 UTC
return time.Unix(0, ns).Add(time.Duration(AppleEpochOffset) * time.Second)
}
func resolvePath(p string) (string, bool) {
if p == "" {
return "", true
}
if strings.HasPrefix(p, "~") {
home, _ := os.UserHomeDir()
p = strings.Replace(p, "~", home, 1)
}
exists := false
if info, err := os.Stat(p); err == nil && !info.IsDir() {
exists = true
}
return p, !exists
}
// parseStreamTyped attempts to recover plain text from an attributedBody typedstream blob.
// It looks for the known start/end sentinels and decodes the UTF-8 payload.
func parseStreamTyped(body []byte) string {
if len(body) == 0 {
return ""
}
const (
startA = 0x01
startB = 0x2b
endA = 0x86
endB = 0x84
)
// Trim to data between markers if present
if idx := bytes.Index(body, []byte{startA, startB}); idx >= 0 && idx+2 < len(body) {
body = body[idx+2:]
}
if idx := bytes.Index(body, []byte{endA, endB}); idx >= 0 {
body = body[:idx]
}
// Decode, tolerating invalid sequences
out := string(bytes.ToValidUTF8(body, nil))
// Drop leading control chars/newlines that often prefix typedstream payloads
out = strings.TrimLeftFunc(out, func(r rune) bool { return r < 32 })
return out
}
// columnExists checks if a column is present on a table, used for older schemas.
func columnExists(ctx context.Context, db *sql.DB, table, column string) bool {
rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
cid int
name string
ctype sql.NullString
notnull sql.NullInt64
dflt sql.NullString
pk sql.NullInt64
)
// pragma table_info columns: cid,name,type,notnull,dflt_value,pk
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
continue
}
if strings.EqualFold(name, column) {
return true
}
}
return false
}

View File

@ -1,282 +0,0 @@
package db
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"testing"
"time"
)
func appleFromTime(t time.Time) int64 {
return t.Add(-time.Duration(AppleEpochOffset) * time.Second).UnixNano()
}
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", "file:imsgtest?mode=memory&cache=shared")
if err != nil {
t.Fatalf("open: %v", err)
}
stmts := []string{
`CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, display_name TEXT, service_name TEXT);`,
`CREATE TABLE message (ROWID INTEGER PRIMARY KEY, handle_id INTEGER, text TEXT, date INTEGER, is_from_me INTEGER, service TEXT);`,
`CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);`,
`CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);`,
`CREATE TABLE attachment (ROWID INTEGER PRIMARY KEY, filename TEXT, transfer_name TEXT, uti TEXT, mime_type TEXT, total_bytes INTEGER, is_sticker INTEGER);`,
`CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("exec %s: %v", s, err)
}
}
now := time.Now().UTC()
// sample data
_, _ = db.Exec(`INSERT INTO chat(ROWID, chat_identifier, display_name, service_name) VALUES (1, '+123', 'Test Chat', 'iMessage')`)
_, _ = db.Exec(`INSERT INTO handle(ROWID, id) VALUES (1, '+123'), (2, 'Me')`)
msgs := []struct {
id int
handle int
text string
fromMe bool
date time.Time
attachs int
}{
{1, 1, "hello", false, now.Add(-10 * time.Minute), 0},
{2, 2, "hi back", true, now.Add(-9 * time.Minute), 1},
{3, 1, "photo", false, now.Add(-1 * time.Minute), 0},
}
for _, m := range msgs {
if _, err := db.Exec(`INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service) VALUES (?,?,?,?,?,?)`, m.id, m.handle, m.text, appleFromTime(m.date), boolToInt(m.fromMe), "iMessage"); err != nil {
t.Fatalf("insert message: %v", err)
}
if _, err := db.Exec(`INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, ?)`, m.id); err != nil {
t.Fatalf("insert cmj: %v", err)
}
for i := 0; i < m.attachs; i++ {
_, _ = db.Exec(`INSERT INTO attachment(ROWID, filename, transfer_name, uti, mime_type, total_bytes, is_sticker) VALUES (?,?,?, ?, ?, ?, ?)`, i+1, "~/Library/Messages/Attachments/test.dat", "test.dat", "public.data", "application/octet-stream", 123, 0)
_, _ = db.Exec(`INSERT INTO message_attachment_join(message_id, attachment_id) VALUES (?, ?)`, m.id, i+1)
}
}
return db
}
func newTestDBWithBody(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", "file:imsgtestbody?mode=memory&cache=shared")
if err != nil {
t.Fatalf("open: %v", err)
}
stmts := []string{
`CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, display_name TEXT, service_name TEXT);`,
`CREATE TABLE message (ROWID INTEGER PRIMARY KEY, handle_id INTEGER, text TEXT, attributedBody BLOB, date INTEGER, is_from_me INTEGER, service TEXT);`,
`CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);`,
`CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);`,
`CREATE TABLE attachment (ROWID INTEGER PRIMARY KEY, filename TEXT, transfer_name TEXT, uti TEXT, mime_type TEXT, total_bytes INTEGER, is_sticker INTEGER);`,
`CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("exec %s: %v", s, err)
}
}
now := time.Now().UTC()
_, _ = db.Exec(`INSERT INTO chat(ROWID, chat_identifier, display_name, service_name) VALUES (1, '+123', 'Test Chat', 'iMessage')`)
_, _ = db.Exec(`INSERT INTO handle(ROWID, id) VALUES (1, '+123')`)
body := bodyBlob("fallback text")
if _, err := db.Exec(`INSERT INTO message(ROWID, handle_id, text, attributedBody, date, is_from_me, service) VALUES (?,?,?,?,?,?,?)`, 1, 1, nil, body, appleFromTime(now), 0, "iMessage"); err != nil {
t.Fatalf("insert message: %v", err)
}
_, _ = db.Exec(`INSERT INTO chat_message_join(chat_id, message_id) VALUES (1, 1)`)
return db
}
func newTempDiskDB(t *testing.T) (string, func()) {
t.Helper()
f, err := os.CreateTemp("", "imsg-disk-*.db")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
path := f.Name()
_ = f.Close()
cleanup := func() { _ = os.Remove(path) }
return path, cleanup
}
func bodyBlob(s string) []byte {
return append(append([]byte{0x01, 0x2b}, []byte(s)...), 0x86, 0x84)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func TestOpenSeesLiveUpdates(t *testing.T) {
ctx := context.Background()
path, cleanup := newTempDiskDB(t)
defer cleanup()
writer, err := sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&mode=rwc", filepath.Clean(path)))
if err != nil {
t.Fatalf("open writer: %v", err)
}
defer func() { _ = writer.Close() }()
if _, err := writer.Exec(`CREATE TABLE message (ROWID INTEGER PRIMARY KEY, text TEXT)`); err != nil {
t.Fatalf("create table: %v", err)
}
if _, err := writer.Exec(`INSERT INTO message(text) VALUES ('first')`); err != nil {
t.Fatalf("insert first: %v", err)
}
reader, err := Open(ctx, path)
if err != nil {
t.Fatalf("Open: %v", err)
}
reader.SetMaxOpenConns(1)
reader.SetMaxIdleConns(1)
defer func() { _ = reader.Close() }()
count := func(db *sql.DB) int {
var c int
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM message").Scan(&c); err != nil {
t.Fatalf("count: %v", err)
}
return c
}
if got := count(reader); got != 1 {
t.Fatalf("expected initial count 1, got %d", got)
}
if _, err := writer.Exec(`INSERT INTO message(text) VALUES ('second')`); err != nil {
t.Fatalf("insert second: %v", err)
}
if got := count(reader); got != 2 {
t.Fatalf("expected reader to see new rows, got %d", got)
}
}
func TestListChats(t *testing.T) {
ctx := context.Background()
store := newTestDB(t)
defer func() { _ = store.Close() }()
chats, err := ListChats(ctx, store, 5)
if err != nil {
t.Fatalf("ListChats: %v", err)
}
if len(chats) != 1 {
t.Fatalf("expected 1 chat, got %d", len(chats))
}
if chats[0].Identifier != "+123" {
t.Fatalf("unexpected identifier %s", chats[0].Identifier)
}
}
func TestMessagesByChat(t *testing.T) {
ctx := context.Background()
store := newTestDB(t)
defer func() { _ = store.Close() }()
msgs, err := MessagesByChat(ctx, store, 1, 10)
if err != nil {
t.Fatalf("MessagesByChat: %v", err)
}
if len(msgs) != 3 {
t.Fatalf("expected 3 messages, got %d", len(msgs))
}
if msgs[0].Attachments != 0 {
t.Fatalf("expected newest message attachments 0, got %d", msgs[0].Attachments)
}
if !msgs[1].IsFromMe {
t.Fatalf("expected second message from me")
}
}
func TestMessagesAfter(t *testing.T) {
ctx := context.Background()
store := newTestDB(t)
defer func() { _ = store.Close() }()
msgs, err := MessagesAfter(ctx, store, 1, 0, 10)
if err != nil {
t.Fatalf("MessagesAfter: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("expected 2 messages after rowid 1, got %d", len(msgs))
}
if msgs[0].RowID != 2 {
t.Fatalf("expected first rowid 2, got %d", msgs[0].RowID)
}
}
func TestMessagesByChatUsesAttributedBodyFallback(t *testing.T) {
ctx := context.Background()
store := newTestDBWithBody(t)
defer func() { _ = store.Close() }()
msgs, err := MessagesByChat(ctx, store, 1, 10)
if err != nil {
t.Fatalf("MessagesByChat fallback: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].Text != "fallback text" {
t.Fatalf("expected fallback text, got %q", msgs[0].Text)
}
}
func TestMessagesAfterUsesAttributedBodyFallback(t *testing.T) {
ctx := context.Background()
store := newTestDBWithBody(t)
defer func() { _ = store.Close() }()
msgs, err := MessagesAfter(ctx, store, 0, 0, 10)
if err != nil {
t.Fatalf("MessagesAfter fallback: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
if msgs[0].Text != "fallback text" {
t.Fatalf("expected fallback text, got %q", msgs[0].Text)
}
}
func TestParseStreamTypedTrimsControls(t *testing.T) {
blob := []byte{0x00, 0x01, 0x2b, '\n', 'H', 'i', 0x86, 0x84, '\r'}
got := parseStreamTyped(blob)
if got != "Hi" {
t.Fatalf("expected Hi, got %q", got)
}
}
func TestAttachmentsByMessage(t *testing.T) {
ctx := context.Background()
store := newTestDB(t)
defer func() { _ = store.Close() }()
metas, err := AttachmentsByMessage(ctx, store, 2)
if err != nil {
t.Fatalf("AttachmentsByMessage: %v", err)
}
if len(metas) != 1 {
t.Fatalf("expected 1 attachment, got %d", len(metas))
}
if metas[0].MimeType != "application/octet-stream" {
t.Fatalf("unexpected mime %s", metas[0].MimeType)
}
}

View File

@ -1,87 +0,0 @@
// Package send dispatches outbound Messages via AppleScript.
package send
import (
"context"
"fmt"
"os/exec"
"github.com/steipete/imsg/internal/util"
)
// Service represents the transport preference.
type Service string
// Service values control which Messages transport is used.
const (
// ServiceAuto picks iMessage when available, otherwise SMS.
ServiceAuto Service = "auto"
// ServiceIMessage forces iMessage.
ServiceIMessage Service = "imessage"
// ServiceSMS forces SMS/Text Relay.
ServiceSMS Service = "sms"
)
// Options controls how a message is sent.
type Options struct {
Recipient string
Text string
AttachmentPath string
Service Service
Region string
}
// Send dispatches a message via Messages.app using AppleScript.
func Send(ctx context.Context, opts Options) error {
if opts.Region == "" {
opts.Region = "US"
}
opts.Recipient = util.NormalizeE164(opts.Recipient, opts.Region)
svc := opts.Service
if svc == "" {
svc = ServiceAuto
}
attachFlag := "0"
if opts.AttachmentPath != "" {
attachFlag = "1"
}
script := appleScript()
args := []string{"-l", "AppleScript", "-e", script, opts.Recipient, opts.Text, string(svc), opts.AttachmentPath, attachFlag}
cmd := exec.CommandContext(ctx, "osascript", args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("osascript failed: %w (output: %s)", err, string(out))
}
return nil
}
func appleScript() string {
// Items: 1 recipient, 2 text, 3 service, 4 file path, 5 useAttachment
return `on run argv
set theRecipient to item 1 of argv
set theMessage to item 2 of argv
set theService to item 3 of argv
set theFilePath to item 4 of argv
set useAttachment to item 5 of argv
tell application "Messages"
if theService is "sms" then
set targetService to first service whose service type is SMS
else
set targetService to first service whose service type is iMessage
end if
set targetBuddy to buddy theRecipient of targetService
if theMessage is not "" then
send theMessage to targetBuddy
end if
if useAttachment is "1" then
-- Messages expects an alias; the coercion prevents "Can't get POSIX file" errors.
set theFile to POSIX file theFilePath as alias
send theFile to targetBuddy
end if
end tell
end run`
}

View File

@ -1,32 +0,0 @@
package send
import (
"strings"
"testing"
)
func TestAppleScriptIncludesMessages(t *testing.T) {
s := appleScript()
if s == "" {
t.Fatal("empty script")
}
if !containsAll(s, []string{"Messages", "send", "buddy"}) {
t.Fatalf("script missing expected tokens: %s", s)
}
}
func TestAppleScriptCoercesAttachmentAlias(t *testing.T) {
s := appleScript()
if !strings.Contains(s, "POSIX file theFilePath as alias") {
t.Fatalf("script should coerce attachment to alias to satisfy Messages: %s", s)
}
}
func containsAll(s string, parts []string) bool {
for _, p := range parts {
if !strings.Contains(s, p) {
return false
}
}
return true
}

View File

@ -1,14 +0,0 @@
// Package util contains helper utilities such as phone normalization.
package util
import "github.com/nyaruka/phonenumbers"
// NormalizeE164 attempts to format the number to E.164 using the provided region (e.g. "US").
// If parsing fails, it returns the original input.
func NormalizeE164(input, region string) string {
num, err := phonenumbers.Parse(input, region)
if err != nil || !phonenumbers.IsValidNumber(num) {
return input
}
return phonenumbers.Format(num, phonenumbers.E164)
}

View File

@ -1,18 +0,0 @@
package util
import "testing"
func TestNormalizeE164(t *testing.T) {
got := NormalizeE164("(415) 555-1212", "US")
want := "+14155551212"
if got != want {
t.Fatalf("want %s got %s", want, got)
}
}
func TestNormalizeE164Fallback(t *testing.T) {
raw := "not-a-number"
if got := NormalizeE164(raw, "US"); got != raw {
t.Fatalf("expected fallback to raw, got %s", got)
}
}

View File

@ -1,40 +0,0 @@
// Package watch streams new messages from the Messages database.
package watch
import (
"context"
"database/sql"
"time"
"github.com/steipete/imsg/internal/db"
)
// Run polls the database and invokes handler for each new message.
func Run(ctx context.Context, store *sql.DB, chatID int64, startRowID int64, interval time.Duration, handler func(db.Message)) error {
cursor := startRowID
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
msgs, err := db.MessagesAfter(ctx, store, cursor, chatID, 100)
if err != nil {
return err
}
for _, m := range msgs {
handler(m)
if m.RowID > cursor {
cursor = m.RowID
}
}
if len(msgs) == 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
}
}
}
}

View File

@ -1,56 +0,0 @@
package watch
import (
"context"
"database/sql"
"testing"
"time"
"github.com/steipete/imsg/internal/db"
)
func TestRunDeliversMessages(t *testing.T) {
store := buildDB(t)
defer func() { _ = store.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
var seen int
err := Run(ctx, store, 0, 0, 50*time.Millisecond, func(_ db.Message) {
seen++
})
if err != context.DeadlineExceeded {
t.Fatalf("expected deadline exceeded, got %v", err)
}
if seen == 0 {
t.Fatal("expected to see at least one message")
}
}
func buildDB(t *testing.T) *sql.DB {
t.Helper()
dbConn, err := sql.Open("sqlite", "file:watchtest?mode=memory&cache=shared")
if err != nil {
t.Fatalf("open: %v", err)
}
stmts := []string{
`CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, display_name TEXT, service_name TEXT);`,
`CREATE TABLE message (ROWID INTEGER PRIMARY KEY, handle_id INTEGER, text TEXT, date INTEGER, is_from_me INTEGER, service TEXT);`,
`CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT);`,
`CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);`,
`CREATE TABLE message_attachment_join (message_id INTEGER, attachment_id INTEGER);`,
}
for _, s := range stmts {
if _, err := dbConn.Exec(s); err != nil {
t.Fatalf("exec %s: %v", s, err)
}
}
now := time.Now().UTC()
apple := now.Add(-time.Duration(db.AppleEpochOffset) * time.Second).UnixNano()
_, _ = dbConn.Exec(`INSERT INTO chat(ROWID, chat_identifier) VALUES (1, '+1')`)
_, _ = dbConn.Exec(`INSERT INTO handle(ROWID, id) VALUES (1, '+1')`)
_, _ = dbConn.Exec(`INSERT INTO message(ROWID, handle_id, text, date, is_from_me, service) VALUES (1,1,'hi', ?,0,'iMessage')`, apple)
_, _ = dbConn.Exec(`INSERT INTO chat_message_join(chat_id, message_id) VALUES (1,1)`)
return dbConn
}

View File

@ -1,13 +1,14 @@
{
"name": "imsg",
"version": "0.1.1",
"version": "0.2.0",
"private": true,
"scripts": {
"imsg": "go run ./cmd/imsg",
"deps:patch": "swift package resolve && scripts/patch-deps.sh",
"imsg": "pnpm -s deps:patch && swift run imsg",
"start": "pnpm imsg",
"format": "gofmt -w cmd internal",
"lint": "golangci-lint run --out-format=colored-line-number",
"test": "go test ./...",
"build": "mkdir -p bin && go build -o bin/imsg ./cmd/imsg"
"format": "swift format --in-place --recursive Sources Tests",
"lint": "swift format lint --recursive Sources Tests && swiftlint",
"test": "pnpm -s deps:patch && swift test",
"build": "pnpm -s deps:patch && mkdir -p bin && swift build -c release --product imsg && cp .build/release/imsg bin/imsg"
}
}

23
scripts/patch-deps.sh Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
SQLITE_PACKAGE=".build/checkouts/SQLite.swift/Package.swift"
if [[ ! -f "$SQLITE_PACKAGE" ]]; then
exit 0
fi
chmod u+w "$SQLITE_PACKAGE" || true
python - <<'PY'
from pathlib import Path
path = Path('.build/checkouts/SQLite.swift/Package.swift')
text = path.read_text()
if 'PrivacyInfo.xcprivacy' in text:
raise SystemExit(0)
needle = 'exclude: [\n "Info.plist"\n ]'
replacement = 'exclude: [\n "Info.plist",\n "PrivacyInfo.xcprivacy"\n ]'
if needle in text:
text = text.replace(needle, replacement)
path.write_text(text)
PY