From a3b0a78f2b64372c77c6b64ebdef05aa4077b559 Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Fri, 24 Apr 2026 08:29:53 -0700 Subject: [PATCH] feat(gmessages): scaffold Google Messages Web scanner (Windows, read-only IDB) (#735) Co-authored-by: Jwalin Shah --- .../src/gmessages_scanner/cdp_walk.rs | 205 ++++++++++++ app/src-tauri/src/gmessages_scanner/idb.rs | 313 ++++++++++++++++++ app/src-tauri/src/gmessages_scanner/mod.rs | 239 +++++++++++++ app/src-tauri/src/lib.rs | 6 + 4 files changed, 763 insertions(+) create mode 100644 app/src-tauri/src/gmessages_scanner/cdp_walk.rs create mode 100644 app/src-tauri/src/gmessages_scanner/idb.rs create mode 100644 app/src-tauri/src/gmessages_scanner/mod.rs diff --git a/app/src-tauri/src/gmessages_scanner/cdp_walk.rs b/app/src-tauri/src/gmessages_scanner/cdp_walk.rs new file mode 100644 index 000000000..321a6b8c7 --- /dev/null +++ b/app/src-tauri/src/gmessages_scanner/cdp_walk.rs @@ -0,0 +1,205 @@ +//! CDP-driven walk of the Google Messages Web `bugle_db` IndexedDB. +//! +//! Pairs with `idb.rs` (schema + normalization). This module does the +//! `IndexedDB.requestData` paging + `Runtime.callFunctionOn` serialisation +//! dance, then hands the raw JSON rows to `idb::normalize_*` for shape +//! checking. + +use serde_json::{json, Value}; + +use super::idb::{ + self, Conversation, Message, ParticipantMap, DATABASE_NAME, STORE_CONVERSATIONS, + STORE_MESSAGES, STORE_PARTICIPANTS, +}; +use crate::cdp::CdpConn; + +/// IndexedDB security origin for the Google Messages Web app. +const ORIGIN: &str = "https://messages.google.com"; +/// Rows per `IndexedDB.requestData` call — matches the WhatsApp scanner. +const PAGE_SIZE: i64 = 500; +/// Hard cap per store to bound full-scan cost on huge histories. +const MAX_RECORDS_PER_STORE: usize = 20_000; +/// `Runtime.callFunctionOn` batch size for RemoteObject serialisation. +const SERIALIZE_BATCH: usize = 100; + +pub struct WalkResult { + pub messages: Vec, + pub conversations: Vec, + pub participants: ParticipantMap, +} + +/// Walk `bugle_db`: messages, conversations, participants. Per-store +/// failures are logged and swallowed so one bad store doesn't nuke the +/// cycle — the caller still gets whatever did come back. +pub async fn walk(cdp: &mut CdpConn, session: &str) -> Result { + // `IndexedDB.enable` is a no-op on modern Chromium but older CEF + // builds refuse `requestData` without it. Cost is trivial. + if let Err(e) = cdp.call("IndexedDB.enable", json!({}), Some(session)).await { + log::debug!("[gmessages][idb] enable: {}", e); + } + + let messages_raw = match read_store(cdp, session, STORE_MESSAGES).await { + Ok(v) => v, + Err(e) => { + log::warn!("[gmessages][idb] read {} failed: {}", STORE_MESSAGES, e); + Vec::new() + } + }; + let convos_raw = match read_store(cdp, session, STORE_CONVERSATIONS).await { + Ok(v) => v, + Err(e) => { + log::warn!("[gmessages][idb] read {} failed: {}", STORE_CONVERSATIONS, e); + Vec::new() + } + }; + let parts_raw = match read_store(cdp, session, STORE_PARTICIPANTS).await { + Ok(v) => v, + Err(e) => { + log::warn!("[gmessages][idb] read {} failed: {}", STORE_PARTICIPANTS, e); + Vec::new() + } + }; + + let messages: Vec = messages_raw + .iter() + .filter_map(idb::normalize_message) + .collect(); + let conversations: Vec = convos_raw + .iter() + .filter_map(idb::normalize_conversation) + .collect(); + let mut participants = ParticipantMap::default(); + for raw in &parts_raw { + if let Some((id, name)) = idb::normalize_participant(raw) { + participants.insert(id, name); + } + } + + log::info!( + "[gmessages][idb] walk messages={} conversations={} participants={}", + messages.len(), + conversations.len(), + participants.len() + ); + Ok(WalkResult { + messages, + conversations, + participants, + }) +} + +async fn read_store(cdp: &mut CdpConn, session: &str, store: &str) -> Result, String> { + let mut out: Vec = Vec::new(); + let mut skip: i64 = 0; + loop { + let remaining = MAX_RECORDS_PER_STORE.saturating_sub(out.len()); + if remaining == 0 { + break; + } + let page = (remaining as i64).min(PAGE_SIZE); + let resp = cdp + .call( + "IndexedDB.requestData", + json!({ + "securityOrigin": ORIGIN, + "databaseName": DATABASE_NAME, + "objectStoreName": store, + "indexName": "", + "skipCount": skip, + "pageSize": page, + }), + Some(session), + ) + .await?; + let entries = resp + .get("objectStoreDataEntries") + .and_then(|x| x.as_array()) + .cloned() + .unwrap_or_default(); + if entries.is_empty() { + break; + } + let value_refs: Vec<&Value> = entries + .iter() + .map(|e| e.get("value").unwrap_or(&Value::Null)) + .collect(); + let materialised = serialize_values(cdp, session, &value_refs).await?; + out.extend(materialised); + + let has_more = resp + .get("hasMore") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + skip += entries.len() as i64; + if !has_more { + break; + } + } + log::debug!("[gmessages][idb] store={} records={}", store, out.len()); + Ok(out) +} + +async fn serialize_values( + cdp: &mut CdpConn, + session: &str, + values: &[&Value], +) -> Result, String> { + let mut result: Vec = vec![Value::Null; values.len()]; + let mut pending: Vec<(usize, String)> = Vec::new(); + for (i, v) in values.iter().enumerate() { + if let Some(inline) = v.get("value") { + result[i] = inline.clone(); + continue; + } + if let Some(oid) = v.get("objectId").and_then(|x| x.as_str()) { + pending.push((i, oid.to_string())); + } + } + for chunk in pending.chunks(SERIALIZE_BATCH) { + let oids: Vec<&str> = chunk.iter().map(|(_, oid)| oid.as_str()).collect(); + let serialised = call_function_batch(cdp, session, &oids).await?; + if serialised.len() != chunk.len() { + return Err(format!( + "serialise batch length mismatch: got {}, expected {}", + serialised.len(), + chunk.len() + )); + } + for ((idx, _), val) in chunk.iter().zip(serialised.into_iter()) { + result[*idx] = val; + } + } + Ok(result) +} + +async fn call_function_batch( + cdp: &mut CdpConn, + session: &str, + object_ids: &[&str], +) -> Result, String> { + if object_ids.is_empty() { + return Ok(Vec::new()); + } + let (first, rest) = object_ids.split_first().unwrap(); + let args: Vec = rest.iter().map(|oid| json!({ "objectId": oid })).collect(); + let resp = cdp + .call( + "Runtime.callFunctionOn", + json!({ + "objectId": first, + "functionDeclaration": "function(){return [this].concat(Array.prototype.slice.call(arguments));}", + "arguments": args, + "returnByValue": true, + "silent": true, + }), + Some(session), + ) + .await?; + if let Some(exc) = resp.get("exceptionDetails") { + return Err(format!("callFunctionOn threw: {exc}")); + } + resp.pointer("/result/value") + .and_then(|v| v.as_array()) + .cloned() + .ok_or_else(|| format!("callFunctionOn result not array: {resp}")) +} diff --git a/app/src-tauri/src/gmessages_scanner/idb.rs b/app/src-tauri/src/gmessages_scanner/idb.rs new file mode 100644 index 000000000..c7755394c --- /dev/null +++ b/app/src-tauri/src/gmessages_scanner/idb.rs @@ -0,0 +1,313 @@ +//! Google Messages Web `bugle_db` IndexedDB schema + normalization. +//! +//! Schema knowledge is taken from publicly documented reverse-engineering +//! of the Google Messages Web client (the `mautrix-gmessages` project and +//! the Google Messages Web source itself). No code is copied — +//! only the factual store / key shape, which is not copyrightable. +//! +//! Stores we care about: +//! * `conversations` — thread metadata (id, participant ids, name) +//! * `messages` — individual SMS/RCS rows +//! * `participants` — participant id → contact name resolution +//! +//! Stores we deliberately skip: +//! * `settings`, `drafts`, `attachments-cache` — not needed for recall. +//! +//! This module only holds schema + normalization. The CDP walk that +//! actually calls `IndexedDB.requestData` will live alongside the WhatsApp +//! scanner's CDP plumbing once we lift a shared `cdp` module — see the +//! TODO in `mod.rs`. + +use std::collections::HashMap; + +use serde_json::Value; + +/// `bugle_db` database name. Stable since ~2022 per mautrix-gmessages +/// history; Google has not shipped a schema rename in the tracked window. +pub const DATABASE_NAME: &str = "bugle_db"; +pub const STORE_CONVERSATIONS: &str = "conversations"; +pub const STORE_MESSAGES: &str = "messages"; +pub const STORE_PARTICIPANTS: &str = "participants"; + +/// Normalized message row emitted to the memory-doc pipeline. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Message { + pub id: String, + pub thread_id: Option, + /// `None` when the message is outbound (sent by the user). + pub sender_id: Option, + pub from_me: bool, + /// Plain UTF-8 body. Attachments / reactions collapse to empty string + /// at normalization — callers render them as `[non-text]`. + pub text: String, + pub timestamp_unix: i64, + /// "sms", "rcs", "mms", etc. Preserved for downstream filters. + pub message_type: Option, +} + +/// Normalized conversation (thread) metadata. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Conversation { + pub thread_id: String, + pub display_name: Option, + pub participant_ids: Vec, +} + +/// Participant-id → display-name map. Populated from the `participants` +/// store; used by `format_transcript` to render human-readable senders. +#[derive(Debug, Default, Clone)] +pub struct ParticipantMap { + inner: HashMap, +} + +impl ParticipantMap { + pub fn insert(&mut self, id: String, name: String) { + self.inner.insert(id, name); + } + + pub fn display_name(&self, id: &str) -> Option { + self.inner.get(id).cloned() + } + + pub fn len(&self) -> usize { + self.inner.len() + } + + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +/// Convert a raw JSON row from the `messages` object store into our +/// normalized shape. Returns `None` if required fields are missing or +/// malformed — we log + skip rather than failing the entire walk. +/// +/// Expected bugle_db fields (observed, documented in mautrix-gmessages): +/// * `messageId` (string) — primary key +/// * `conversationId` (string) +/// * `senderId` (string, absent for outgoing) +/// * `messageStatus` (object with `status` int; outgoing statuses 2/4/6) +/// * `text` (string; may be absent for attachment-only) +/// * `timestamp` (int; microseconds since unix epoch) +/// * `messageType` (string: "SMS", "RCS", etc.) +pub fn normalize_message(raw: &Value) -> Option { + let id = raw.get("messageId")?.as_str()?.to_string(); + let thread_id = raw + .get("conversationId") + .and_then(|v| v.as_str()) + .map(str::to_string); + let sender_id = raw + .get("senderId") + .and_then(|v| v.as_str()) + .map(str::to_string); + let from_me = is_outgoing(raw); + let text = raw + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + // bugle_db timestamps are microseconds since unix epoch. Guard against + // the legacy-seconds form (< 10^12 = before year 33700 in micros, + // practically any real timestamp is in the 10^15 range). + let timestamp_unix = raw.get("timestamp").and_then(|v| v.as_i64()).map(|t| { + if t > 1_000_000_000_000 { + t / 1_000_000 + } else { + t + } + })?; + let message_type = raw + .get("messageType") + .and_then(|v| v.as_str()) + .map(|s| s.to_ascii_lowercase()); + + Some(Message { + id, + thread_id, + sender_id: if from_me { None } else { sender_id }, + from_me, + text, + timestamp_unix, + message_type, + }) +} + +/// Heuristic: bugle_db marks outgoing messages with a `messageStatus` +/// object whose `status` is in {2 (OUTGOING_DELIVERED), 4 (OUTGOING_READ), +/// 6 (OUTGOING_FAILED)} or an explicit boolean `isOutgoing` on newer +/// schemas. Fall back to `senderId == null` which is also a reliable +/// signal on older writes. +fn is_outgoing(raw: &Value) -> bool { + if let Some(b) = raw.get("isOutgoing").and_then(|v| v.as_bool()) { + return b; + } + if let Some(status) = raw + .get("messageStatus") + .and_then(|s| s.get("status")) + .and_then(|v| v.as_i64()) + { + // Status codes 1-9 are outgoing; 10+ are incoming (OUTGOING_* vs + // INCOMING_* in the bugle_db protobuf enum). Exact values per + // mautrix-gmessages' `libgm/events/types.go`. + return (1..=9).contains(&status); + } + raw.get("senderId") + .map(|v| v.is_null() || v.as_str().is_some_and(str::is_empty)) + .unwrap_or(false) +} + +/// Normalize a `conversations` store row. +pub fn normalize_conversation(raw: &Value) -> Option { + let thread_id = raw.get("conversationId")?.as_str()?.to_string(); + let display_name = raw.get("name").and_then(|v| v.as_str()).map(str::to_string); + let participant_ids = raw + .get("participantIds") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + Some(Conversation { + thread_id, + display_name, + participant_ids, + }) +} + +/// Normalize a `participants` store row into `(id, name)`. +pub fn normalize_participant(raw: &Value) -> Option<(String, String)> { + let id = raw.get("participantId")?.as_str()?.to_string(); + let name = raw + .get("fullName") + .or_else(|| raw.get("firstName")) + .or_else(|| raw.get("displayName")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if name.is_empty() { + None + } else { + Some((id, name)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalize_incoming_sms_row() { + let raw = json!({ + "messageId": "msg-1", + "conversationId": "thread-1", + "senderId": "+15551234567", + "text": "hello", + "timestamp": 1_700_000_000_000_000i64, + "messageType": "SMS", + "messageStatus": { "status": 100 }, + }); + let m = normalize_message(&raw).expect("normalize ok"); + assert_eq!(m.id, "msg-1"); + assert_eq!(m.thread_id.as_deref(), Some("thread-1")); + assert_eq!(m.sender_id.as_deref(), Some("+15551234567")); + assert!(!m.from_me); + assert_eq!(m.text, "hello"); + assert_eq!(m.timestamp_unix, 1_700_000_000); + assert_eq!(m.message_type.as_deref(), Some("sms")); + } + + #[test] + fn normalize_outgoing_row_sets_from_me_and_blanks_sender() { + let raw = json!({ + "messageId": "msg-2", + "conversationId": "thread-1", + "senderId": "+15559998888", + "text": "yo", + "timestamp": 1_700_000_005_000_000i64, + "messageStatus": { "status": 4 }, + }); + let m = normalize_message(&raw).expect("normalize ok"); + assert!(m.from_me, "status=4 is OUTGOING_READ"); + assert!(m.sender_id.is_none(), "outgoing rows blank the sender"); + } + + #[test] + fn normalize_accepts_legacy_second_precision_timestamp() { + let raw = json!({ + "messageId": "msg-3", + "conversationId": "thread-1", + "senderId": "+15551234567", + "text": "hi", + "timestamp": 1_700_000_000i64, + "messageStatus": { "status": 100 }, + }); + let m = normalize_message(&raw).expect("normalize ok"); + assert_eq!(m.timestamp_unix, 1_700_000_000); + } + + #[test] + fn normalize_skips_row_missing_required_fields() { + let raw = json!({ + "conversationId": "thread-1", + "text": "no id", + "timestamp": 1_700_000_000_000_000i64, + }); + assert!(normalize_message(&raw).is_none()); + } + + #[test] + fn normalize_conversation_row_with_participants() { + let raw = json!({ + "conversationId": "thread-1", + "name": "Family Group", + "participantIds": ["+15551234567", "+15559998888"], + }); + let c = normalize_conversation(&raw).expect("normalize ok"); + assert_eq!(c.thread_id, "thread-1"); + assert_eq!(c.display_name.as_deref(), Some("Family Group")); + assert_eq!(c.participant_ids.len(), 2); + } + + #[test] + fn normalize_participant_row_prefers_full_name() { + let raw = json!({ + "participantId": "+15551234567", + "fullName": "Alice Example", + "firstName": "Alice", + }); + let (id, name) = normalize_participant(&raw).expect("normalize ok"); + assert_eq!(id, "+15551234567"); + assert_eq!(name, "Alice Example"); + } + + #[test] + fn normalize_participant_falls_back_to_first_name() { + let raw = json!({ + "participantId": "+15551234567", + "firstName": "Alice", + }); + let (_, name) = normalize_participant(&raw).expect("normalize ok"); + assert_eq!(name, "Alice"); + } + + #[test] + fn normalize_participant_returns_none_for_empty_name() { + let raw = json!({ + "participantId": "+15551234567", + }); + assert!(normalize_participant(&raw).is_none()); + } + + #[test] + fn participant_map_roundtrip() { + let mut pm = ParticipantMap::default(); + assert!(pm.is_empty()); + pm.insert("+15551234567".into(), "Alice".into()); + assert_eq!(pm.len(), 1); + assert_eq!(pm.display_name("+15551234567").as_deref(), Some("Alice")); + assert!(pm.display_name("unknown").is_none()); + } +} diff --git a/app/src-tauri/src/gmessages_scanner/mod.rs b/app/src-tauri/src/gmessages_scanner/mod.rs new file mode 100644 index 000000000..dbf53964d --- /dev/null +++ b/app/src-tauri/src/gmessages_scanner/mod.rs @@ -0,0 +1,239 @@ +//! Google Messages Web scanner — Windows-focused, read-only IndexedDB walk. +//! +//! Scope for Stage 1: +//! * Read-only scan of `bugle_db` (the IndexedDB database used by +//! `messages.google.com/web`) via CDP on the embedded CEF webview. +//! * One ingest call per `(thread_id, day)` group — same +//! `openhuman.memory_doc_ingest` shape the iMessage and WhatsApp +//! scanners already use. +//! * No DOM automation. No send path. Send is deferred to a separate +//! PR that will use OS Accessibility APIs (macOS AX / Windows UIA) — +//! indistinguishable from a screen reader, so ToS-clean. +//! +//! Targeted at Windows + Android (the only practical combo for Google +//! Messages — iPhone owners use iMessage, mac users typically use +//! Messages Web in a browser tab that the CEF shell doesn't own). The +//! code is windows-gated at module-scope; on other targets the public +//! surface compiles to no-op stubs so `lib.rs` stays clean. +//! +//! History model differs from iMessage: +//! * iMessage (#724) reads `chat.db` which holds FULL history locally. +//! * Google Messages Web only caches in `bugle_db` what the web client +//! has already synced. If the user never scrolled to older +//! conversations, those pages aren't in IDB. Document this behavior +//! in the UI — "scroll to backfill older history." +//! +//! CDP wiring TODO: +//! * The CEF remote-debugging port + per-account target selection lives +//! in `whatsapp_scanner::mod` today (`CDP_HOST`/`CDP_PORT`, +//! `Target.getTargets` filter). When this module is promoted from +//! scaffold to running scanner, lift that plumbing into a shared +//! `cdp` module and point this scanner at the Google Messages Web +//! target (`messages.google.com/web`). Until then `run_scanner` is a +//! stub that logs and exits — the PR ships the normalization + +//! memory-doc shape so downstream can iterate without the full CDP +//! loop landed. + +// Scaffold PR — orchestrator loop is a stub pending the shared CDP lift +// from `whatsapp_scanner`. Once that lands and this module actually +// drives `idb::walk` + `memory_doc_ingest`, drop the blanket allow below. +#![allow(dead_code)] + +#[cfg(target_os = "windows")] +use std::sync::Arc; +#[cfg(target_os = "windows")] +use std::time::Duration; + +#[cfg(target_os = "windows")] +use parking_lot::Mutex; +#[cfg(target_os = "windows")] +use tauri::{AppHandle, Runtime}; + +pub mod idb; + +#[cfg(target_os = "windows")] +const SCAN_INTERVAL: Duration = Duration::from_secs(60); + +/// Per-account scanner registry. Google Messages Web supports one paired +/// phone per browser session; the registry shape is kept symmetric with +/// the iMessage / WhatsApp scanners for future multi-account expansion. +#[cfg(target_os = "windows")] +pub struct ScannerRegistry { + inner: Mutex>>, +} + +#[cfg(target_os = "windows")] +impl ScannerRegistry { + pub fn new() -> Self { + Self { + inner: Mutex::new(None), + } + } + + pub fn ensure_scanner(self: Arc, app: AppHandle, account_id: String) { + let mut guard = self.inner.lock(); + if guard.as_ref().map_or(false, |h| !h.is_finished()) { + return; + } + let handle = tokio::spawn(run_scanner(app, account_id)); + *guard = Some(handle); + } +} + +/// Stub loop — logs and exits. Wire CDP target discovery + `idb::walk` +/// here once the shared `cdp` module is lifted from `whatsapp_scanner`. +/// See module-level TODO. +#[cfg(target_os = "windows")] +async fn run_scanner(_app: AppHandle, account_id: String) { + log::info!( + "[gmessages] scanner scaffold loaded account={} interval={:?} — CDP wiring pending", + account_id, + SCAN_INTERVAL + ); +} + +// Non-Windows stub so the rest of the app compiles unchanged on mac/linux. +#[cfg(not(target_os = "windows"))] +pub struct ScannerRegistry; + +#[cfg(not(target_os = "windows"))] +impl ScannerRegistry { + pub fn new() -> Self { + Self + } + pub fn ensure_scanner( + self: std::sync::Arc, + _app: tauri::AppHandle, + _account_id: String, + ) { + } +} + +/// Format a list of normalized messages into a transcript string suitable +/// for `memory_doc_ingest.content`. Matches the iMessage scanner output +/// shape so Neocortex sees a uniform format across channels. +pub fn format_transcript(messages: &[idb::Message], participants: &idb::ParticipantMap) -> String { + let mut out = String::new(); + for m in messages { + let sender = if m.from_me { + "me".to_string() + } else { + m.sender_id + .as_deref() + .and_then(|sid| participants.display_name(sid)) + .unwrap_or_else(|| m.sender_id.clone().unwrap_or_else(|| "unknown".into())) + }; + let text = m.text.replace('\n', " "); + let body = if text.is_empty() { + "[non-text]".to_string() + } else { + text + }; + out.push_str(&format!("[{}] {}: {}\n", m.timestamp_unix, sender, body)); + } + out +} + +/// Group a flat list of messages into `(thread_id, YYYY-MM-DD) -> Vec`. +/// Day bucketing uses the local timezone — users inspect memory docs by +/// their calendar day, not UTC (same policy as iMessage #724 after the +/// CodeRabbit local-TZ fix). +pub fn group_by_thread_day( + messages: Vec, +) -> Vec<((String, String), Vec)> { + use std::collections::BTreeMap; + let mut groups: BTreeMap<(String, String), Vec> = BTreeMap::new(); + for m in messages { + let Some(thread_id) = m.thread_id.clone() else { + continue; + }; + let day = seconds_to_ymd(m.timestamp_unix); + groups.entry((thread_id, day)).or_default().push(m); + } + groups.into_iter().collect() +} + +/// Local-timezone day bucket for a unix-second timestamp. Returns +/// "YYYY-MM-DD" or "unknown" for values that fall outside chrono's range. +pub fn seconds_to_ymd(secs: i64) -> String { + use chrono::{Local, TimeZone}; + Local + .timestamp_opt(secs, 0) + .single() + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "unknown".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn msg(id: &str, thread: &str, ts: i64, text: &str, from_me: bool) -> idb::Message { + idb::Message { + id: id.into(), + thread_id: Some(thread.into()), + sender_id: if from_me { + None + } else { + Some("+15551234567".into()) + }, + from_me, + text: text.into(), + timestamp_unix: ts, + message_type: Some("sms".into()), + } + } + + #[test] + fn group_by_thread_day_buckets_messages_correctly() { + // Two messages ~5s apart in the same thread should fall into one + // group; a third in a different thread into its own group. + let base = 1_700_000_000; + let msgs = vec![ + msg("1", "t1", base, "hi", false), + msg("2", "t1", base + 5, "yo", true), + msg("3", "t2", base, "other", false), + ]; + let groups = group_by_thread_day(msgs); + assert_eq!(groups.len(), 2); + let t1 = groups.iter().find(|((t, _), _)| t == "t1").unwrap(); + assert_eq!(t1.1.len(), 2); + } + + #[test] + fn format_transcript_includes_sender_and_body() { + let msgs = vec![ + msg("1", "t1", 1_700_000_000, "hi", false), + msg("2", "t1", 1_700_000_005, "yo", true), + ]; + let participants = idb::ParticipantMap::default(); + let t = format_transcript(&msgs, &participants); + assert!(t.contains("hi")); + assert!(t.contains("me: yo")); + assert!(t.contains("+15551234567: hi")); + } + + #[test] + fn format_transcript_resolves_display_name_from_participants() { + let mut participants = idb::ParticipantMap::default(); + participants.insert("+15551234567".into(), "Alice".into()); + let msgs = vec![msg("1", "t1", 1_700_000_000, "hi", false)]; + let t = format_transcript(&msgs, &participants); + assert!(t.contains("Alice: hi"), "got {:?}", t); + } + + #[test] + fn format_transcript_marks_empty_body_as_non_text() { + let msgs = vec![msg("1", "t1", 1_700_000_000, "", false)]; + let t = format_transcript(&msgs, &idb::ParticipantMap::default()); + assert!(t.contains("[non-text]"), "got {:?}", t); + } + + #[test] + fn seconds_to_ymd_shape() { + let out = seconds_to_ymd(1_700_000_000); + assert_eq!(out.len(), 10); + assert_eq!(&out[4..5], "-"); + assert_eq!(&out[7..8], "-"); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 49197e726..9fc453954 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -9,6 +9,8 @@ mod core_update; mod discord_scanner; mod gmail; #[cfg(feature = "cef")] +mod gmessages_scanner; +#[cfg(feature = "cef")] mod imessage_scanner; mod notification_settings; #[cfg(feature = "cef")] @@ -626,6 +628,10 @@ pub fn run() { .manage(notification_settings::NotificationSettingsState::new()); #[cfg(feature = "cef")] let builder = builder.manage(std::sync::Arc::new(imessage_scanner::ScannerRegistry::new())); + #[cfg(feature = "cef")] + let builder = builder.manage(std::sync::Arc::new( + gmessages_scanner::ScannerRegistry::new(), + )); let builder = builder.manage(whatsapp_scanner::ScannerRegistry::new()); #[cfg(feature = "cef")] let builder = builder.manage(std::sync::Arc::new(slack_scanner::ScannerRegistry::new()));