Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmux-tui/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions cmux-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ members = [
"crates/acpmux-build-id",
"crates/cmux-coderouter",
"crates/cmux-history",
"crates/cmux-imessage",
"crates/cmux-chat-index",
"crates/cmux-cloud-cli",
"crates/cmux-conversation",
Expand Down Expand Up @@ -227,6 +228,7 @@ cmux-flow-trace = { path = "crates/cmux-flow-trace" }
cmux-remote-protocol = { path = "crates/cmux-remote-protocol" }
cmux-conversation = { path = "crates/cmux-conversation" }
cmux-history = { path = "crates/cmux-history" }
cmux-imessage = { path = "crates/cmux-imessage" }
cmux-local-auth = { path = "crates/cmux-local-auth" }
cmux-config = { path = "crates/cmux-config" }
cmux-tui-cloud-conversations = { path = "crates/cmux-tui-cloud-conversations" }
Expand Down
18 changes: 18 additions & 0 deletions cmux-tui/crates/cmux-imessage/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "cmux-imessage"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
description = "Read-only reader of the macOS Messages database (chat.db) for cmux's opt-in Messages mirror: threads, group chats, messages and a watermark for live updates"

[dependencies]
rusqlite = { workspace = true }
serde = { workspace = true }

[dev-dependencies]
serde_json = { workspace = true }

[lints]
workspace = true
19 changes: 19 additions & 0 deletions cmux-tui/crates/cmux-imessage/src/date.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// Milliseconds between the Unix epoch and Apple's reference date (2001-01-01T00:00:00Z).
const APPLE_EPOCH_UNIX_MS: i64 = 978_307_200_000;

/// Converts a Messages `date` column to Unix milliseconds.
///
/// Since macOS 10.13 the column holds nanoseconds since 2001-01-01; older databases hold seconds.
/// Values above 10^11 cannot be seconds (that is past the year 5000), so they are nanoseconds.
/// Zero means "no date" and stays `None`.
pub fn apple_to_unix_ms(value: i64) -> Option<i64> {
if value == 0 {
return None;
}
let since_apple_ms = if value.abs() > 100_000_000_000 {
value / 1_000_000
} else {
value.saturating_mul(1_000)
};
Some(since_apple_ms.saturating_add(APPLE_EPOCH_UNIX_MS))
}
57 changes: 57 additions & 0 deletions cmux-tui/crates/cmux-imessage/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
use std::fmt;
use std::path::PathBuf;

/// Why the mirror could not read the Messages database.
#[derive(Debug)]
pub enum MirrorError {
/// macOS refused the read: the process lacks Full Disk Access. The UI asks Leo for the grant.
NotPermitted(PathBuf),
/// There is no Messages database at the path (Messages never set up on this Mac).
Missing(PathBuf),
/// The database is not shaped like a Messages database this reader knows.
Schema(String),
/// SQLite refused a statement or a step.
Sqlite(rusqlite::Error),
}

impl MirrorError {
/// A stable, machine-readable code for the wire (`not_permitted`, `missing`, ...).
pub fn code(&self) -> &'static str {
match self {
Self::NotPermitted(_) => "not_permitted",
Self::Missing(_) => "missing",
Self::Schema(_) => "schema",
Self::Sqlite(_) => "sqlite",
}
}
}

impl fmt::Display for MirrorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotPermitted(path) => write!(
f,
"messages mirror: no permission to read {} (grant Full Disk Access)",
path.display()
),
Self::Missing(path) => write!(f, "messages mirror: no database at {}", path.display()),
Self::Schema(message) => write!(f, "messages mirror: unexpected schema: {message}"),
Self::Sqlite(error) => write!(f, "messages mirror: sqlite: {error}"),
}
}
}

impl std::error::Error for MirrorError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Sqlite(error) => Some(error),
_ => None,
}
}
}

impl From<rusqlite::Error> for MirrorError {
fn from(error: rusqlite::Error) -> Self {
Self::Sqlite(error)
}
}
25 changes: 25 additions & 0 deletions cmux-tui/crates/cmux-imessage/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//! Read-only reader of the macOS Messages database (`~/Library/Messages/chat.db`) for cmux's
//! opt-in Messages mirror.
//!
//! The mirror is off by default and nothing it reads leaves the Mac. This crate only reads: it
//! opens the database with `SQLITE_OPEN_READ_ONLY`, never writes, and never takes a write lock, so
//! Messages.app keeps owning the file. Reading the file needs Full Disk Access for the process
//! that opens it; without it [`Reader::open`] returns [`MirrorError::NotPermitted`] so the UI can
//! ask for the grant.
//!
//! Shapes:
//! - [`Reader::threads`]: conversations (1:1 and group chats), newest activity first.
//! - [`Reader::messages`]: one conversation's messages, oldest first, paged backwards by rowid.
//! - [`Reader::since`]: messages newer than a rowid watermark, for live updates.

mod date;
mod error;
mod model;
mod reader;
mod typedstream;

pub use date::apple_to_unix_ms;
pub use error::MirrorError;
pub use model::{Attachment, Message, Thread, ThreadPreview};
pub use reader::{Reader, default_database_path};
pub use typedstream::decode_attributed_body;
58 changes: 58 additions & 0 deletions cmux-tui/crates/cmux-imessage/src/model.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
use serde::Serialize;

/// One conversation: a 1:1 chat or a group chat.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct Thread {
/// `chat.ROWID`, the id the other calls take.
pub chat_id: i64,
/// `chat.guid`, stable across launches (`iMessage;-;+15551234567`, `iMessage;+;chat123...`).
pub guid: String,
/// The group's name when it has one, else the participants' handles joined by ", ".
pub title: String,
/// The group's own name (`chat.display_name`), when set.
pub display_name: Option<String>,
pub is_group: bool,
/// `iMessage`, `SMS`, `RCS`, ...
pub service: Option<String>,
/// Participants' handles (phone numbers or emails), sorted, without the Mac's own account.
pub participants: Vec<String>,
pub last_message: Option<ThreadPreview>,
}

/// The newest message of a thread, for the list row.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct ThreadPreview {
pub rowid: i64,
pub text: Option<String>,
pub is_from_me: bool,
pub sender: Option<String>,
pub date_ms: Option<i64>,
pub has_attachments: bool,
}

/// One message of a conversation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct Message {
/// `message.ROWID`; increases with arrival, so it is the paging and live-update watermark.
pub rowid: i64,
pub guid: String,
pub chat_id: i64,
/// The sender's handle; `None` for the Mac's own messages.
pub sender: Option<String>,
pub is_from_me: bool,
pub date_ms: Option<i64>,
/// The text: `message.text`, or decoded from `attributedBody` when that column is empty.
pub text: Option<String>,
pub attachments: Vec<Attachment>,
}

/// An attachment's metadata. The mirror never copies the file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct Attachment {
pub name: Option<String>,
pub mime_type: Option<String>,
}
Loading
Loading