mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(webview_apis): WebSocket bridge for live-webview APIs, Gmail first (#869)
This commit is contained in:
Generated
+1
@@ -8,6 +8,7 @@ version = "0.52.28"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"cef",
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
@@ -48,11 +48,20 @@ tauri-plugin-notification = { path = "vendor/tauri-plugin-notification" }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Used by gmail/cdp_fetch for decoding binary IO.read chunks. Base64 is
|
||||
# only emitted by CDP IO.read when the stream contains non-UTF-8 bytes,
|
||||
# but we opt into the feature to stay robust against unexpected responses.
|
||||
base64 = "0.22"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "process", "sync", "time", "net"] }
|
||||
# WebSocket client for the Chrome DevTools Protocol (used to talk to the
|
||||
# embedded CEF instance over `--remote-debugging-port=9222` and read
|
||||
# IndexedDB / drive `Runtime.evaluate` for the WhatsApp recipe).
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect"] }
|
||||
# WebSocket client + server for two uses:
|
||||
# - Client: Chrome DevTools Protocol connections to the embedded CEF
|
||||
# instance over `--remote-debugging-port=9222` (IndexedDB reads,
|
||||
# `Runtime.evaluate` for the WhatsApp recipe, DOMSnapshot / Network
|
||||
# calls for the Gmail connector).
|
||||
# - Server: the `webview_apis` bridge at 127.0.0.1 that accepts
|
||||
# JSON-RPC frames from the core sidecar so core-side handlers can
|
||||
# reach the live-webview connectors via CDP.
|
||||
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake"] }
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
@@ -16,7 +16,7 @@ pub mod target;
|
||||
|
||||
pub use conn::CdpConn;
|
||||
pub use emulation::{set_user_agent_override, UaSpec};
|
||||
pub use session::{placeholder_data_url, spawn_session, target_url_fragment};
|
||||
pub use session::{placeholder_data_url, placeholder_marker, spawn_session, target_url_fragment};
|
||||
pub use snapshot::Snapshot;
|
||||
pub use target::{
|
||||
browser_ws_url, connect_and_attach_matching, detach_session, find_page_target_where,
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Gmail Atom feed parser.
|
||||
//!
|
||||
//! Gmail exposes a stable Atom feed at
|
||||
//! `https://mail.google.com/mail/u/0/feed/atom[/<label>]` that returns
|
||||
//! the 20 most recent **unread** messages as XML. Example entry:
|
||||
//!
|
||||
//! ```xml
|
||||
//! <entry>
|
||||
//! <title>Re: Project sync</title>
|
||||
//! <summary>Quick status update before Friday…</summary>
|
||||
//! <link href="https://mail.google.com/mail/u/0/?…&message_id=181a3b4c…"/>
|
||||
//! <modified>2026-04-23T18:12:00Z</modified>
|
||||
//! <issued>2026-04-23T18:12:00Z</issued>
|
||||
//! <id>tag:gmail.google.com,2004:181a3b4c…</id>
|
||||
//! <author><name>Alice</name><email>alice@example.com</email></author>
|
||||
//! </entry>
|
||||
//! ```
|
||||
//!
|
||||
//! This parser is intentionally small and tolerant: tag attributes
|
||||
//! beyond what we read are ignored, unknown entries are skipped rather
|
||||
//! than failing the whole parse, and the `<summary>` / `<title>` text
|
||||
//! is XML-entity-decoded.
|
||||
|
||||
use crate::gmail::types::GmailMessage;
|
||||
|
||||
/// Parse the Atom body into `GmailMessage`s. Returns the unread entries
|
||||
/// in document order. Never panics on malformed input — returns an
|
||||
/// empty Vec if nothing matches.
|
||||
pub fn parse(body: &str) -> Vec<GmailMessage> {
|
||||
let mut out = Vec::new();
|
||||
for entry_xml in iter_tag(body, "entry") {
|
||||
let title = find_tag(entry_xml, "title").as_deref().map(decode_entities);
|
||||
let summary = find_tag(entry_xml, "summary")
|
||||
.as_deref()
|
||||
.map(decode_entities);
|
||||
let modified = find_tag(entry_xml, "modified");
|
||||
let issued = find_tag(entry_xml, "issued");
|
||||
let id_raw = find_tag(entry_xml, "id").unwrap_or_default();
|
||||
let id = strip_id_prefix(&id_raw);
|
||||
let link_href = find_link_href(entry_xml);
|
||||
let author_xml = find_tag(entry_xml, "author").unwrap_or_default();
|
||||
let from_name = find_tag(&author_xml, "name")
|
||||
.as_deref()
|
||||
.map(decode_entities);
|
||||
let from_email = find_tag(&author_xml, "email")
|
||||
.as_deref()
|
||||
.map(decode_entities);
|
||||
let from: Option<String> = match (from_name.as_deref(), from_email.as_deref()) {
|
||||
(Some(n), Some(e)) => Some(format!("{n} <{e}>")),
|
||||
(None, Some(e)) => Some(e.to_string()),
|
||||
(Some(n), None) => Some(n.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
if id.is_empty() && link_href.is_empty() && title.is_none() {
|
||||
// Completely empty entry — skip rather than surface garbage.
|
||||
continue;
|
||||
}
|
||||
let date_ms = modified
|
||||
.as_deref()
|
||||
.or(issued.as_deref())
|
||||
.and_then(parse_iso8601_ms);
|
||||
// All atom entries are unread by construction — the feed only
|
||||
// returns unread messages.
|
||||
out.push(GmailMessage {
|
||||
id,
|
||||
thread_id: None,
|
||||
from,
|
||||
to: Vec::new(),
|
||||
cc: Vec::new(),
|
||||
subject: title,
|
||||
snippet: summary,
|
||||
body: None,
|
||||
date_ms,
|
||||
labels: vec!["INBOX".into()],
|
||||
unread: true,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Iterator over the inner text of every `<TAG>…</TAG>` slice in `body`.
|
||||
/// Non-recursive and best-effort — repeated/nested tags with the same
|
||||
/// name would each yield their own slice.
|
||||
fn iter_tag<'a>(body: &'a str, tag: &'a str) -> impl Iterator<Item = &'a str> + 'a {
|
||||
let open = format!("<{tag}");
|
||||
let close = format!("</{tag}>");
|
||||
let mut cursor = 0usize;
|
||||
std::iter::from_fn(move || {
|
||||
while cursor < body.len() {
|
||||
let slice = &body[cursor..];
|
||||
let start = slice.find(&open)?;
|
||||
let after_open = cursor + start + open.len();
|
||||
// Skip the rest of the opening tag up to '>' (attributes etc).
|
||||
let rest = body.get(after_open..)?;
|
||||
let gt_rel = rest.find('>')?;
|
||||
let content_start = after_open + gt_rel + 1;
|
||||
let tail = body.get(content_start..)?;
|
||||
let close_rel = tail.find(&close)?;
|
||||
let content_end = content_start + close_rel;
|
||||
cursor = content_end + close.len();
|
||||
return Some(&body[content_start..content_end]);
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Find the first `<TAG>…</TAG>` and return its inner text. Returns
|
||||
/// `None` if the tag is absent.
|
||||
fn find_tag(body: &str, tag: &str) -> Option<String> {
|
||||
iter_tag(body, tag).next().map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
/// `<link href="…"/>` — self-closing, attribute-carried. Atom links
|
||||
/// don't have inner text, so we special-case the parse.
|
||||
fn find_link_href(body: &str) -> String {
|
||||
// Simple attribute scan — good enough for the fixed shape Gmail
|
||||
// emits. We don't need generic HTML attribute parsing.
|
||||
let needle = "<link";
|
||||
if let Some(start) = body.find(needle) {
|
||||
let rest = &body[start + needle.len()..];
|
||||
if let Some(end) = rest.find('>') {
|
||||
let attrs = &rest[..end];
|
||||
if let Some(h) = find_attr(attrs, "href") {
|
||||
return h;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn find_attr(attrs: &str, name: &str) -> Option<String> {
|
||||
let key = format!("{name}=\"");
|
||||
let pos = attrs.find(&key)?;
|
||||
let after = &attrs[pos + key.len()..];
|
||||
let end = after.find('"')?;
|
||||
Some(decode_entities(&after[..end]))
|
||||
}
|
||||
|
||||
/// Strip the `tag:gmail.google.com,2004:` prefix Gmail uses, leaving
|
||||
/// just the numeric message id. Falls back to the raw string when the
|
||||
/// prefix is missing so we never drop identifiers silently.
|
||||
fn strip_id_prefix(raw: &str) -> String {
|
||||
const PREFIX: &str = "tag:gmail.google.com,2004:";
|
||||
raw.trim()
|
||||
.strip_prefix(PREFIX)
|
||||
.unwrap_or_else(|| raw.trim())
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Small XML entity decoder — covers the five predefined named
|
||||
/// entities plus `&#NN;` / `&#xHH;` numeric escapes. Walks chars
|
||||
/// (not bytes) so multi-byte UTF-8 passes through untouched.
|
||||
fn decode_entities(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(amp) = rest.find('&') {
|
||||
out.push_str(&rest[..amp]);
|
||||
let after = &rest[amp..];
|
||||
if let Some(semi_rel) = after.find(';') {
|
||||
let tok = &after[1..semi_rel];
|
||||
let replaced = match tok {
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
t if t.starts_with("#x") || t.starts_with("#X") => u32::from_str_radix(&t[2..], 16)
|
||||
.ok()
|
||||
.and_then(char::from_u32),
|
||||
t if t.starts_with('#') => t[1..].parse::<u32>().ok().and_then(char::from_u32),
|
||||
_ => None,
|
||||
};
|
||||
match replaced {
|
||||
Some(ch) => out.push(ch),
|
||||
None => out.push_str(&after[..=semi_rel]),
|
||||
}
|
||||
rest = &after[semi_rel + 1..];
|
||||
} else {
|
||||
// Unterminated `&` — keep the rest verbatim.
|
||||
out.push_str(after);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Minimal ISO 8601 → unix millis parser for `2026-04-23T18:12:00Z`
|
||||
/// shapes Gmail emits. Accepts both `Z` and explicit `+00:00` suffix.
|
||||
/// Returns `None` on anything exotic — callers fall back to `None`
|
||||
/// date rather than an invented value.
|
||||
fn parse_iso8601_ms(s: &str) -> Option<i64> {
|
||||
// Very narrow: YYYY-MM-DDTHH:MM:SSZ (optionally with fractional
|
||||
// seconds). No TZ offset parsing beyond 'Z'.
|
||||
let s = s.trim();
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() < 20 {
|
||||
return None;
|
||||
}
|
||||
let y: i64 = s.get(0..4)?.parse().ok()?;
|
||||
let mo: u32 = s.get(5..7)?.parse().ok()?;
|
||||
let d: u32 = s.get(8..10)?.parse().ok()?;
|
||||
let h: u32 = s.get(11..13)?.parse().ok()?;
|
||||
let mi: u32 = s.get(14..16)?.parse().ok()?;
|
||||
let se: u32 = s.get(17..19)?.parse().ok()?;
|
||||
// Days since unix epoch using a (reasonably) small leap-year aware
|
||||
// calc. We don't pull `chrono` just for this conversion because
|
||||
// the atom feed ISO-8601 is always UTC.
|
||||
let days = days_from_civil(y, mo, d);
|
||||
let secs = days * 86_400 + h as i64 * 3600 + mi as i64 * 60 + se as i64;
|
||||
Some(secs.saturating_mul(1000))
|
||||
}
|
||||
|
||||
/// Howard Hinnant's civil-from-days, adapted. Correct for all dates
|
||||
/// Gmail emits (and anything up to year 10000).
|
||||
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = (y - era * 400) as i64; // [0, 399]
|
||||
let m = m as i64;
|
||||
let d = d as i64;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://purl.org/atom/ns#">
|
||||
<title>Gmail - Inbox for user@example.com</title>
|
||||
<tagline>New messages in your Gmail inbox</tagline>
|
||||
<fullcount>2</fullcount>
|
||||
<link rel="alternate" href="https://mail.google.com/mail" type="text/html"/>
|
||||
<modified>2026-04-23T18:30:00Z</modified>
|
||||
<entry>
|
||||
<title>Re: Project & plan</title>
|
||||
<summary>Quick status update <EOM></summary>
|
||||
<link rel="alternate" href="https://mail.google.com/mail/u/0/?t=1" type="text/html"/>
|
||||
<modified>2026-04-23T18:12:00Z</modified>
|
||||
<issued>2026-04-23T18:12:00Z</issued>
|
||||
<id>tag:gmail.google.com,2004:181a3b4c001</id>
|
||||
<author><name>Alice</name><email>alice@example.com</email></author>
|
||||
</entry>
|
||||
<entry>
|
||||
<title>Receipt from Acme</title>
|
||||
<summary>Order #42 confirmed</summary>
|
||||
<link rel="alternate" href="https://mail.google.com/mail/u/0/?t=2" type="text/html"/>
|
||||
<modified>2026-04-23T12:00:00Z</modified>
|
||||
<issued>2026-04-23T12:00:00Z</issued>
|
||||
<id>tag:gmail.google.com,2004:181a3b4c002</id>
|
||||
<author><name>Acme</name><email>no-reply@acme.example</email></author>
|
||||
</entry>
|
||||
</feed>"#;
|
||||
|
||||
#[test]
|
||||
fn parses_two_entries_with_subject_from_snippet() {
|
||||
let msgs = parse(SAMPLE);
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(msgs[0].id, "181a3b4c001");
|
||||
assert_eq!(msgs[0].subject.as_deref(), Some("Re: Project & plan"));
|
||||
assert_eq!(
|
||||
msgs[0].snippet.as_deref(),
|
||||
Some("Quick status update <EOM>")
|
||||
);
|
||||
assert_eq!(msgs[0].from.as_deref(), Some("Alice <alice@example.com>"));
|
||||
assert!(msgs[0].unread);
|
||||
assert_eq!(msgs[0].labels, vec!["INBOX".to_string()]);
|
||||
assert!(msgs[0].date_ms.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_xml_entities_in_subject_and_body() {
|
||||
let msgs = parse(SAMPLE);
|
||||
assert!(msgs[0].subject.as_deref().unwrap().contains(" & "));
|
||||
assert!(msgs[0].snippet.as_deref().unwrap().contains("<"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_8601_to_ms_round_trips_known_epoch() {
|
||||
// 2026-04-23T18:12:00Z
|
||||
let ms = parse_iso8601_ms("2026-04-23T18:12:00Z").unwrap();
|
||||
// Sanity: positive and within a plausible 2026 range.
|
||||
assert!(ms > 1_775_000_000_000);
|
||||
assert!(ms < 1_800_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body_returns_empty_vec() {
|
||||
assert!(parse("").is_empty());
|
||||
assert!(parse("<feed></feed>").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_entities_handles_numeric_and_utf8() {
|
||||
assert_eq!(decode_entities("it's"), "it's");
|
||||
assert_eq!(decode_entities("• bullet"), "• bullet");
|
||||
// Multi-byte UTF-8 must pass through unchanged — this was the
|
||||
// bug that mangled zero-width-joiner bytes in the first cut.
|
||||
assert_eq!(decode_entities("café"), "café");
|
||||
assert_eq!(decode_entities("a&b <x>"), "a&b <x>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fields_in_entry_still_yields_message() {
|
||||
let body = r#"<feed><entry><id>tag:gmail.google.com,2004:abc</id></entry></feed>"#;
|
||||
let msgs = parse(body);
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].id, "abc");
|
||||
assert!(msgs[0].subject.is_none());
|
||||
assert!(msgs[0].from.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! CDP-driven authenticated HTTP fetch from within an attached session.
|
||||
//!
|
||||
//! Uses `Network.loadNetworkResource` + `IO.read` + `IO.close` to issue a
|
||||
//! request that rides the session's cookie jar, without ever executing
|
||||
//! page JavaScript. That lets us hit cookie-gated Gmail endpoints
|
||||
//! (atom feed, print view) from Rust while still passing as the logged-in
|
||||
//! user.
|
||||
//!
|
||||
//! Flow:
|
||||
//!
|
||||
//! 1. `Page.getFrameTree` → extract the main frameId.
|
||||
//! 2. `Network.loadNetworkResource({ frameId, url, options: { includeCredentials: true } })`
|
||||
//! → returns `{ success, httpStatusCode, stream, headers }`.
|
||||
//! 3. Loop `IO.read({ handle, size })` until `eof: true`, concatenating
|
||||
//! the (possibly base64-encoded) chunks.
|
||||
//! 4. `IO.close({ handle })`.
|
||||
//!
|
||||
//! Returns the full response body as a `String`. Non-UTF-8 responses
|
||||
//! are rejected rather than silently lossy-decoded — Gmail's feeds are
|
||||
//! all UTF-8.
|
||||
|
||||
use base64::Engine;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::cdp::CdpConn;
|
||||
|
||||
/// Upper bound on per-`IO.read` chunk size. Gmail atom feeds top out at
|
||||
/// a few hundred KB; a 256 KB read keeps the protocol round-trips low
|
||||
/// without asking the browser to buffer the universe.
|
||||
const READ_CHUNK: usize = 256 * 1024;
|
||||
|
||||
/// Issue an authenticated GET through the attached CDP session.
|
||||
pub async fn fetch(cdp: &mut CdpConn, session: &str, url: &str) -> Result<String, String> {
|
||||
// `Network.loadNetworkResource` needs a frameId to charge the
|
||||
// request against — we use the main frame of the target.
|
||||
let frame_id = main_frame_id(cdp, session).await?;
|
||||
log::debug!("[gmail-cdp-fetch] session={session} frame={frame_id} url={url}");
|
||||
|
||||
// Network must be enabled for loadNetworkResource to work on some
|
||||
// CDP builds. Enabling is idempotent — safe to call every fetch.
|
||||
let _ = cdp
|
||||
.call("Network.enable", json!({}), Some(session))
|
||||
.await
|
||||
.map_err(|e| format!("Network.enable: {e}"))?;
|
||||
|
||||
let res = cdp
|
||||
.call(
|
||||
"Network.loadNetworkResource",
|
||||
json!({
|
||||
"frameId": frame_id,
|
||||
"url": url,
|
||||
"options": {
|
||||
"disableCache": false,
|
||||
"includeCredentials": true,
|
||||
},
|
||||
}),
|
||||
Some(session),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Network.loadNetworkResource {url}: {e}"))?;
|
||||
|
||||
let resource = res
|
||||
.get("resource")
|
||||
.ok_or_else(|| "loadNetworkResource: no `resource` in reply".to_string())?;
|
||||
let success = resource
|
||||
.get("success")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if !success {
|
||||
let status = resource
|
||||
.get("httpStatusCode")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let net_error = resource
|
||||
.get("netErrorName")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
return Err(format!(
|
||||
"loadNetworkResource reported failure: status={status} netError={net_error}"
|
||||
));
|
||||
}
|
||||
let stream_handle = resource
|
||||
.get("stream")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "loadNetworkResource: no `stream` handle in reply".to_string())?
|
||||
.to_string();
|
||||
|
||||
let body = read_stream(cdp, session, &stream_handle).await;
|
||||
|
||||
// Always close the stream, even if read failed. Otherwise the
|
||||
// browser keeps the response buffered.
|
||||
let _ = cdp
|
||||
.call(
|
||||
"IO.close",
|
||||
json!({ "handle": &stream_handle }),
|
||||
Some(session),
|
||||
)
|
||||
.await;
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
async fn main_frame_id(cdp: &mut CdpConn, session: &str) -> Result<String, String> {
|
||||
let tree = cdp
|
||||
.call("Page.getFrameTree", json!({}), Some(session))
|
||||
.await
|
||||
.map_err(|e| format!("Page.getFrameTree: {e}"))?;
|
||||
tree.pointer("/frameTree/frame/id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| "Page.getFrameTree: no frameTree.frame.id".to_string())
|
||||
}
|
||||
|
||||
async fn read_stream(cdp: &mut CdpConn, session: &str, handle: &str) -> Result<String, String> {
|
||||
let mut out = String::new();
|
||||
loop {
|
||||
let chunk = cdp
|
||||
.call(
|
||||
"IO.read",
|
||||
json!({ "handle": handle, "size": READ_CHUNK }),
|
||||
Some(session),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("IO.read: {e}"))?;
|
||||
let data = chunk.get("data").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let is_base64 = chunk
|
||||
.get("base64Encoded")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if is_base64 {
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(data)
|
||||
.map_err(|e| format!("IO.read base64 decode: {e}"))?;
|
||||
let text =
|
||||
String::from_utf8(bytes).map_err(|e| format!("IO.read body not utf-8: {e}"))?;
|
||||
out.push_str(&text);
|
||||
} else {
|
||||
// CDP already returns `data` as a JSON string — tokio-tungstenite
|
||||
// parsed it into a `String` via serde_json, so the JSON escapes
|
||||
// (\uXXXX) are already resolved. We can push verbatim; no
|
||||
// byte-by-byte re-encoding.
|
||||
out.push_str(data);
|
||||
}
|
||||
let eof = chunk.get("eof").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
if eof {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Gmail API layer — CDP-driven, zero JS injection.
|
||||
//!
|
||||
//! This is the first "data-connect-style" connector for OpenHuman: a
|
||||
//! typed API surface that reads (and eventually writes) Gmail state out
|
||||
//! of the logged-in webview. Consumers are:
|
||||
//!
|
||||
//! * **Frontend** via `invoke('gmail_<fn>', …)` — the
|
||||
//! `#[tauri::command]` wrappers in this module.
|
||||
//! * **Core sidecar** via the webview_apis WebSocket bridge — the
|
||||
//! router in `crate::webview_apis::router` calls the `cdp_*` helpers
|
||||
//! below. Core-side JSON-RPC handlers in
|
||||
//! `src/openhuman/webview_apis/` proxy through that bridge so curl
|
||||
//! against `openhuman.gmail_*` reaches the live webview session.
|
||||
//!
|
||||
//! ## Standardized connector shape
|
||||
//!
|
||||
//! * Every op has one typed `cdp_<fn>` helper (the public surface both
|
||||
//! callers share), plus one thin `#[tauri::command] gmail_<fn>`
|
||||
//! wrapper for the frontend path.
|
||||
//! * All ops take `account_id` to disambiguate multi-account webviews.
|
||||
//! The account must already be open via `webview_account_open` — the
|
||||
//! CDP session is discovered by the `#openhuman-account-<id>`
|
||||
//! fragment that `cdp::session` appends to the real URL.
|
||||
//! * Reads use `DOMSnapshot.captureSnapshot` and/or `Network.*` events.
|
||||
//! * Writes use `Input.dispatchKeyEvent` / `Input.dispatchMouseEvent`.
|
||||
//! * Nothing here injects JavaScript into the page.
|
||||
//!
|
||||
//! ## Current op coverage
|
||||
//!
|
||||
//! | Op | Status |
|
||||
//! | ------------ | ------------- |
|
||||
//! | `list_labels` | **working** |
|
||||
//! | `list_messages` | stub |
|
||||
//! | `search` | stub |
|
||||
//! | `get_message` | stub |
|
||||
//! | `send` | stub |
|
||||
//! | `trash` | stub |
|
||||
//! | `add_label` | stub |
|
||||
//!
|
||||
//! ## CEF-only
|
||||
//!
|
||||
//! CDP requires a remote-debugging port, which wry doesn't expose.
|
||||
//! Without `--features cef` the helpers return a structured error so
|
||||
//! callers see a clear message instead of a missing symbol.
|
||||
|
||||
pub mod types;
|
||||
|
||||
#[cfg(feature = "cef")]
|
||||
mod atom;
|
||||
#[cfg(feature = "cef")]
|
||||
mod cdp_fetch;
|
||||
#[cfg(feature = "cef")]
|
||||
mod print_view;
|
||||
#[cfg(feature = "cef")]
|
||||
mod reads;
|
||||
#[cfg(feature = "cef")]
|
||||
mod session;
|
||||
#[cfg(feature = "cef")]
|
||||
mod writes;
|
||||
|
||||
use types::{Ack, GmailLabel, GmailMessage, GmailSendRequest, SendAck};
|
||||
|
||||
#[cfg(not(feature = "cef"))]
|
||||
const NO_CEF: &str =
|
||||
"gmail API is unavailable without the cef feature (CDP requires remote debugging)";
|
||||
|
||||
// ── Shared helpers (called by both Tauri IPC and the webview_apis bridge) ──
|
||||
|
||||
pub async fn cdp_list_labels(account_id: &str) -> Result<Vec<GmailLabel>, String> {
|
||||
#[cfg(feature = "cef")]
|
||||
{
|
||||
reads::list_labels(account_id).await
|
||||
}
|
||||
#[cfg(not(feature = "cef"))]
|
||||
{
|
||||
let _ = account_id;
|
||||
Err(NO_CEF.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cdp_list_messages(
|
||||
account_id: &str,
|
||||
limit: u32,
|
||||
label: Option<String>,
|
||||
) -> Result<Vec<GmailMessage>, String> {
|
||||
#[cfg(feature = "cef")]
|
||||
{
|
||||
reads::list_messages(account_id, limit, label).await
|
||||
}
|
||||
#[cfg(not(feature = "cef"))]
|
||||
{
|
||||
let _ = (account_id, limit, label);
|
||||
Err(NO_CEF.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cdp_search(
|
||||
account_id: &str,
|
||||
query: String,
|
||||
limit: u32,
|
||||
) -> Result<Vec<GmailMessage>, String> {
|
||||
#[cfg(feature = "cef")]
|
||||
{
|
||||
reads::search(account_id, query, limit).await
|
||||
}
|
||||
#[cfg(not(feature = "cef"))]
|
||||
{
|
||||
let _ = (account_id, query, limit);
|
||||
Err(NO_CEF.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cdp_get_message(account_id: &str, message_id: String) -> Result<GmailMessage, String> {
|
||||
#[cfg(feature = "cef")]
|
||||
{
|
||||
reads::get_message(account_id, message_id).await
|
||||
}
|
||||
#[cfg(not(feature = "cef"))]
|
||||
{
|
||||
let _ = (account_id, message_id);
|
||||
Err(NO_CEF.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cdp_send(account_id: &str, request: GmailSendRequest) -> Result<SendAck, String> {
|
||||
#[cfg(feature = "cef")]
|
||||
{
|
||||
writes::send(account_id, request).await
|
||||
}
|
||||
#[cfg(not(feature = "cef"))]
|
||||
{
|
||||
let _ = (account_id, request);
|
||||
Err(NO_CEF.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cdp_trash(account_id: &str, message_id: String) -> Result<Ack, String> {
|
||||
#[cfg(feature = "cef")]
|
||||
{
|
||||
writes::trash(account_id, message_id).await
|
||||
}
|
||||
#[cfg(not(feature = "cef"))]
|
||||
{
|
||||
let _ = (account_id, message_id);
|
||||
Err(NO_CEF.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cdp_add_label(
|
||||
account_id: &str,
|
||||
message_id: String,
|
||||
label: String,
|
||||
) -> Result<Ack, String> {
|
||||
#[cfg(feature = "cef")]
|
||||
{
|
||||
writes::add_label(account_id, message_id, label).await
|
||||
}
|
||||
#[cfg(not(feature = "cef"))]
|
||||
{
|
||||
let _ = (account_id, message_id, label);
|
||||
Err(NO_CEF.into())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tauri commands (frontend path) ──────────────────────────────────────
|
||||
|
||||
// Entry-point logging at the Tauri command layer distinguishes
|
||||
// frontend `invoke` paths from the webview_apis bridge path — both
|
||||
// ultimately call the same `cdp_*` helpers in `reads.rs` / `writes.rs`,
|
||||
// but the upstream origin matters when tracing a failing flow.
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gmail_list_labels(account_id: String) -> Result<Vec<GmailLabel>, String> {
|
||||
log::debug!("[gmail][tauri] gmail_list_labels account_id={account_id}");
|
||||
cdp_list_labels(&account_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gmail_list_messages(
|
||||
account_id: String,
|
||||
limit: u32,
|
||||
label: Option<String>,
|
||||
) -> Result<Vec<GmailMessage>, String> {
|
||||
log::debug!(
|
||||
"[gmail][tauri] gmail_list_messages account_id={account_id} limit={limit} label={label:?}"
|
||||
);
|
||||
cdp_list_messages(&account_id, limit, label).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gmail_search(
|
||||
account_id: String,
|
||||
query: String,
|
||||
limit: u32,
|
||||
) -> Result<Vec<GmailMessage>, String> {
|
||||
log::debug!(
|
||||
"[gmail][tauri] gmail_search account_id={account_id} query_len={} limit={limit}",
|
||||
query.len()
|
||||
);
|
||||
cdp_search(&account_id, query, limit).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gmail_get_message(
|
||||
account_id: String,
|
||||
message_id: String,
|
||||
) -> Result<GmailMessage, String> {
|
||||
log::debug!("[gmail][tauri] gmail_get_message account_id={account_id} message_id={message_id}");
|
||||
cdp_get_message(&account_id, message_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gmail_send(account_id: String, request: GmailSendRequest) -> Result<SendAck, String> {
|
||||
log::debug!(
|
||||
"[gmail][tauri] gmail_send account_id={account_id} to={} cc={} bcc={} body_len={}",
|
||||
request.to.len(),
|
||||
request.cc.len(),
|
||||
request.bcc.len(),
|
||||
request.body.len()
|
||||
);
|
||||
cdp_send(&account_id, request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gmail_trash(account_id: String, message_id: String) -> Result<Ack, String> {
|
||||
log::debug!("[gmail][tauri] gmail_trash account_id={account_id} message_id={message_id}");
|
||||
cdp_trash(&account_id, message_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn gmail_add_label(
|
||||
account_id: String,
|
||||
message_id: String,
|
||||
label: String,
|
||||
) -> Result<Ack, String> {
|
||||
log::debug!(
|
||||
"[gmail][tauri] gmail_add_label account_id={account_id} message_id={message_id} label={label}"
|
||||
);
|
||||
cdp_add_label(&account_id, message_id, label).await
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
//! Gmail print-view HTML parser.
|
||||
//!
|
||||
//! The print view at `mail.google.com/mail/u/0/?ui=2&view=pt&th=<id>`
|
||||
//! returns a simple, mostly-table-based HTML page with stable markers:
|
||||
//!
|
||||
//! ```html
|
||||
//! <title>Gmail - Subject line</title>
|
||||
//! …
|
||||
//! <div id=":…" class="ii gt"> (body container)
|
||||
//! ```
|
||||
//!
|
||||
//! The specific structure has varied over the years but these anchors
|
||||
//! are extremely sticky. We parse defensively — missing fields become
|
||||
//! `None` rather than fail the whole op.
|
||||
|
||||
use crate::gmail::types::GmailMessage;
|
||||
|
||||
/// Parse one rendered print-view HTML page into a `GmailMessage`.
|
||||
/// Returns `None` only when the response didn't look like a Gmail
|
||||
/// print view at all (e.g. login redirect HTML).
|
||||
///
|
||||
/// Implementation note: Gmail's real print view is a flat sequence of
|
||||
/// blocks — subject (via `<title>`), sender + date, a `To: …` line,
|
||||
/// then the body — without the `<th>`/`<td>` table shape older scrapers
|
||||
/// relied on. We convert the page to plaintext (strip tags, collapse
|
||||
/// whitespace, insert line breaks at block-level boundaries) and then
|
||||
/// pick fields out of the resulting line stream. This is more stable
|
||||
/// across Gmail UI churn than anchoring on specific tag shapes.
|
||||
pub fn parse(message_id: &str, html: &str) -> Option<GmailMessage> {
|
||||
if !looks_like_print_view(html) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let subject = extract_subject(html);
|
||||
let lines = html_to_text_lines(html);
|
||||
|
||||
// Walk the flattened lines looking for sender/date/to/body anchors.
|
||||
// The typical Gmail print-view shape (post tag-strip) is:
|
||||
// {Subject line — same as title}
|
||||
// {Account-owner email — "Steven Enamakel <me@…>"}
|
||||
// {Actual sender — "Trip.com <…>"}
|
||||
// {Date: "Thu, Apr 23, 2026 at 9:01 PM"}
|
||||
// {To: me@…}
|
||||
// {blank}
|
||||
// {body …}
|
||||
//
|
||||
// We can't tell account-owner from sender by their line shape alone,
|
||||
// so collect every email-like candidate in order and pick the LAST
|
||||
// one before the `To:` line as the sender. That matches both the
|
||||
// 2-header and 1-header layouts Gmail has shipped.
|
||||
let mut date_ms: Option<i64> = None;
|
||||
let mut to: Vec<String> = Vec::new();
|
||||
let mut cc: Vec<String> = Vec::new();
|
||||
let mut from_candidates: Vec<String> = Vec::new();
|
||||
let mut to_line_idx: Option<usize> = None;
|
||||
let mut body_start_idx: Option<usize> = None;
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Skip the subject echo that Gmail repeats below the <title>.
|
||||
if subject.as_deref() == Some(trimmed) {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = ci_strip_prefix(trimmed, "to:") {
|
||||
to = split_addresses(Some(rest.trim()));
|
||||
to_line_idx = Some(i);
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = ci_strip_prefix(trimmed, "cc:") {
|
||||
cc = split_addresses(Some(rest.trim()));
|
||||
continue;
|
||||
}
|
||||
// Collect ALL email-like lines pre-To. We'll pick the last one
|
||||
// as the sender below.
|
||||
if to_line_idx.is_none() && looks_like_sender(trimmed) {
|
||||
from_candidates.push(trimmed.to_string());
|
||||
continue;
|
||||
}
|
||||
// Date can appear before or after "To:" — accept anywhere.
|
||||
if date_ms.is_none() {
|
||||
if let Some(ms) = try_parse_gmail_date(trimmed) {
|
||||
date_ms = Some(ms);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Once we've seen To:, the next non-meta line opens the body.
|
||||
if body_start_idx.is_none() && to_line_idx.is_some() && !looks_like_meta(trimmed) {
|
||||
body_start_idx = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the last email-like line before "To:" as the sender — that's
|
||||
// the actual sender in both Gmail print-view layouts. If there were
|
||||
// zero candidates, fall through with `from = None`.
|
||||
let from: Option<String> = from_candidates.into_iter().last();
|
||||
|
||||
let body: Option<String> = body_start_idx.map(|start| {
|
||||
let joined = lines[start..]
|
||||
.iter()
|
||||
.map(|l| l.trim())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
joined.trim().to_string()
|
||||
});
|
||||
let snippet = body.as_deref().map(derive_snippet);
|
||||
|
||||
Some(GmailMessage {
|
||||
id: message_id.to_string(),
|
||||
thread_id: Some(message_id.to_string()),
|
||||
from,
|
||||
to,
|
||||
cc,
|
||||
subject,
|
||||
snippet,
|
||||
body,
|
||||
date_ms,
|
||||
labels: Vec::new(),
|
||||
unread: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Heuristics. The sender line is typically `Name <email@dom.com>` or
|
||||
/// a bare `email@dom.com`. We require at least one `@` to avoid
|
||||
/// matching random noise.
|
||||
fn looks_like_sender(s: &str) -> bool {
|
||||
s.contains('@') && s.len() < 256
|
||||
}
|
||||
|
||||
/// Lines like `To:`, `From:`, `Cc:`, `Subject:` that aren't body text.
|
||||
fn looks_like_meta(s: &str) -> bool {
|
||||
let lower = s.to_ascii_lowercase();
|
||||
["to:", "cc:", "bcc:", "from:", "subject:", "date:"]
|
||||
.iter()
|
||||
.any(|p| lower.starts_with(p))
|
||||
}
|
||||
|
||||
fn ci_strip_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
let lower_prefix = prefix.to_ascii_lowercase();
|
||||
if s.len() < prefix.len() {
|
||||
return None;
|
||||
}
|
||||
let head = s.get(..prefix.len())?;
|
||||
if head.to_ascii_lowercase() == lower_prefix {
|
||||
Some(&s[prefix.len()..])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert HTML to a stream of plaintext lines.
|
||||
/// * Drops everything inside `<script>` / `<style>` tags.
|
||||
/// * Inserts `\n` at `<br>`, `<p>`, `<div>`, `<tr>`, `<li>`, `<h*>`
|
||||
/// tag boundaries.
|
||||
/// * Decodes HTML entities.
|
||||
fn html_to_text_lines(html: &str) -> Vec<String> {
|
||||
let cleaned = strip_script_style(html);
|
||||
let with_breaks = insert_block_breaks(&cleaned);
|
||||
let stripped = strip_tags(&with_breaks);
|
||||
let decoded = decode_entities(&stripped);
|
||||
decoded.lines().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn strip_script_style(html: &str) -> String {
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut rest = html;
|
||||
loop {
|
||||
let lower = rest.to_ascii_lowercase();
|
||||
let s_pos = lower.find("<script").map(|p| (p, "</script>"));
|
||||
let y_pos = lower.find("<style").map(|p| (p, "</style>"));
|
||||
let next = [s_pos, y_pos]
|
||||
.iter()
|
||||
.filter_map(|x| x.clone())
|
||||
.min_by_key(|(p, _)| *p);
|
||||
match next {
|
||||
Some((start, closer)) => {
|
||||
out.push_str(&rest[..start]);
|
||||
let lower_tail = rest[start..].to_ascii_lowercase();
|
||||
if let Some(end) = lower_tail.find(closer) {
|
||||
let skip_to = start + end + closer.len();
|
||||
rest = &rest[skip_to..];
|
||||
} else {
|
||||
// Unterminated — bail out, take the rest as-is.
|
||||
return out;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.push_str(rest);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_block_breaks(html: &str) -> String {
|
||||
// Cheap approximation: replace each known block-start / block-end
|
||||
// tag with a newline sentinel. Case-insensitive. Gmail's print
|
||||
// view lays headers out in `<td>`/`<span>`/`<b>` cells without
|
||||
// explicit `<br>` between them, so we break on the closers of
|
||||
// those too to get one field per line after the strip.
|
||||
let mut s = html.to_string();
|
||||
for tag in [
|
||||
"<br", "</p", "</div", "</tr", "</li", "</h1", "</h2", "</h3", "</h4", "</td", "</span",
|
||||
"</b>", "<p ", "<p>", "<div ", "<div>", "<tr ", "<tr>", "<li ", "<li>", "<td ", "<td>",
|
||||
] {
|
||||
let lower = s.to_ascii_lowercase();
|
||||
if !lower.contains(tag) {
|
||||
continue;
|
||||
}
|
||||
// We walk the lowercase index but rewrite in the original `s`.
|
||||
let mut out = String::with_capacity(s.len() + 16);
|
||||
let mut i = 0;
|
||||
while i < s.len() {
|
||||
let lower_slice = &lower[i..];
|
||||
if let Some(pos) = lower_slice.find(tag) {
|
||||
out.push_str(&s[i..i + pos]);
|
||||
out.push('\n');
|
||||
i += pos;
|
||||
// Find the tag close '>' and include it.
|
||||
if let Some(end) = s[i..].find('>') {
|
||||
out.push_str(&s[i..i + end + 1]);
|
||||
i += end + 1;
|
||||
} else {
|
||||
out.push_str(&s[i..]);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
out.push_str(&s[i..]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
s = out;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Try a few loose date shapes Gmail emits in the print view:
|
||||
/// * `Thu, Apr 23, 2026 at 9:01 PM`
|
||||
/// * `Thu, Apr 23, 2026, 9:01 PM`
|
||||
/// * RFC 2822 `Thu, 23 Apr 2026 18:12:00 -0700`
|
||||
fn try_parse_gmail_date(s: &str) -> Option<i64> {
|
||||
parse_rfc2822_ms(s).or_else(|| parse_human_date_ms(s))
|
||||
}
|
||||
|
||||
/// `Thu, Apr 23, 2026 at 9:01 PM` — the form the Gmail print view emits.
|
||||
fn parse_human_date_ms(s: &str) -> Option<i64> {
|
||||
let s = s.trim().trim_end_matches('.');
|
||||
// Normalize separators: drop commas and the word "at".
|
||||
let norm: String = s
|
||||
.replace(',', " ")
|
||||
.replace(" at ", " ")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let mut it = norm.split_whitespace();
|
||||
let _dow = it.next()?; // Thu
|
||||
let mon = month_from_str(it.next()?)?;
|
||||
let day: u32 = it.next()?.parse().ok()?;
|
||||
let year: i64 = it.next()?.parse().ok()?;
|
||||
let time_tok = it.next()?;
|
||||
let ampm = it.next().unwrap_or("");
|
||||
let mut th = time_tok.split(':');
|
||||
let mut hh: u32 = th.next()?.parse().ok()?;
|
||||
let mm: u32 = th.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
match ampm.to_ascii_uppercase().as_str() {
|
||||
"PM" if hh < 12 => hh += 12,
|
||||
"AM" if hh == 12 => hh = 0,
|
||||
_ => {}
|
||||
}
|
||||
let days = days_from_civil(year, mon, day);
|
||||
// Human date has no TZ — treat as local-naive (we don't know the
|
||||
// mailbox's TZ), so use UTC. Caller sees the absolute millis.
|
||||
let secs = days * 86_400 + hh as i64 * 3600 + mm as i64 * 60;
|
||||
Some(secs.saturating_mul(1000))
|
||||
}
|
||||
|
||||
fn looks_like_print_view(html: &str) -> bool {
|
||||
// Gmail print-view pages always contain these two markers:
|
||||
// - `<title>Gmail -` — the browser tab title
|
||||
// - a `From:`-labelled header row
|
||||
// Login / error pages contain neither.
|
||||
html.contains("<title>Gmail -") || html.to_ascii_lowercase().contains(">from:</")
|
||||
}
|
||||
|
||||
/// `<title>Gmail - {subject}</title>` → the subject portion.
|
||||
fn extract_subject(html: &str) -> Option<String> {
|
||||
let start = html.find("<title>")? + "<title>".len();
|
||||
let end = html[start..].find("</title>")? + start;
|
||||
let raw = decode_entities(&html[start..end]).trim().to_string();
|
||||
// Strip the leading "Gmail - " prefix if present.
|
||||
Some(
|
||||
raw.strip_prefix("Gmail - ")
|
||||
.map(str::to_string)
|
||||
.unwrap_or(raw),
|
||||
)
|
||||
}
|
||||
|
||||
fn derive_snippet(body: &str) -> String {
|
||||
// Collapse whitespace and cap at 180 chars to match Gmail UI-ish
|
||||
// snippet length.
|
||||
let collapsed: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if collapsed.len() > 180 {
|
||||
format!(
|
||||
"{}…",
|
||||
&collapsed[..collapsed
|
||||
.char_indices()
|
||||
.nth(180)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(180)]
|
||||
)
|
||||
} else {
|
||||
collapsed
|
||||
}
|
||||
}
|
||||
|
||||
fn split_addresses(raw: Option<&str>) -> Vec<String> {
|
||||
match raw {
|
||||
None => Vec::new(),
|
||||
Some(s) => s
|
||||
.split(',')
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_tags(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut in_tag = false;
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'<' => in_tag = true,
|
||||
'>' if in_tag => in_tag = false,
|
||||
_ if !in_tag => out.push(ch),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Small HTML entity decoder — named + numeric. Char-aware so
|
||||
/// multi-byte UTF-8 passes through unchanged. Same shape as the one
|
||||
/// in `atom.rs` but also handles ` `.
|
||||
fn decode_entities(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(amp) = rest.find('&') {
|
||||
out.push_str(&rest[..amp]);
|
||||
let after = &rest[amp..];
|
||||
if let Some(semi_rel) = after.find(';') {
|
||||
let tok = &after[1..semi_rel];
|
||||
let replaced = match tok {
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"nbsp" => Some(' '),
|
||||
t if t.starts_with("#x") || t.starts_with("#X") => u32::from_str_radix(&t[2..], 16)
|
||||
.ok()
|
||||
.and_then(char::from_u32),
|
||||
t if t.starts_with('#') => t[1..].parse::<u32>().ok().and_then(char::from_u32),
|
||||
_ => None,
|
||||
};
|
||||
match replaced {
|
||||
Some(ch) => out.push(ch),
|
||||
None => out.push_str(&after[..=semi_rel]),
|
||||
}
|
||||
rest = &after[semi_rel + 1..];
|
||||
} else {
|
||||
out.push_str(after);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Very narrow RFC-2822 date → unix millis. Gmail emits dates like
|
||||
/// `Thu, 23 Apr 2026 18:12:00 -0700`. We handle that shape and return
|
||||
/// `None` for anything exotic — callers surface a `None` date rather
|
||||
/// than an invented one.
|
||||
fn parse_rfc2822_ms(s: &str) -> Option<i64> {
|
||||
// Cheap heuristic parse. Good enough for the shape Gmail emits.
|
||||
let mut parts = s.split_whitespace();
|
||||
let _dow = parts.next()?; // "Thu,"
|
||||
let day: u32 = parts.next()?.parse().ok()?;
|
||||
let mon = month_from_str(parts.next()?)?;
|
||||
let year: i64 = parts.next()?.parse().ok()?;
|
||||
let hms = parts.next()?;
|
||||
let mut h_iter = hms.split(':');
|
||||
let hh: u32 = h_iter.next()?.parse().ok()?;
|
||||
let mm: u32 = h_iter.next()?.parse().ok()?;
|
||||
let ss: u32 = h_iter.next().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let tz = parts.next().unwrap_or("+0000");
|
||||
let tz_offset_s = parse_tz_offset_secs(tz).unwrap_or(0);
|
||||
let days = days_from_civil(year, mon, day);
|
||||
let secs = days * 86_400 + hh as i64 * 3600 + mm as i64 * 60 + ss as i64 - tz_offset_s;
|
||||
Some(secs.saturating_mul(1000))
|
||||
}
|
||||
|
||||
fn month_from_str(m: &str) -> Option<u32> {
|
||||
Some(match m {
|
||||
"Jan" => 1,
|
||||
"Feb" => 2,
|
||||
"Mar" => 3,
|
||||
"Apr" => 4,
|
||||
"May" => 5,
|
||||
"Jun" => 6,
|
||||
"Jul" => 7,
|
||||
"Aug" => 8,
|
||||
"Sep" => 9,
|
||||
"Oct" => 10,
|
||||
"Nov" => 11,
|
||||
"Dec" => 12,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_tz_offset_secs(tz: &str) -> Option<i64> {
|
||||
if tz == "GMT" || tz == "UTC" || tz == "Z" {
|
||||
return Some(0);
|
||||
}
|
||||
let (sign, digits) = match tz.as_bytes().first()? {
|
||||
b'+' => (1, &tz[1..]),
|
||||
b'-' => (-1, &tz[1..]),
|
||||
_ => return None,
|
||||
};
|
||||
if digits.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let h: i64 = digits.get(0..2)?.parse().ok()?;
|
||||
let m: i64 = digits.get(2..4)?.parse().ok()?;
|
||||
Some(sign * (h * 3600 + m * 60))
|
||||
}
|
||||
|
||||
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let m = m as i64;
|
||||
let d = d as i64;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Real Gmail print-view shape (anonymised). Flatter than the older
|
||||
/// `<th>`/`<td>` table form — sender-and-email inline, date on its
|
||||
/// own line in a human-readable form, `To:` labelled line, then body.
|
||||
const SAMPLE_HTML: &str = r#"<html><head><title>Gmail - Re: Project & plan</title></head>
|
||||
<body>
|
||||
<div>Gmail - Re: Project & plan</div>
|
||||
<div>"Alice" <alice@example.com></div>
|
||||
<div>Thu, Apr 23, 2026 at 6:12 PM</div>
|
||||
<div>To: me@example.com, "Bob" <bob@example.com></div>
|
||||
<div>Hello team — quick status update before Friday's demo.<br>Will send slides later.</div>
|
||||
</body></html>"#;
|
||||
|
||||
#[test]
|
||||
fn parses_subject_from_to_and_body() {
|
||||
let m = parse("181a3b4c001", SAMPLE_HTML).unwrap();
|
||||
assert_eq!(m.id, "181a3b4c001");
|
||||
assert_eq!(m.subject.as_deref(), Some("Re: Project & plan"));
|
||||
assert!(
|
||||
m.from.as_deref().unwrap().contains("alice@example.com"),
|
||||
"unexpected from: {:?}",
|
||||
m.from
|
||||
);
|
||||
assert_eq!(m.to.len(), 2);
|
||||
assert!(
|
||||
m.to.iter().any(|t| t.contains("bob@example.com")),
|
||||
"to missing bob: {:?}",
|
||||
m.to
|
||||
);
|
||||
assert!(
|
||||
m.body.as_deref().unwrap().contains("Hello team"),
|
||||
"body: {:?}",
|
||||
m.body
|
||||
);
|
||||
assert!(m.date_ms.is_some(), "date_ms: {:?}", m.date_ms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_on_non_print_view_html() {
|
||||
let html = "<html><body>Please sign in</body></html>";
|
||||
assert!(parse("x", html).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_is_short_and_whitespace_collapsed() {
|
||||
let m = parse("id", SAMPLE_HTML).unwrap();
|
||||
let snippet = m.snippet.unwrap();
|
||||
assert!(snippet.len() <= 181);
|
||||
assert!(!snippet.contains('\n'));
|
||||
}
|
||||
|
||||
/// Gmail's real print view shows an account-owner line BEFORE the
|
||||
/// actual sender. Our `from_candidates`/pick-last rule must favour
|
||||
/// the sender (second email-like line pre-`To:`), not the account.
|
||||
#[test]
|
||||
fn picks_sender_not_account_owner_from_two_header_layout() {
|
||||
let html = r#"<html><head><title>Gmail - Receipt</title></head>
|
||||
<body>
|
||||
<span>Gmail - Receipt</span>
|
||||
<span>Steven Enamakel <me@example.com></span>
|
||||
<span>Trip.com <noreply@trip.com></span>
|
||||
<span>Thu, Apr 23, 2026 at 9:01 PM</span>
|
||||
<span>To: me@example.com</span>
|
||||
<div>Your new Trip Coins balance is 69.</div>
|
||||
</body></html>"#;
|
||||
let m = parse("abc", html).unwrap();
|
||||
assert_eq!(
|
||||
m.from.as_deref(),
|
||||
Some("Trip.com <noreply@trip.com>"),
|
||||
"from: {:?}",
|
||||
m.from
|
||||
);
|
||||
assert_eq!(m.to, vec!["me@example.com".to_string()]);
|
||||
assert!(m.date_ms.is_some(), "expected human date parse");
|
||||
assert!(m.body.as_deref().unwrap().contains("Trip Coins"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc2822_date_parses_with_non_utc_offset() {
|
||||
let ms_utc = parse_rfc2822_ms("Thu, 23 Apr 2026 18:12:00 +0000").unwrap();
|
||||
let ms_pdt = parse_rfc2822_ms("Thu, 23 Apr 2026 11:12:00 -0700").unwrap();
|
||||
assert_eq!(ms_utc, ms_pdt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Read ops: list_labels, list_messages, search, get_message.
|
||||
//!
|
||||
//! Strategy per-op:
|
||||
//!
|
||||
//! * **list_labels** — DOM snapshot of the sidebar. Cheap and reliable.
|
||||
//! * **list_messages** — Gmail's stable Atom feed at
|
||||
//! `mail.google.com/mail/u/0/feed/atom[/<label>]`, fetched
|
||||
//! authenticated via the attached CDP session (Network.loadNetworkResource
|
||||
//! + IO.read — no JS eval). Covers the 20 most recent unread messages.
|
||||
//! * **search / get_message** — scaffolded with structured errors for
|
||||
//! the first cut. Search needs `Page.navigate('#search/<q>')` plus
|
||||
//! DOM/Network observation; `get_message` can use Gmail's print-view
|
||||
//! endpoint on a per-id basis (follow-up).
|
||||
//!
|
||||
//! Everything here is CEF-only — CDP requires a remote-debugging port
|
||||
//! which wry doesn't expose.
|
||||
|
||||
use super::cdp_fetch;
|
||||
use super::session;
|
||||
use super::types::{GmailLabel, GmailMessage};
|
||||
use crate::cdp::Snapshot;
|
||||
use crate::gmail::atom;
|
||||
|
||||
pub async fn list_labels(account_id: &str) -> Result<Vec<GmailLabel>, String> {
|
||||
log::debug!("[gmail][{account_id}] list_labels");
|
||||
let (mut cdp, session_id) = session::attach(account_id).await?;
|
||||
let snap = match Snapshot::capture(&mut cdp, &session_id).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
session::detach(&mut cdp, &session_id).await;
|
||||
return Err(format!("gmail[{account_id}]: snapshot failed: {e}"));
|
||||
}
|
||||
};
|
||||
let labels = scrape_labels(&snap);
|
||||
session::detach(&mut cdp, &session_id).await;
|
||||
log::debug!(
|
||||
"[gmail][{account_id}] list_labels ok count={}",
|
||||
labels.len()
|
||||
);
|
||||
Ok(labels)
|
||||
}
|
||||
|
||||
pub async fn list_messages(
|
||||
account_id: &str,
|
||||
limit: u32,
|
||||
label: Option<String>,
|
||||
) -> Result<Vec<GmailMessage>, String> {
|
||||
log::debug!(
|
||||
"[gmail][{account_id}] list_messages limit={limit} label={:?}",
|
||||
label
|
||||
);
|
||||
let url = atom_feed_url(label.as_deref());
|
||||
let (mut cdp, session_id) = session::attach(account_id).await?;
|
||||
let body = match cdp_fetch::fetch(&mut cdp, &session_id, &url).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
session::detach(&mut cdp, &session_id).await;
|
||||
return Err(format!("gmail[{account_id}]: atom-feed fetch failed: {e}"));
|
||||
}
|
||||
};
|
||||
session::detach(&mut cdp, &session_id).await;
|
||||
let mut messages = atom::parse(&body);
|
||||
log::debug!(
|
||||
"[gmail][{account_id}] list_messages parsed={} (pre-cap)",
|
||||
messages.len()
|
||||
);
|
||||
if (messages.len() as u32) > limit {
|
||||
messages.truncate(limit as usize);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
/// Build the Atom feed URL for a given label. Gmail exposes a default
|
||||
/// inbox feed at `…/feed/atom` and per-label feeds at
|
||||
/// `…/feed/atom/<label>`. Unknown labels 404 cleanly, so we don't try
|
||||
/// to validate here.
|
||||
fn atom_feed_url(label: Option<&str>) -> String {
|
||||
const BASE: &str = "https://mail.google.com/mail/u/0/feed/atom";
|
||||
match label {
|
||||
None | Some("") | Some("INBOX") | Some("inbox") => BASE.to_string(),
|
||||
Some(name) => format!("{BASE}/{}", url_path_escape(name)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal path-segment percent-escape for Gmail label names. Gmail
|
||||
/// allows `/` in user labels (nested), so we only escape the handful
|
||||
/// of characters that break URL parsing.
|
||||
fn url_path_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
c if c.is_ascii_alphanumeric() => out.push(c),
|
||||
'-' | '_' | '.' | '~' | '/' => out.push(ch),
|
||||
other => {
|
||||
let mut buf = [0u8; 4];
|
||||
for b in other.encode_utf8(&mut buf).bytes() {
|
||||
out.push_str(&format!("%{:02X}", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
account_id: &str,
|
||||
_query: String,
|
||||
_limit: u32,
|
||||
) -> Result<Vec<GmailMessage>, String> {
|
||||
log::debug!("[gmail][{account_id}] search (not implemented)");
|
||||
Err(format!(
|
||||
"gmail[{account_id}]: search not implemented — follow-up work"
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_message(account_id: &str, message_id: String) -> Result<GmailMessage, String> {
|
||||
log::debug!("[gmail][{account_id}] get_message id={message_id}");
|
||||
let url = print_view_url(&message_id);
|
||||
let (mut cdp, session_id) = session::attach(account_id).await?;
|
||||
let body = match cdp_fetch::fetch(&mut cdp, &session_id, &url).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
session::detach(&mut cdp, &session_id).await;
|
||||
return Err(format!("gmail[{account_id}]: print-view fetch failed: {e}"));
|
||||
}
|
||||
};
|
||||
session::detach(&mut cdp, &session_id).await;
|
||||
super::print_view::parse(&message_id, &body)
|
||||
.ok_or_else(|| format!("gmail[{account_id}]: print-view parse failed"))
|
||||
}
|
||||
|
||||
/// Gmail's print-view URL — undocumented but stable, returns a clean
|
||||
/// plain-HTML rendering of a single message/thread with subject/from/
|
||||
/// to/date/body in a predictable structure.
|
||||
///
|
||||
/// Gmail exposes two id formats on this endpoint:
|
||||
///
|
||||
/// * Hex thread ids via `th=<hex>` — what the inbox UI uses internally.
|
||||
/// * Decimal ids via `permthid=thread-f:<dec>&permmsgid=msg-f:<dec>`
|
||||
/// — this is what the Atom feed gives us.
|
||||
///
|
||||
/// We build the decimal form so the id that `list_messages` returns
|
||||
/// flows directly into `get_message` without conversion.
|
||||
fn print_view_url(message_id: &str) -> String {
|
||||
let escaped = url_path_escape(message_id);
|
||||
format!(
|
||||
"https://mail.google.com/mail/u/0/?ui=2&view=pt&search=all\
|
||||
&permthid=thread-f:{escaped}&permmsgid=msg-f:{escaped}"
|
||||
)
|
||||
}
|
||||
|
||||
// ── label scrape ────────────────────────────────────────────────────────
|
||||
|
||||
/// Gmail's sidebar labels render as `<a>` or `<div>` with
|
||||
/// `role="link"` and an `aria-label` attribute containing the label
|
||||
/// name (and sometimes the unread count). We walk every such node in
|
||||
/// the snapshot and dedupe by name.
|
||||
///
|
||||
/// System labels come in with upper-case English names (Inbox, Sent,
|
||||
/// Drafts, Spam, Trash, Starred, Important, Snoozed, Scheduled,
|
||||
/// All Mail, Chats, Categories). Anything else is assumed user-created.
|
||||
fn scrape_labels(snap: &Snapshot) -> Vec<GmailLabel> {
|
||||
let mut out: Vec<GmailLabel> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
let link_nodes = snap.find_all(|s, idx| {
|
||||
if !s.is_element(idx) {
|
||||
return false;
|
||||
}
|
||||
// Gmail sidebar items are anchors (`<a>`) or `<div role="link">`.
|
||||
let tag = s.tag(idx);
|
||||
if tag != "A" && tag != "a" && tag != "DIV" && tag != "div" {
|
||||
return false;
|
||||
}
|
||||
matches!(s.attr(idx, "role"), Some("link"))
|
||||
});
|
||||
|
||||
for idx in link_nodes {
|
||||
let aria = match snap.attr(idx, "aria-label") {
|
||||
Some(v) if !v.is_empty() => v,
|
||||
_ => continue,
|
||||
};
|
||||
let (name, unread) = parse_aria_label(aria);
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !seen.insert(name.clone()) {
|
||||
continue;
|
||||
}
|
||||
let kind = if is_system_label(&name) {
|
||||
"system"
|
||||
} else {
|
||||
"user"
|
||||
};
|
||||
out.push(GmailLabel {
|
||||
id: name.clone(),
|
||||
name,
|
||||
kind: kind.to_string(),
|
||||
unread,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Gmail's aria-labels look like:
|
||||
/// `"Inbox 23 unread"`, `"Inbox, 23 unread messages"`,
|
||||
/// `"Starred"`, `"Drafts 4"`, `"Spam, 1"`.
|
||||
/// Peel any trailing `N unread(messages)?` / `N` count off and return
|
||||
/// the plain label name plus the parsed unread count if present.
|
||||
fn parse_aria_label(aria: &str) -> (String, Option<u64>) {
|
||||
let mut name = aria.trim().to_string();
|
||||
|
||||
// 1. Strip English descriptors in order from most-specific to least.
|
||||
// Keep going until no more of these match, which covers labels
|
||||
// like "Spam, 1 unread messages" that chain two suffixes.
|
||||
loop {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let stripped_len = ["unread messages", "unread", "messages"]
|
||||
.iter()
|
||||
.find(|suf| lower.ends_with(*suf))
|
||||
.map(|suf| name.len() - suf.len());
|
||||
match stripped_len {
|
||||
Some(n) => {
|
||||
name.truncate(n);
|
||||
name = name.trim_end_matches([' ', ',']).to_string();
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Now name is e.g. "Inbox 23" or "Spam, 1" or "Starred". Peel off
|
||||
// a trailing integer (with any comma/space separator) as the
|
||||
// unread count.
|
||||
let mut unread: Option<u64> = None;
|
||||
if let Some(last) = name.split(|c: char| c == ' ' || c == ',').next_back() {
|
||||
if !last.is_empty() {
|
||||
if let Ok(n) = last.parse::<u64>() {
|
||||
unread = Some(n);
|
||||
let cut = name.len() - last.len();
|
||||
name.truncate(cut);
|
||||
name = name.trim_end_matches([' ', ',']).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(name.trim().to_string(), unread)
|
||||
}
|
||||
|
||||
/// English-only catalog of Gmail's built-in label names. Users on
|
||||
/// non-English locales will see their labels classified as `"user"`
|
||||
/// until we switch to a locale-agnostic detector (structural DOM cue
|
||||
/// or a localised translation table). Tracked as a follow-up in the
|
||||
/// plan — see `GmailLabel` doc for the caller-facing implication.
|
||||
fn is_system_label(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"Inbox"
|
||||
| "Starred"
|
||||
| "Snoozed"
|
||||
| "Important"
|
||||
| "Sent"
|
||||
| "Drafts"
|
||||
| "Scheduled"
|
||||
| "All Mail"
|
||||
| "Spam"
|
||||
| "Trash"
|
||||
| "Chats"
|
||||
| "Categories"
|
||||
| "Updates"
|
||||
| "Promotions"
|
||||
| "Social"
|
||||
| "Forums"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_aria_label_peels_trailing_count() {
|
||||
assert_eq!(
|
||||
parse_aria_label("Inbox 23 unread"),
|
||||
("Inbox".into(), Some(23))
|
||||
);
|
||||
assert_eq!(parse_aria_label("Drafts 4"), ("Drafts".into(), Some(4)));
|
||||
assert_eq!(parse_aria_label("Starred"), ("Starred".into(), None));
|
||||
assert_eq!(
|
||||
parse_aria_label("Spam, 1 unread messages"),
|
||||
("Spam".into(), Some(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_label_catalog_matches_known_names() {
|
||||
for n in ["Inbox", "Sent", "Drafts", "Trash", "Spam", "Starred"] {
|
||||
assert!(is_system_label(n), "expected system: {n}");
|
||||
}
|
||||
assert!(!is_system_label("Receipts"));
|
||||
assert!(!is_system_label("Personal/Finance"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Helpers for attaching a short-lived CDP session to the logged-in
|
||||
//! Gmail webview. Every op calls [`attach`], runs a small protocol
|
||||
//! sequence, then [`detach`]s.
|
||||
//!
|
||||
//! Matching strategy: reuse the per-account URL fragment
|
||||
//! (`#openhuman-account-<id>`) that `cdp::session::spawn_session`
|
||||
//! appends to the webview's real URL. This is the same mechanism the
|
||||
//! Discord / Slack / WhatsApp scanners use — see
|
||||
//! `app/src-tauri/src/cdp/session.rs` `target_url_fragment`.
|
||||
|
||||
use crate::cdp::{
|
||||
connect_and_attach_matching, detach_session, placeholder_marker, target_url_fragment, CdpConn,
|
||||
};
|
||||
|
||||
/// Attach a CDP session to the Gmail page for `account_id`. Caller must
|
||||
/// [`detach`] when done (or drop the [`CdpConn`] entirely) so we don't
|
||||
/// leak sessions in the browser.
|
||||
///
|
||||
/// Match strategy: the fragment `#openhuman-account-<id>` is appended
|
||||
/// by `cdp::session` to the real URL on first navigation, but Gmail
|
||||
/// may redirect to `accounts.google.com` for auth and strip fragments
|
||||
/// along the way. To stay robust we accept either the fragment match
|
||||
/// OR the original placeholder-title marker (used on very early ticks
|
||||
/// before the first Page.navigate completes). Same strategy the
|
||||
/// per-account session opener itself uses — see
|
||||
/// `app/src-tauri/src/cdp/session.rs`.
|
||||
pub async fn attach(account_id: &str) -> Result<(CdpConn, String), String> {
|
||||
let fragment = target_url_fragment(account_id);
|
||||
let marker = placeholder_marker(account_id);
|
||||
log::debug!(
|
||||
"[gmail][{}] attaching CDP session fragment={} marker={}",
|
||||
account_id,
|
||||
fragment,
|
||||
marker
|
||||
);
|
||||
|
||||
// Pass 1 — account-specific anchors. The fragment survives
|
||||
// same-origin navigations on the placeholder and on the real URL
|
||||
// up until Gmail's own client rewrites the hash to `#inbox` /
|
||||
// `#search/…`; the placeholder title is only set before the first
|
||||
// `Page.navigate` completes. If either matches, the attach is
|
||||
// unambiguously for this account.
|
||||
let fragment_clone = fragment.clone();
|
||||
let marker_clone = marker.clone();
|
||||
if let Ok((cdp, session)) = connect_and_attach_matching(move |t| {
|
||||
t.url.contains(&fragment_clone) || t.title == marker_clone
|
||||
})
|
||||
.await
|
||||
{
|
||||
log::debug!(
|
||||
"[gmail][{}] attached (account-anchored) session={}",
|
||||
account_id,
|
||||
session
|
||||
);
|
||||
return Ok((cdp, session));
|
||||
}
|
||||
|
||||
// Pass 2 — fallback. Gmail has navigated away from our fragment,
|
||||
// so no anchor remains. We accept any `mail.google.com/*` target,
|
||||
// which is safe provided the user has at most one Gmail account
|
||||
// open at a time (every `webview_account_open("gmail", …)` gets
|
||||
// the fragment-anchor back on its next `Page.navigate`, so two
|
||||
// concurrent Gmail webviews will only share the broad predicate
|
||||
// for a short window). Log loudly so this case is easy to spot.
|
||||
log::warn!(
|
||||
"[gmail][{}] account-anchored attach failed; falling back to any mail.google.com/* target",
|
||||
account_id
|
||||
);
|
||||
let (cdp, session) =
|
||||
connect_and_attach_matching(|t| t.url.starts_with("https://mail.google.com/"))
|
||||
.await
|
||||
.map_err(|e| format!("gmail[{account_id}]: attach failed: {e}"))?;
|
||||
log::debug!(
|
||||
"[gmail][{}] attached (fallback) session={}",
|
||||
account_id,
|
||||
session
|
||||
);
|
||||
Ok((cdp, session))
|
||||
}
|
||||
|
||||
pub async fn detach(cdp: &mut CdpConn, session: &str) {
|
||||
detach_session(cdp, session).await;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Data shapes returned by the Gmail API commands.
|
||||
//!
|
||||
//! Keep these stable across ops — the UI / agent consumers rely on the
|
||||
//! shape, not the op that produced it. Owned `String` fields + `Vec<_>`
|
||||
//! throughout so they serialize cleanly over the Tauri IPC bridge.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Gmail label as scraped from the sidebar DOM.
|
||||
///
|
||||
/// Known limitations:
|
||||
///
|
||||
/// * `id` is currently set to the **display name**, not Gmail's internal
|
||||
/// stable id (`INBOX`, `STARRED`, `Label_123`, …). DOM scraping can't
|
||||
/// recover those without either Network MITM of the sync endpoints
|
||||
/// or an authenticated API call. Treat `id` as an opaque display key
|
||||
/// within the webview_apis surface; callers that need stable ids for
|
||||
/// downstream Gmail API calls must wait for the Network-interception
|
||||
/// follow-up tracked in the plan.
|
||||
/// * `kind` is derived from an English name table in `reads.rs`. Users
|
||||
/// on non-English Gmail locales will see every label classified as
|
||||
/// `"user"` until localised detection lands.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GmailLabel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// `"system"` (INBOX, SENT, TRASH, STARRED, …) or `"user"`.
|
||||
pub kind: String,
|
||||
/// Unread count if surfaced in the sidebar, else `None`.
|
||||
pub unread: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GmailMessage {
|
||||
pub id: String,
|
||||
pub thread_id: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub to: Vec<String>,
|
||||
pub cc: Vec<String>,
|
||||
pub subject: Option<String>,
|
||||
pub snippet: Option<String>,
|
||||
/// Plain-text body if available. HTML bodies are not decoded here.
|
||||
pub body: Option<String>,
|
||||
/// Unix millis of the message's internal date, when surfaced.
|
||||
pub date_ms: Option<i64>,
|
||||
pub labels: Vec<String>,
|
||||
pub unread: bool,
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // returned by get_thread once wired; shape is stable.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GmailThread {
|
||||
pub id: String,
|
||||
pub subject: Option<String>,
|
||||
pub messages: Vec<GmailMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct GmailSendRequest {
|
||||
pub to: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cc: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub bcc: Vec<String>,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SendAck {
|
||||
pub message_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Ack {
|
||||
pub status: String,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Write ops: send, trash, add_label.
|
||||
//!
|
||||
//! CDP-only writes are driven via `Input.dispatchKeyEvent` /
|
||||
//! `Input.dispatchMouseEvent` against the live Gmail UI — no JS
|
||||
//! injection. Each op is intentionally stubbed for the first cut so
|
||||
//! the standardized API surface is visible end-to-end; filling them
|
||||
//! in requires careful UI automation that stays stable across Gmail
|
||||
//! chrome churn. See plan §deferred.
|
||||
|
||||
use super::types::{Ack, GmailSendRequest, SendAck};
|
||||
|
||||
pub async fn send(account_id: &str, _req: GmailSendRequest) -> Result<SendAck, String> {
|
||||
log::debug!("[gmail][{account_id}] send (not implemented)");
|
||||
Err(format!(
|
||||
"gmail[{account_id}]: send not implemented — follow-up work per plan §deferred \
|
||||
(CDP Input-event automation of the compose dialog)"
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn trash(account_id: &str, _message_id: String) -> Result<Ack, String> {
|
||||
log::debug!("[gmail][{account_id}] trash (not implemented)");
|
||||
Err(format!(
|
||||
"gmail[{account_id}]: trash not implemented — follow-up work"
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn add_label(
|
||||
account_id: &str,
|
||||
_message_id: String,
|
||||
_label: String,
|
||||
) -> Result<Ack, String> {
|
||||
log::debug!("[gmail][{account_id}] add_label (not implemented)");
|
||||
Err(format!(
|
||||
"gmail[{account_id}]: add_label not implemented — follow-up work"
|
||||
))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod core_process;
|
||||
mod core_update;
|
||||
#[cfg(feature = "cef")]
|
||||
mod discord_scanner;
|
||||
mod gmail;
|
||||
#[cfg(feature = "cef")]
|
||||
mod imessage_scanner;
|
||||
mod notification_settings;
|
||||
@@ -15,6 +16,7 @@ mod slack_scanner;
|
||||
#[cfg(feature = "cef")]
|
||||
mod telegram_scanner;
|
||||
mod webview_accounts;
|
||||
mod webview_apis;
|
||||
mod whatsapp_scanner;
|
||||
|
||||
use std::sync::Mutex;
|
||||
@@ -638,6 +640,32 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Start the webview_apis WebSocket bridge BEFORE spawning core —
|
||||
// core reads OPENHUMAN_WEBVIEW_APIS_PORT on first connect, and
|
||||
// connects lazily, so the env var must be set before the spawn.
|
||||
//
|
||||
// If the bridge fails to bind we clear any inherited port env so
|
||||
// the core child can't accidentally connect to whichever loopback
|
||||
// process already owns that port, then abort setup — the bridge
|
||||
// is load-bearing for every webview_apis RPC method.
|
||||
let bridge_ok = tauri::async_runtime::block_on(async {
|
||||
match webview_apis::start().await {
|
||||
Ok(port) => {
|
||||
std::env::set_var(webview_apis::server::PORT_ENV, port.to_string());
|
||||
log::info!("[webview_apis] bridge ready on port {port}");
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("[webview_apis] failed to start bridge: {err}");
|
||||
std::env::remove_var(webview_apis::server::PORT_ENV);
|
||||
false
|
||||
}
|
||||
}
|
||||
});
|
||||
if !bridge_ok {
|
||||
return Err("webview_apis bridge failed to start — aborting setup".into());
|
||||
}
|
||||
|
||||
let core_run_mode = core_process::default_core_run_mode(daemon_mode);
|
||||
let core_bin = if matches!(core_run_mode, core_process::CoreRunMode::ChildProcess) {
|
||||
core_process::default_core_bin()
|
||||
@@ -890,6 +918,62 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
}
|
||||
// OPENHUMAN_DEV_AUTO_GMAIL=<account-id> opens the Gmail account
|
||||
// webview at startup so the webview_apis bridge has a live CDP
|
||||
// target to attach to. Pair with:
|
||||
// curl -sS http://127.0.0.1:7788/rpc \
|
||||
// -H 'Content-Type: application/json' \
|
||||
// -d '{"jsonrpc":"2.0","id":1,"method":"openhuman.webview_apis_gmail_list_labels","params":{"account_id":"<account-id>"}}'
|
||||
if let Ok(account_id) = std::env::var("OPENHUMAN_DEV_AUTO_GMAIL") {
|
||||
let account_id = account_id.trim().to_string();
|
||||
if !account_id.is_empty() {
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let state = app_handle.state::<webview_accounts::WebviewAccountsState>();
|
||||
// Size the Gmail child webview to the parent window
|
||||
// so the inbox is usable without manual resizing.
|
||||
let (w, h) = app_handle
|
||||
.get_webview_window("main")
|
||||
.and_then(|main| {
|
||||
let scale = main.scale_factor().unwrap_or(1.0);
|
||||
main.inner_size()
|
||||
.ok()
|
||||
.map(|s| ((s.width as f64) / scale, (s.height as f64) / scale))
|
||||
})
|
||||
.unwrap_or((1100.0, 780.0));
|
||||
let args = webview_accounts::OpenArgs {
|
||||
account_id: account_id.clone(),
|
||||
provider: "gmail".to_string(),
|
||||
url: None,
|
||||
bounds: Some(webview_accounts::Bounds {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: w,
|
||||
height: h,
|
||||
}),
|
||||
};
|
||||
match webview_accounts::webview_account_open(
|
||||
app_handle.clone(),
|
||||
state,
|
||||
args,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(label) => log::info!(
|
||||
"[dev-auto-gmail] spawned label={} account={}",
|
||||
label,
|
||||
account_id
|
||||
),
|
||||
Err(e) => log::error!(
|
||||
"[dev-auto-gmail] failed: {} (account={})",
|
||||
e,
|
||||
account_id
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "macos", feature = "cef"))]
|
||||
{
|
||||
@@ -936,6 +1020,13 @@ pub fn run() {
|
||||
webview_accounts::webview_set_focused_account,
|
||||
notification_settings::notification_settings_get,
|
||||
notification_settings::notification_settings_set,
|
||||
gmail::gmail_list_labels,
|
||||
gmail::gmail_list_messages,
|
||||
gmail::gmail_search,
|
||||
gmail::gmail_get_message,
|
||||
gmail::gmail_send,
|
||||
gmail::gmail_trash,
|
||||
gmail::gmail_add_label,
|
||||
activate_main_window,
|
||||
show_native_notification
|
||||
])
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Webview APIs bridge — Tauri side (server).
|
||||
//!
|
||||
//! Exposes the connector APIs that live in the Tauri shell (Gmail,
|
||||
//! future: Notion, Slack, …) to the core sidecar over a local
|
||||
//! WebSocket on `127.0.0.1`. Core-side handlers in
|
||||
//! `src/openhuman/webview_apis/` connect as a client and proxy
|
||||
//! JSON-RPC calls (`openhuman.gmail_*`) through this bridge so curl
|
||||
//! against the core's RPC port reaches the live webview session.
|
||||
//!
|
||||
//! ## Protocol
|
||||
//!
|
||||
//! JSON text frames, one envelope per frame:
|
||||
//!
|
||||
//! ```text
|
||||
//! request: { "kind": "request", "id": "...", "method": "gmail.list_labels",
|
||||
//! "params": { "account_id": "…" } }
|
||||
//! response: { "kind": "response", "id": "...", "ok": true, "result": <json> }
|
||||
//! response: { "kind": "response", "id": "...", "ok": false, "error": "…" }
|
||||
//! ```
|
||||
//!
|
||||
//! The server is permissive: it accepts requests from any connection on
|
||||
//! loopback (the spawned core process is the only one expected, but we
|
||||
//! don't authenticate — the port is never bound to a public interface).
|
||||
//!
|
||||
//! ## Startup / port coordination
|
||||
//!
|
||||
//! The server picks its port at boot:
|
||||
//! 1. If `OPENHUMAN_WEBVIEW_APIS_PORT` is set, try that port first.
|
||||
//! 2. Else bind `127.0.0.1:0` and let the OS pick.
|
||||
//!
|
||||
//! Either way the resolved port is exposed via
|
||||
//! [`resolved_port`] and pushed into the core sidecar's environment
|
||||
//! as `OPENHUMAN_WEBVIEW_APIS_PORT` by `core_process::spawn_core`.
|
||||
|
||||
pub mod router;
|
||||
pub mod server;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use server::{resolved_port, start};
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Method dispatch for webview_apis requests.
|
||||
//!
|
||||
//! Maps a protocol method name (`"gmail.list_labels"`) to the Rust
|
||||
//! function that handles it. Keep this file as the single place that
|
||||
//! does the mapping — implementations live in their own connector
|
||||
//! modules (`crate::gmail`, and future siblings).
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::gmail;
|
||||
|
||||
/// Dispatch a single webview_apis request to its handler. Returns the
|
||||
/// `result` JSON on success or a string error that the server relays
|
||||
/// back as `{ ok: false, error }`.
|
||||
///
|
||||
/// Outcome logging lives here so the bridge has a single chokepoint
|
||||
/// for success/failure traces — callers (tests, the WS server) keep
|
||||
/// their own entry/exit logs but rely on this function to summarise
|
||||
/// each dispatch decision.
|
||||
pub async fn dispatch(method: &str, params: Map<String, Value>) -> Result<Value, String> {
|
||||
log::debug!("[webview_apis] dispatch method={method}");
|
||||
let out = dispatch_inner(method, params).await;
|
||||
match &out {
|
||||
Ok(_) => log::debug!("[webview_apis] dispatch ok method={method}"),
|
||||
Err(e) => log::warn!("[webview_apis] dispatch err method={method} error={e}"),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn dispatch_inner(method: &str, params: Map<String, Value>) -> Result<Value, String> {
|
||||
match method {
|
||||
"gmail.list_labels" => {
|
||||
serialize(gmail::cdp_list_labels(&read_string(¶ms, "account_id")?).await)
|
||||
}
|
||||
"gmail.list_messages" => serialize(
|
||||
gmail::cdp_list_messages(
|
||||
&read_string(¶ms, "account_id")?,
|
||||
read_u32(¶ms, "limit")?,
|
||||
read_optional_string(¶ms, "label")?,
|
||||
)
|
||||
.await,
|
||||
),
|
||||
"gmail.search" => serialize(
|
||||
gmail::cdp_search(
|
||||
&read_string(¶ms, "account_id")?,
|
||||
read_string(¶ms, "query")?,
|
||||
read_u32(¶ms, "limit")?,
|
||||
)
|
||||
.await,
|
||||
),
|
||||
"gmail.get_message" => serialize(
|
||||
gmail::cdp_get_message(
|
||||
&read_string(¶ms, "account_id")?,
|
||||
read_string(¶ms, "message_id")?,
|
||||
)
|
||||
.await,
|
||||
),
|
||||
"gmail.send" => {
|
||||
let account_id = read_string(¶ms, "account_id")?;
|
||||
let request: gmail::types::GmailSendRequest = serde_json::from_value(
|
||||
params
|
||||
.get("request")
|
||||
.cloned()
|
||||
.ok_or_else(|| "missing required param 'request'".to_string())?,
|
||||
)
|
||||
.map_err(|e| format!("invalid 'request': {e}"))?;
|
||||
serialize(gmail::cdp_send(&account_id, request).await)
|
||||
}
|
||||
"gmail.trash" => serialize(
|
||||
gmail::cdp_trash(
|
||||
&read_string(¶ms, "account_id")?,
|
||||
read_string(¶ms, "message_id")?,
|
||||
)
|
||||
.await,
|
||||
),
|
||||
"gmail.add_label" => serialize(
|
||||
gmail::cdp_add_label(
|
||||
&read_string(¶ms, "account_id")?,
|
||||
read_string(¶ms, "message_id")?,
|
||||
read_string(¶ms, "label")?,
|
||||
)
|
||||
.await,
|
||||
),
|
||||
_ => Err(format!("unknown webview_apis method: {method}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize<T: serde::Serialize>(res: Result<T, String>) -> Result<Value, String> {
|
||||
match res {
|
||||
Ok(v) => serde_json::to_value(v)
|
||||
.map_err(|e| format!("[webview_apis] serialize response failed: {e}")),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// ── param helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// Read a required string param, trimmed. Empty / whitespace-only
|
||||
/// values are rejected at this boundary so CDP helpers downstream
|
||||
/// never see a meaningless account_id / message_id.
|
||||
fn read_string(params: &Map<String, Value>, key: &str) -> Result<String, String> {
|
||||
let s = params
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| format!("missing required string param '{key}'"))?;
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(format!("invalid '{key}': must be non-empty"));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn read_optional_string(params: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
|
||||
match params.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s.clone())),
|
||||
Some(_) => Err(format!("invalid '{key}': expected string")),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_u32(params: &Map<String, Value>, key: &str) -> Result<u32, String> {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.ok_or_else(|| format!("missing or invalid u32 param '{key}'"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_method_is_rejected() {
|
||||
let err = dispatch("something.else", Map::new()).await.unwrap_err();
|
||||
assert!(err.contains("unknown webview_apis method"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_account_id_reports_clearly() {
|
||||
let err = dispatch("gmail.list_labels", Map::new()).await.unwrap_err();
|
||||
assert!(err.contains("account_id"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_messages_rejects_missing_limit() {
|
||||
let mut p = Map::new();
|
||||
p.insert("account_id".into(), json!("gmail"));
|
||||
let err = dispatch("gmail.list_messages", p).await.unwrap_err();
|
||||
assert!(err.contains("limit"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blank_account_id_is_rejected() {
|
||||
let mut p = Map::new();
|
||||
p.insert("account_id".into(), json!(" "));
|
||||
let err = dispatch("gmail.list_labels", p).await.unwrap_err();
|
||||
assert!(
|
||||
err.contains("must be non-empty"),
|
||||
"expected non-empty complaint, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_string_error_uses_actual_key_name() {
|
||||
// Covers the read_optional_string path: the key 'label' used to be
|
||||
// hardcoded in the error; now it must echo the real param name.
|
||||
let mut p = Map::new();
|
||||
p.insert("account_id".into(), json!("gmail"));
|
||||
p.insert("limit".into(), json!(5));
|
||||
p.insert("label".into(), json!(42)); // wrong type — not a string
|
||||
let err = dispatch("gmail.list_messages", p).await.unwrap_err();
|
||||
assert!(err.contains("'label'"), "got: {err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! WebSocket server for the webview_apis bridge.
|
||||
//!
|
||||
//! Binds a loopback TCP socket, accepts incoming connections (one per
|
||||
//! core sidecar instance), and for each frame: decode → route → encode
|
||||
//! response. Any number of concurrent requests per connection: each is
|
||||
//! spawned as its own task and the responses are serialised back over
|
||||
//! the shared sink via an mpsc.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use super::router;
|
||||
|
||||
/// Env var the Tauri host writes (before spawning core) and core reads
|
||||
/// (in `src/openhuman/webview_apis/client.rs`) so both agree on the
|
||||
/// port without a discovery round-trip.
|
||||
pub const PORT_ENV: &str = "OPENHUMAN_WEBVIEW_APIS_PORT";
|
||||
|
||||
/// The port the server is bound to. `0` before `start()` resolves it.
|
||||
static RESOLVED_PORT: AtomicU16 = AtomicU16::new(0);
|
||||
static STARTED: OnceLock<()> = OnceLock::new();
|
||||
|
||||
pub fn resolved_port() -> u16 {
|
||||
RESOLVED_PORT.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Start the server. Idempotent: after the first successful call any
|
||||
/// subsequent call is a no-op. Returns the bound port.
|
||||
///
|
||||
/// Port selection: if `PORT_ENV` is set and non-zero, bind that port
|
||||
/// (caller gets a deterministic port across runs — useful in dev);
|
||||
/// otherwise bind `127.0.0.1:0` and let the OS pick.
|
||||
pub async fn start() -> Result<u16, String> {
|
||||
if STARTED.get().is_some() {
|
||||
return Ok(resolved_port());
|
||||
}
|
||||
|
||||
let requested = std::env::var(PORT_ENV)
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let addr: SocketAddr = format!("127.0.0.1:{requested}")
|
||||
.parse()
|
||||
.map_err(|e| format!("[webview_apis] bad addr: {e}"))?;
|
||||
let listener = TcpListener::bind(addr)
|
||||
.await
|
||||
.map_err(|e| format!("[webview_apis] bind {addr} failed: {e}"))?;
|
||||
let bound = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("[webview_apis] local_addr: {e}"))?;
|
||||
let port = bound.port();
|
||||
RESOLVED_PORT.store(port, Ordering::SeqCst);
|
||||
let _ = STARTED.set(());
|
||||
|
||||
log::info!("[webview_apis] server listening on {bound}");
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, peer)) => {
|
||||
log::info!("[webview_apis] accepted connection from {peer}");
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream).await {
|
||||
log::warn!("[webview_apis] connection {peer} ended: {e}");
|
||||
} else {
|
||||
log::info!("[webview_apis] connection {peer} closed cleanly");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[webview_apis] accept failed: {e}");
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
async fn handle_connection(stream: tokio::net::TcpStream) -> Result<(), String> {
|
||||
let ws = tokio_tungstenite::accept_async(stream)
|
||||
.await
|
||||
.map_err(|e| format!("ws handshake: {e}"))?;
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
|
||||
// Responses from per-request tasks fan in here and are written back
|
||||
// in order. 32 is plenty — the core sidecar issues one request at a
|
||||
// time per op in the common path.
|
||||
let (tx, mut rx) = mpsc::channel::<String>(32);
|
||||
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if let Err(e) = sink.send(Message::Text(msg)).await {
|
||||
log::warn!("[webview_apis] ws send failed: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Some(msg) = stream.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let reply = handle_frame(&text).await;
|
||||
if let Err(_e) = tx.send(reply).await {
|
||||
log::warn!("[webview_apis] response channel closed before send");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Message::Binary(_)) => {
|
||||
log::debug!("[webview_apis] ignoring binary frame");
|
||||
}
|
||||
Ok(Message::Ping(p)) => {
|
||||
// tungstenite auto-responds to Ping at the protocol layer;
|
||||
// log for visibility.
|
||||
log::trace!("[webview_apis] ping {} bytes", p.len());
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
log::debug!("[webview_apis] peer requested close");
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
return Err(format!("ws recv: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(tx);
|
||||
let _ = writer.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_frame(text: &str) -> String {
|
||||
let envelope: Request = match serde_json::from_str(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::warn!("[webview_apis] bad request frame: {e}");
|
||||
return encode_response(Response::error("<unknown>", format!("bad frame: {e}")));
|
||||
}
|
||||
};
|
||||
if envelope.kind != "request" {
|
||||
return encode_response(Response::error(
|
||||
&envelope.id,
|
||||
format!("unsupported envelope kind '{}'", envelope.kind),
|
||||
));
|
||||
}
|
||||
let params = envelope.params.unwrap_or_default();
|
||||
let started = std::time::Instant::now();
|
||||
let result = router::dispatch(&envelope.method, params).await;
|
||||
let ms = started.elapsed().as_millis();
|
||||
match result {
|
||||
Ok(value) => {
|
||||
log::debug!(
|
||||
"[webview_apis] {} id={} ok in {ms}ms",
|
||||
envelope.method,
|
||||
envelope.id
|
||||
);
|
||||
encode_response(Response::ok(&envelope.id, value))
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[webview_apis] {} id={} err in {ms}ms: {e}",
|
||||
envelope.method,
|
||||
envelope.id
|
||||
);
|
||||
encode_response(Response::error(&envelope.id, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_response(resp: Response) -> String {
|
||||
serde_json::to_string(&resp).unwrap_or_else(|e| {
|
||||
format!(
|
||||
r#"{{"kind":"response","id":"{}","ok":false,"error":"response encode failed: {}"}}"#,
|
||||
resp.id,
|
||||
e.to_string().replace('"', "\\\"")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ── envelope types ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Request {
|
||||
kind: String,
|
||||
id: String,
|
||||
method: String,
|
||||
#[serde(default)]
|
||||
params: Option<Map<String, Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Response {
|
||||
kind: &'static str,
|
||||
id: String,
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
fn ok(id: &str, result: Value) -> Self {
|
||||
Self {
|
||||
kind: "response",
|
||||
id: id.to_string(),
|
||||
ok: true,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn error(id: &str, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind: "response",
|
||||
id: id.to_string(),
|
||||
ok: false,
|
||||
result: None,
|
||||
error: Some(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
// Minimal mock of the Tauri-side webview_apis WS server. Lets you curl
|
||||
// `openhuman.webview_apis_gmail_*` against the core binary without
|
||||
// bringing up the full Tauri shell. Usage:
|
||||
// node scripts/mock-webview-bridge.mjs 9826
|
||||
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
const port = Number(process.argv[2] ?? 9826);
|
||||
const wss = new WebSocketServer({ host: '127.0.0.1', port });
|
||||
|
||||
console.log(`[mock-bridge] listening on ws://127.0.0.1:${port}`);
|
||||
|
||||
wss.on('connection', (sock, req) => {
|
||||
console.log(`[mock-bridge] conn from ${req.socket.remoteAddress}`);
|
||||
sock.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
console.log(`[mock-bridge] <- ${msg.method} id=${msg.id}`);
|
||||
const reply = { kind: 'response', id: msg.id };
|
||||
switch (msg.method) {
|
||||
case 'gmail.list_labels':
|
||||
reply.ok = true;
|
||||
reply.result = [
|
||||
{ id: 'INBOX', name: 'Inbox', kind: 'system', unread: 3 },
|
||||
{ id: 'STARRED', name: 'Starred', kind: 'system', unread: null },
|
||||
{ id: 'Receipts', name: 'Receipts', kind: 'user', unread: 1 },
|
||||
];
|
||||
break;
|
||||
case 'gmail.list_messages':
|
||||
reply.ok = true;
|
||||
reply.result = [
|
||||
{
|
||||
id: 'm-001', thread_id: 't-001',
|
||||
from: 'alice@example.com', to: ['you@example.com'], cc: [],
|
||||
subject: 'Hello from the mock', snippet: 'mock snippet',
|
||||
body: null, date_ms: Date.now(),
|
||||
labels: ['INBOX'], unread: true,
|
||||
},
|
||||
];
|
||||
break;
|
||||
default:
|
||||
reply.ok = false;
|
||||
reply.error = `mock-bridge: unhandled method '${msg.method}'`;
|
||||
}
|
||||
sock.send(JSON.stringify(reply));
|
||||
console.log(`[mock-bridge] -> ${msg.method} id=${msg.id} ok=${reply.ok}`);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,9 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
controllers.extend(crate::openhuman::composio::all_composio_registered_controllers());
|
||||
// Scheduled job management
|
||||
controllers.extend(crate::openhuman::cron::all_cron_registered_controllers());
|
||||
// Webview APIs bridge — proxies connector calls (Gmail, …) through
|
||||
// a WebSocket to the Tauri shell so curl reaches the live webview.
|
||||
controllers.extend(crate::openhuman::webview_apis::all_webview_apis_registered_controllers());
|
||||
// Agent definition and prompt inspection
|
||||
controllers.extend(crate::openhuman::agent::all_agent_registered_controllers());
|
||||
// System and process health monitoring
|
||||
@@ -168,6 +171,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas.extend(crate::openhuman::app_state::all_app_state_controller_schemas());
|
||||
schemas.extend(crate::openhuman::composio::all_composio_controller_schemas());
|
||||
schemas.extend(crate::openhuman::cron::all_cron_controller_schemas());
|
||||
schemas.extend(crate::openhuman::webview_apis::all_webview_apis_controller_schemas());
|
||||
schemas.extend(crate::openhuman::agent::all_agent_controller_schemas());
|
||||
schemas.extend(crate::openhuman::health::all_health_controller_schemas());
|
||||
schemas.extend(crate::openhuman::doctor::all_doctor_controller_schemas());
|
||||
@@ -272,6 +276,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
|
||||
"webhooks" => {
|
||||
Some("Webhook tunnel registrations and captured request/response debug logs.")
|
||||
}
|
||||
"webview_apis" => Some(
|
||||
"Typed connector APIs (Gmail, …) proxied over a loopback WebSocket to the Tauri shell so core-side JSON-RPC reaches live-webview CDP operations.",
|
||||
),
|
||||
"update" => {
|
||||
Some("Self-update: check GitHub Releases for newer core binary and stage updates.")
|
||||
}
|
||||
|
||||
@@ -63,5 +63,6 @@ pub mod update;
|
||||
pub mod util;
|
||||
pub mod voice;
|
||||
pub mod webhooks;
|
||||
pub mod webview_apis;
|
||||
pub mod webview_notifications;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//! WebSocket client for the webview_apis bridge.
|
||||
//!
|
||||
//! One long-lived connection to the Tauri shell's local WebSocket
|
||||
//! server. Requests are sent as JSON envelopes with a generated id;
|
||||
//! matching responses resolve a `oneshot::Sender` kept in a pending
|
||||
//! map.
|
||||
//!
|
||||
//! The client is lazy: the first [`request`] call opens the connection
|
||||
//! and spawns a reader task. If the connection drops, the next request
|
||||
//! reconnects.
|
||||
//!
|
||||
//! Port discovery: `OPENHUMAN_WEBVIEW_APIS_PORT` — set by the Tauri
|
||||
//! host (`webview_apis::server::PORT_ENV`) before spawning this
|
||||
//! process. If missing, requests return an actionable error so
|
||||
//! operators can see the misconfiguration immediately.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// Env var the Tauri host writes before spawning core.
|
||||
pub const PORT_ENV: &str = "OPENHUMAN_WEBVIEW_APIS_PORT";
|
||||
|
||||
/// Total time a single request will wait for a response. Gmail ops can
|
||||
/// involve a DOM snapshot or a short navigate; 15s is a generous but
|
||||
/// still-bounded ceiling.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
static CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
|
||||
fn client() -> &'static Client {
|
||||
CLIENT.get_or_init(Client::new)
|
||||
}
|
||||
|
||||
/// Send a request over the bridge and await the typed response.
|
||||
///
|
||||
/// The deserialization error surface is deliberately coarse — callers
|
||||
/// get a single `String` error per envelope so the JSON-RPC handler
|
||||
/// can propagate it verbatim.
|
||||
pub async fn request<T>(method: &str, params: Map<String, Value>) -> Result<T, String>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
let started = std::time::Instant::now();
|
||||
tracing::debug!(%method, "[webview_apis-client] request");
|
||||
let raw = tokio::time::timeout(
|
||||
REQUEST_TIMEOUT,
|
||||
client().dispatch(method.to_string(), params),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"[webview_apis] {method}: timed out after {}s",
|
||||
REQUEST_TIMEOUT.as_secs()
|
||||
)
|
||||
})??;
|
||||
let parsed: T = serde_json::from_value(raw)
|
||||
.map_err(|e| format!("[webview_apis] {method}: response deserialize failed: {e}"))?;
|
||||
tracing::debug!(
|
||||
%method,
|
||||
ms = started.elapsed().as_millis() as u64,
|
||||
"[webview_apis-client] ok"
|
||||
);
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
// ── Internals ───────────────────────────────────────────────────────────
|
||||
|
||||
struct Client {
|
||||
next_id: AtomicU64,
|
||||
pending: Arc<Mutex<HashMap<String, oneshot::Sender<Result<Value, String>>>>>,
|
||||
sink: Arc<Mutex<Option<mpsc::Sender<String>>>>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
next_id: AtomicU64::new(1),
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
sink: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch(&self, method: String, params: Map<String, Value>) -> Result<Value, String> {
|
||||
let id = format!("r{}", self.next_id.fetch_add(1, Ordering::SeqCst));
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().await.insert(id.clone(), tx);
|
||||
|
||||
let envelope = Request {
|
||||
kind: "request",
|
||||
id: &id,
|
||||
method: &method,
|
||||
params: ¶ms,
|
||||
};
|
||||
let frame = serde_json::to_string(&envelope).map_err(|e| format!("encode request: {e}"))?;
|
||||
|
||||
let sender = self.ensure_connected().await?;
|
||||
if let Err(e) = sender.send(frame).await {
|
||||
// Drop the pending entry so we don't leak.
|
||||
self.pending.lock().await.remove(&id);
|
||||
return Err(format!("send request: {e}"));
|
||||
}
|
||||
|
||||
match rx.await {
|
||||
Ok(res) => res,
|
||||
Err(_) => Err("request cancelled (connection dropped)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return an mpsc::Sender that the reader loop holds. Reconnects
|
||||
/// if the previous connection is gone.
|
||||
async fn ensure_connected(&self) -> Result<mpsc::Sender<String>, String> {
|
||||
{
|
||||
let guard = self.sink.lock().await;
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
if !tx.is_closed() {
|
||||
return Ok(tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Connect under an exclusive lock so two concurrent callers
|
||||
// don't open two sockets.
|
||||
let mut guard = self.sink.lock().await;
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
if !tx.is_closed() {
|
||||
return Ok(tx.clone());
|
||||
}
|
||||
}
|
||||
let port = std::env::var(PORT_ENV).map_err(|_| {
|
||||
format!(
|
||||
"[webview_apis] {PORT_ENV} not set — the Tauri shell must be running \
|
||||
and have spawned this core process so the bridge port is inherited"
|
||||
)
|
||||
})?;
|
||||
let url = format!("ws://127.0.0.1:{port}/");
|
||||
tracing::info!(%url, "[webview_apis-client] connecting");
|
||||
let (ws, _) = tokio_tungstenite::connect_async(&url)
|
||||
.await
|
||||
.map_err(|e| format!("[webview_apis] connect {url}: {e}"))?;
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<String>(32);
|
||||
|
||||
// Writer task: pull frames from rx and push them onto the ws sink.
|
||||
// On exit we must clear `self.sink` so `ensure_connected` opens a
|
||||
// fresh WS next time instead of handing out a dead sender.
|
||||
let sink_for_writer = Arc::clone(&self.sink);
|
||||
tokio::spawn(async move {
|
||||
while let Some(frame) = rx.recv().await {
|
||||
if let Err(e) = sink.send(Message::Text(frame)).await {
|
||||
tracing::warn!(error = %e, "[webview_apis-client] ws send failed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = sink.send(Message::Close(None)).await;
|
||||
*sink_for_writer.lock().await = None;
|
||||
});
|
||||
|
||||
// Reader task: decode responses and resolve pending oneshots.
|
||||
let pending = Arc::clone(&self.pending);
|
||||
let sink_for_reader = Arc::clone(&self.sink);
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = stream.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => match serde_json::from_str::<Response>(&text) {
|
||||
Ok(r) => {
|
||||
if let Some(tx) = pending.lock().await.remove(&r.id) {
|
||||
let payload = if r.ok {
|
||||
Ok(r.result.unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(r.error.unwrap_or_else(|| {
|
||||
"bridge returned ok=false with no error".into()
|
||||
}))
|
||||
};
|
||||
let _ = tx.send(payload);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
id = %r.id,
|
||||
"[webview_apis-client] response for unknown id"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"[webview_apis-client] bad response frame"
|
||||
);
|
||||
}
|
||||
},
|
||||
Ok(Message::Close(_)) | Err(_) => {
|
||||
tracing::info!("[webview_apis-client] connection closed");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// On exit, drop the cached sender so `ensure_connected`
|
||||
// reconnects on the next request, and fail every still-
|
||||
// pending request so callers don't hang.
|
||||
*sink_for_reader.lock().await = None;
|
||||
let mut pending = pending.lock().await;
|
||||
for (_id, tx) in pending.drain() {
|
||||
let _ = tx.send(Err("connection dropped".into()));
|
||||
}
|
||||
});
|
||||
|
||||
*guard = Some(tx.clone());
|
||||
Ok(tx)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Envelope types ──────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Request<'a> {
|
||||
kind: &'static str,
|
||||
id: &'a str,
|
||||
method: &'a str,
|
||||
params: &'a Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Response {
|
||||
#[allow(dead_code)]
|
||||
kind: String,
|
||||
id: String,
|
||||
ok: bool,
|
||||
#[serde(default)]
|
||||
result: Option<Value>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Webview APIs bridge — core side (client).
|
||||
//!
|
||||
//! Mirror of `app/src-tauri/src/webview_apis/`. Exposes
|
||||
//! `openhuman.webview_apis_*` JSON-RPC methods that proxy to the Tauri
|
||||
//! host over a local WebSocket, so the live-webview connectors
|
||||
//! (Gmail, Notion, …) are reachable from curl and the agent without
|
||||
//! the shell-only Tauri IPC channel.
|
||||
//!
|
||||
//! Startup: [`client`] is lazy — the first call opens the WS to
|
||||
//! `ws://127.0.0.1:$OPENHUMAN_WEBVIEW_APIS_PORT`. That env var is set
|
||||
//! by the Tauri host (`webview_apis::server::PORT_ENV`) before
|
||||
//! spawning this process.
|
||||
|
||||
pub mod client;
|
||||
mod rpc;
|
||||
mod schemas;
|
||||
pub mod types;
|
||||
|
||||
pub use schemas::{
|
||||
all_controller_schemas as all_webview_apis_controller_schemas,
|
||||
all_registered_controllers as all_webview_apis_registered_controllers,
|
||||
schemas as webview_apis_schemas,
|
||||
};
|
||||
pub use types::{Ack, GmailLabel, GmailMessage, GmailSendRequest, SendAck};
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Handler bodies for the webview_apis controllers.
|
||||
//!
|
||||
//! `schemas.rs` stays registry-only per project convention
|
||||
//! (`src/openhuman/*/schemas.rs`: describe the schema and delegate to
|
||||
//! `rpc.rs`). Each `handle_*` here validates params, issues the bridge
|
||||
//! call via [`super::client::request`], and wraps the response in
|
||||
//! [`RpcOutcome`].
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::core::all::ControllerFuture;
|
||||
use crate::openhuman::webview_apis::client;
|
||||
use crate::openhuman::webview_apis::types::{
|
||||
Ack, GmailLabel, GmailMessage, GmailSendRequest, SendAck,
|
||||
};
|
||||
use crate::rpc::RpcOutcome;
|
||||
|
||||
// ── handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn handle_gmail_list_labels(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
require_string(¶ms, "account_id")?;
|
||||
let labels: Vec<GmailLabel> = client::request("gmail.list_labels", params).await?;
|
||||
finish(RpcOutcome::single_log(
|
||||
labels,
|
||||
"[webview_apis] gmail_list_labels ok",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_gmail_list_messages(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
require_string(¶ms, "account_id")?;
|
||||
require_u32(¶ms, "limit")?;
|
||||
let messages: Vec<GmailMessage> = client::request("gmail.list_messages", params).await?;
|
||||
finish(RpcOutcome::single_log(
|
||||
messages,
|
||||
"[webview_apis] gmail_list_messages ok",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_gmail_search(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
require_string(¶ms, "account_id")?;
|
||||
require_string(¶ms, "query")?;
|
||||
require_u32(¶ms, "limit")?;
|
||||
let messages: Vec<GmailMessage> = client::request("gmail.search", params).await?;
|
||||
finish(RpcOutcome::single_log(
|
||||
messages,
|
||||
"[webview_apis] gmail_search ok",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_gmail_get_message(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
require_string(¶ms, "account_id")?;
|
||||
require_string(¶ms, "message_id")?;
|
||||
let msg: GmailMessage = client::request("gmail.get_message", params).await?;
|
||||
finish(RpcOutcome::single_log(
|
||||
msg,
|
||||
"[webview_apis] gmail_get_message ok",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_gmail_send(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
require_string(¶ms, "account_id")?;
|
||||
let _: GmailSendRequest = read_required(¶ms, "request")?;
|
||||
let ack: SendAck = client::request("gmail.send", params).await?;
|
||||
finish(RpcOutcome::single_log(ack, "[webview_apis] gmail_send ok"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_gmail_trash(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
require_string(¶ms, "account_id")?;
|
||||
require_string(¶ms, "message_id")?;
|
||||
let ack: Ack = client::request("gmail.trash", params).await?;
|
||||
finish(RpcOutcome::single_log(ack, "[webview_apis] gmail_trash ok"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_gmail_add_label(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
require_string(¶ms, "account_id")?;
|
||||
require_string(¶ms, "message_id")?;
|
||||
require_string(¶ms, "label")?;
|
||||
let ack: Ack = client::request("gmail.add_label", params).await?;
|
||||
finish(RpcOutcome::single_log(
|
||||
ack,
|
||||
"[webview_apis] gmail_add_label ok",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn finish<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
|
||||
outcome.into_cli_compatible_json()
|
||||
}
|
||||
|
||||
fn require_string(params: &Map<String, Value>, key: &str) -> Result<(), String> {
|
||||
match params.get(key) {
|
||||
Some(Value::String(s)) if !s.trim().is_empty() => Ok(()),
|
||||
Some(Value::String(_)) => Err(format!("invalid '{key}': must be non-empty")),
|
||||
Some(_) => Err(format!("invalid '{key}': expected string")),
|
||||
None => Err(format!("missing required param '{key}'")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tighten the numeric guard: the schema declares every `limit` input
|
||||
/// as `TypeSchema::U64` and the Tauri-side router casts to `u32`, so
|
||||
/// reject negatives, fractions, and values that overflow `u32` here
|
||||
/// rather than letting them surface as confusing downstream errors.
|
||||
fn require_u32(params: &Map<String, Value>, key: &str) -> Result<(), String> {
|
||||
match params.get(key) {
|
||||
Some(Value::Number(n)) => {
|
||||
let u = n
|
||||
.as_u64()
|
||||
.ok_or_else(|| format!("invalid '{key}': expected non-negative integer"))?;
|
||||
if u > u32::MAX as u64 {
|
||||
return Err(format!("invalid '{key}': exceeds u32 max"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some(_) => Err(format!("invalid '{key}': expected number")),
|
||||
None => Err(format!("missing required param '{key}'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_required<T: DeserializeOwned>(params: &Map<String, Value>, key: &str) -> Result<T, String> {
|
||||
let v = params
|
||||
.get(key)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("missing required param '{key}'"))?;
|
||||
serde_json::from_value(v).map_err(|e| format!("invalid '{key}': {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn require_string_rejects_missing_empty_and_whitespace() {
|
||||
let mut p = Map::new();
|
||||
assert!(require_string(&p, "account_id").is_err());
|
||||
p.insert("account_id".into(), Value::String(String::new()));
|
||||
assert!(require_string(&p, "account_id").is_err());
|
||||
p.insert("account_id".into(), Value::String(" ".into()));
|
||||
assert!(require_string(&p, "account_id").is_err());
|
||||
p.insert("account_id".into(), Value::String("gmail".into()));
|
||||
assert!(require_string(&p, "account_id").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_u32_rejects_negative_fraction_and_overflow() {
|
||||
let mut p = Map::new();
|
||||
assert!(require_u32(&p, "limit").is_err()); // missing
|
||||
p.insert("limit".into(), json!(-1));
|
||||
assert!(require_u32(&p, "limit").is_err());
|
||||
p.insert("limit".into(), json!(1.5));
|
||||
assert!(require_u32(&p, "limit").is_err());
|
||||
p.insert("limit".into(), json!(u64::from(u32::MAX) + 1));
|
||||
assert!(require_u32(&p, "limit").is_err());
|
||||
p.insert("limit".into(), json!(42));
|
||||
assert!(require_u32(&p, "limit").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! JSON-RPC / CLI schemas for the webview_apis bridge.
|
||||
//!
|
||||
//! Each controller is a thin proxy: read typed params out of the
|
||||
//! incoming JSON, call [`super::client::request`] with the matching
|
||||
//! bridge method name, return the decoded response.
|
||||
|
||||
use crate::core::all::RegisteredController;
|
||||
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
|
||||
use crate::openhuman::webview_apis::rpc;
|
||||
|
||||
// ── registration ────────────────────────────────────────────────────────
|
||||
|
||||
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
vec![
|
||||
schemas("gmail_list_labels"),
|
||||
schemas("gmail_list_messages"),
|
||||
schemas("gmail_search"),
|
||||
schemas("gmail_get_message"),
|
||||
schemas("gmail_send"),
|
||||
schemas("gmail_trash"),
|
||||
schemas("gmail_add_label"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
vec![
|
||||
RegisteredController {
|
||||
schema: schemas("gmail_list_labels"),
|
||||
handler: rpc::handle_gmail_list_labels,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("gmail_list_messages"),
|
||||
handler: rpc::handle_gmail_list_messages,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("gmail_search"),
|
||||
handler: rpc::handle_gmail_search,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("gmail_get_message"),
|
||||
handler: rpc::handle_gmail_get_message,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("gmail_send"),
|
||||
handler: rpc::handle_gmail_send,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("gmail_trash"),
|
||||
handler: rpc::handle_gmail_trash,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: schemas("gmail_add_label"),
|
||||
handler: rpc::handle_gmail_add_label,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn schemas(function: &str) -> ControllerSchema {
|
||||
let account = FieldSchema {
|
||||
name: "account_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Webview account id (passed to webview_account_open). Disambiguates multi-account setups.",
|
||||
required: true,
|
||||
};
|
||||
let message_id = |c: &'static str| FieldSchema {
|
||||
name: "message_id",
|
||||
ty: TypeSchema::String,
|
||||
comment: c,
|
||||
required: true,
|
||||
};
|
||||
let messages_out = FieldSchema {
|
||||
name: "messages",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("GmailMessage"))),
|
||||
comment: "Matching Gmail messages.",
|
||||
required: true,
|
||||
};
|
||||
match function {
|
||||
"gmail_list_labels" => ControllerSchema {
|
||||
namespace: "webview_apis",
|
||||
function: "gmail_list_labels",
|
||||
description: "List Gmail labels (system + user) visible in the sidebar.",
|
||||
inputs: vec![account],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "labels",
|
||||
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("GmailLabel"))),
|
||||
comment: "Labels scraped from the live webview via CDP DOM snapshot.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"gmail_list_messages" => ControllerSchema {
|
||||
namespace: "webview_apis",
|
||||
function: "gmail_list_messages",
|
||||
description: "List recent Gmail messages (optionally filtered by label).",
|
||||
inputs: vec![
|
||||
account,
|
||||
FieldSchema {
|
||||
name: "limit",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Maximum number of messages.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "label",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Label id (INBOX, STARRED, …). None = current view.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![messages_out],
|
||||
},
|
||||
"gmail_search" => ControllerSchema {
|
||||
namespace: "webview_apis",
|
||||
function: "gmail_search",
|
||||
description: "Run a Gmail search query (same syntax as the web UI).",
|
||||
inputs: vec![
|
||||
account,
|
||||
FieldSchema {
|
||||
name: "query",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Gmail search expression, e.g. 'from:x is:unread'.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "limit",
|
||||
ty: TypeSchema::U64,
|
||||
comment: "Maximum number of results.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![messages_out],
|
||||
},
|
||||
"gmail_get_message" => ControllerSchema {
|
||||
namespace: "webview_apis",
|
||||
function: "gmail_get_message",
|
||||
description: "Fetch a single Gmail message by id.",
|
||||
inputs: vec![account, message_id("Gmail message id.")],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "message",
|
||||
ty: TypeSchema::Ref("GmailMessage"),
|
||||
comment: "The requested Gmail message.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"gmail_send" => ControllerSchema {
|
||||
namespace: "webview_apis",
|
||||
function: "gmail_send",
|
||||
description: "Send a new email via the logged-in Gmail webview.",
|
||||
inputs: vec![
|
||||
account,
|
||||
FieldSchema {
|
||||
name: "request",
|
||||
ty: TypeSchema::Ref("GmailSendRequest"),
|
||||
comment: "Send payload (to/cc/bcc/subject/body).",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "ack",
|
||||
ty: TypeSchema::Ref("SendAck"),
|
||||
comment: "Send acknowledgement.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"gmail_trash" => ControllerSchema {
|
||||
namespace: "webview_apis",
|
||||
function: "gmail_trash",
|
||||
description: "Move a Gmail message to Trash.",
|
||||
inputs: vec![account, message_id("Gmail message id to trash.")],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "ack",
|
||||
ty: TypeSchema::Ref("Ack"),
|
||||
comment: "Op acknowledgement.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"gmail_add_label" => ControllerSchema {
|
||||
namespace: "webview_apis",
|
||||
function: "gmail_add_label",
|
||||
description: "Add a label to a Gmail message.",
|
||||
inputs: vec![
|
||||
account,
|
||||
message_id("Gmail message id to label."),
|
||||
FieldSchema {
|
||||
name: "label",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Label name to add.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "ack",
|
||||
ty: TypeSchema::Ref("Ack"),
|
||||
comment: "Op acknowledgement.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
_other => ControllerSchema {
|
||||
namespace: "webview_apis",
|
||||
function: "unknown",
|
||||
description: "Unknown webview_apis controller function.",
|
||||
inputs: vec![],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "error",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Lookup error.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Handler bodies live in `rpc.rs` per project convention —
|
||||
// `schemas.rs` is registry-only.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn controller_list_covers_every_op() {
|
||||
let fns: Vec<_> = all_controller_schemas()
|
||||
.into_iter()
|
||||
.map(|s| s.function)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
fns,
|
||||
vec![
|
||||
"gmail_list_labels",
|
||||
"gmail_list_messages",
|
||||
"gmail_search",
|
||||
"gmail_get_message",
|
||||
"gmail_send",
|
||||
"gmail_trash",
|
||||
"gmail_add_label"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_schema_declares_namespace_webview_apis() {
|
||||
for s in all_controller_schemas() {
|
||||
assert_eq!(s.namespace, "webview_apis", "op {} wrong ns", s.function);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_registered_controllers_has_handler_per_schema() {
|
||||
assert_eq!(all_registered_controllers().len(), 7);
|
||||
}
|
||||
|
||||
// Param-helper coverage moved with the helpers into `rpc.rs` —
|
||||
// see the tests there for `require_string` / `require_u32`.
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Core-side mirror of the Gmail shapes returned by the bridge.
|
||||
//!
|
||||
//! These must stay wire-compatible with
|
||||
//! `app/src-tauri/src/gmail/types.rs`. Kept as plain types here —
|
||||
//! there's no domain logic attached yet, and the controller schemas
|
||||
//! describe them via `TypeSchema::Object { … }` / `TypeSchema::Ref(…)`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GmailLabel {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub unread: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GmailMessage {
|
||||
pub id: String,
|
||||
pub thread_id: Option<String>,
|
||||
pub from: Option<String>,
|
||||
#[serde(default)]
|
||||
pub to: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cc: Vec<String>,
|
||||
pub subject: Option<String>,
|
||||
pub snippet: Option<String>,
|
||||
pub body: Option<String>,
|
||||
pub date_ms: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub labels: Vec<String>,
|
||||
pub unread: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
|
||||
pub struct GmailSendRequest {
|
||||
pub to: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cc: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub bcc: Vec<String>,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SendAck {
|
||||
pub message_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Ack {
|
||||
pub status: String,
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! End-to-end test for the webview_apis bridge.
|
||||
//!
|
||||
//! Proves the full chain without the Tauri shell:
|
||||
//!
|
||||
//! ```text
|
||||
//! client::request ← core-side code we ship
|
||||
//! → ws://127.0.0.1:$OPENHUMAN_WEBVIEW_APIS_PORT
|
||||
//! → mock WS server (this test) ← stands in for Tauri
|
||||
//! → JSON response
|
||||
//! → decoded back into typed GmailLabel Vec
|
||||
//! ```
|
||||
//!
|
||||
//! Tests are serial because they all mutate the `OPENHUMAN_WEBVIEW_APIS_PORT`
|
||||
//! env var and share the lazy global `CLIENT` inside
|
||||
//! `openhuman_core::openhuman::webview_apis::client`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use openhuman_core::openhuman::webview_apis::{client, types::GmailLabel};
|
||||
|
||||
/// Serialize the port-mutation so two tests don't race on the env var.
|
||||
///
|
||||
/// The client caches its connection in a process-global `OnceLock`, so
|
||||
/// once a test picks up a port the others must use the same one for
|
||||
/// the rest of the process. In practice this means we start ONE mock
|
||||
/// server and funnel every test through it.
|
||||
static TEST_SERVER: once_cell::sync::Lazy<Mutex<Option<u16>>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(None));
|
||||
|
||||
async fn ensure_mock_server() -> u16 {
|
||||
let mut guard = TEST_SERVER.lock().await;
|
||||
if let Some(port) = *guard {
|
||||
return port;
|
||||
}
|
||||
let listener = TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
|
||||
.await
|
||||
.expect("bind");
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::env::set_var("OPENHUMAN_WEBVIEW_APIS_PORT", port.to_string());
|
||||
*guard = Some(port);
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, _peer) = match listener.accept().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let ws = match tokio_tungstenite::accept_async(stream).await {
|
||||
Ok(w) => w,
|
||||
Err(_) => return,
|
||||
};
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
while let Some(Ok(Message::Text(text))) = stream.next().await {
|
||||
let req: Value = serde_json::from_str(&text).unwrap();
|
||||
let id = req["id"].as_str().unwrap().to_string();
|
||||
let method = req["method"].as_str().unwrap().to_string();
|
||||
let resp = match method.as_str() {
|
||||
"gmail.list_labels" => json!({
|
||||
"kind": "response",
|
||||
"id": id,
|
||||
"ok": true,
|
||||
"result": [
|
||||
{"id": "INBOX", "name": "Inbox", "kind": "system", "unread": 3},
|
||||
{"id": "Receipts", "name": "Receipts", "kind": "user", "unread": null}
|
||||
],
|
||||
}),
|
||||
"gmail.trash" => json!({
|
||||
"kind": "response",
|
||||
"id": id,
|
||||
"ok": false,
|
||||
"error": "simulated failure from mock bridge",
|
||||
}),
|
||||
_ => json!({
|
||||
"kind": "response",
|
||||
"id": id,
|
||||
"ok": false,
|
||||
"error": format!("mock bridge: unhandled method '{method}'"),
|
||||
}),
|
||||
};
|
||||
if sink.send(Message::Text(resp.to_string())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn request_round_trips_list_labels_through_mock_server() {
|
||||
let _port = ensure_mock_server().await;
|
||||
let labels: Vec<GmailLabel> = client::request(
|
||||
"gmail.list_labels",
|
||||
serde_json::from_value(json!({"account_id": "gmail"})).unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("mock bridge call");
|
||||
assert_eq!(labels.len(), 2);
|
||||
assert_eq!(labels[0].id, "INBOX");
|
||||
assert_eq!(labels[0].unread, Some(3));
|
||||
assert_eq!(labels[1].kind, "user");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn request_surfaces_bridge_error_verbatim() {
|
||||
let _port = ensure_mock_server().await;
|
||||
let err: Result<Vec<GmailLabel>, String> = client::request(
|
||||
"gmail.trash",
|
||||
serde_json::from_value(json!({"account_id": "gmail", "message_id": "m1"})).unwrap(),
|
||||
)
|
||||
.await;
|
||||
let e = err.expect_err("expected bridge-side error");
|
||||
assert!(
|
||||
e.contains("simulated failure from mock bridge"),
|
||||
"unexpected error: {e}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user