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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src-tauri/Cargo.lock

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

6 changes: 6 additions & 0 deletions app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ tokio-tungstenite = { version = "0.24", default-features = false, features = ["c
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }

reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# Tauri's vendored dev-server proxy (see `vendor/tauri-cef/.../protocol/tauri.rs`)
# builds a reqwest 0.13 client that requires a process-wide rustls
# `CryptoProvider`. Without one, `ClientBuilder::build()` panics with
# "No provider set" the first time `tauri dev` proxies a request. We install
# the ring provider at startup in `lib.rs::run()`.
rustls = { version = "0.23", default-features = false, features = ["ring"] }
semver = "1"
# Auto-update archive extraction. GitHub release assets ship as
# `openhuman-core-<version>-<triple>.tar.gz` on Unix and `…<triple>.zip`
Expand Down
15 changes: 14 additions & 1 deletion app/src-tauri/permissions/allow-core-process.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
[[permission]]
identifier = "allow-core-process"
description = "Core RPC URL, sidecar restart, dictation hotkey, and webview-account commands"
description = "Core RPC URL, sidecar restart, dictation hotkey, webview-account, and gmail-CDP commands"

[permission.commands]
allow = [
"core_rpc_url",
"restart_core_process",
"spawn_welcome_agent",
"service_install_direct",
"service_start_direct",
"service_stop_direct",
Expand All @@ -25,5 +26,17 @@ allow = [
"screen_share_begin_session",
"screen_share_thumbnail",
"screen_share_finalize_session",
# Gmail-CDP surface (see app/src-tauri/src/gmail/). Drives the
# logged-in Gmail webview through DOMSnapshot + Input events. Used
# by onboarding's LinkedIn-enrichment pipeline today and by future
# agent tools.
"gmail_list_labels",
"gmail_list_messages",
"gmail_search",
"gmail_get_message",
"gmail_send",
"gmail_trash",
"gmail_add_label",
"gmail_find_linkedin_profile_url",
]
deny = []
236 changes: 236 additions & 0 deletions app/src-tauri/src/cdp/input.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
//! Thin helpers around `Input.dispatchMouseEvent` and
//! `Input.dispatchKeyEvent` so providers can drive web UIs without
//! touching the page's JavaScript.
//!
//! All coordinates are CSS pixels relative to the viewport — the same
//! frame `DOMSnapshot.captureSnapshot(includeDOMRects=true)` returns
//! bounding rects in. Callers typically pair these with
//! [`crate::cdp::Snapshot::rect`] to find the click target.
//!
//! Everything here is CEF-only — CDP requires a remote-debugging port,
//! which wry doesn't expose.
//!
//! # Cookbook
//!
//! ```ignore
//! let snap = Snapshot::capture_with_rects(&mut cdp, &session).await?;
//! let idx = snap.find_descendant(0, |s, i| s.attr(i, "aria-label") == Some("Search mail"))
//! .ok_or("search box not found")?;
//! let rect = snap.rect(idx).ok_or("search box has no layout rect")?;
//! let (cx, cy) = rect.center();
//! input::click(&mut cdp, &session, cx, cy).await?;
//! input::type_text(&mut cdp, &session, "from:linkedin.com").await?;
//! input::press_key(&mut cdp, &session, Key::Enter).await?;
//! ```

use serde_json::{json, Value};

use super::CdpConn;

#[allow(dead_code)] // helper is used by currently gated input paths.
#[cfg(target_os = "macos")]
const SELECT_ALL_MODIFIER: u32 = 4;
#[allow(dead_code)] // helper is used by currently gated input paths.
#[cfg(not(target_os = "macos"))]
const SELECT_ALL_MODIFIER: u32 = 2;

/// Names recognised by `Input.dispatchKeyEvent`'s `key` field. We
/// hand-pick the ones Gmail's keyboard handlers care about so callers
/// can use a typed value rather than stringly-typed literals scattered
/// across providers.
#[allow(dead_code)] // variants reserved for upcoming providers / write ops.
#[derive(Debug, Clone, Copy)]
pub enum Key {
Enter,
Escape,
Tab,
Backspace,
ArrowDown,
ArrowUp,
}

impl Key {
/// `(key, code, windowsVirtualKeyCode)` triple. Gmail's listeners
/// branch on different fields depending on browser; we set all three
/// to maximise compatibility.
fn cdp_fields(self) -> (&'static str, &'static str, u32) {
match self {
Key::Enter => ("Enter", "Enter", 13),
Key::Escape => ("Escape", "Escape", 27),
Key::Tab => ("Tab", "Tab", 9),
Key::Backspace => ("Backspace", "Backspace", 8),
Key::ArrowDown => ("ArrowDown", "ArrowDown", 40),
Key::ArrowUp => ("ArrowUp", "ArrowUp", 38),
}
}
}

/// Click at `(x, y)` — left button, no modifiers, single click.
/// Issues mouseMoved → mousePressed → mouseReleased so hover handlers
/// (Gmail's search-box has one) fire correctly before the click.
pub async fn click(cdp: &mut CdpConn, session: &str, x: f64, y: f64) -> Result<(), String> {
log::debug!("[cdp::input] click session={session} x={x:.1} y={y:.1}");
let _ = mouse_event(cdp, session, "mouseMoved", x, y, 0).await?;
let _ = mouse_event(cdp, session, "mousePressed", x, y, 1).await?;
let _ = mouse_event(cdp, session, "mouseReleased", x, y, 1).await?;
log::debug!("[cdp::input] click complete session={session}");
Ok(())
}

async fn mouse_event(
cdp: &mut CdpConn,
session: &str,
kind: &str,
x: f64,
y: f64,
click_count: u32,
) -> Result<Value, String> {
log::debug!(
"[cdp::input] mouse_event session={session} kind={kind} x={x:.1} y={y:.1} clicks={click_count}"
);
cdp.call(
"Input.dispatchMouseEvent",
json!({
"type": kind,
"x": x,
"y": y,
"button": "left",
"buttons": if kind == "mousePressed" { 1 } else { 0 },
"clickCount": click_count,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchMouseEvent {kind}: {e}"))
}

/// Type a literal string by dispatching one `keyDown`/`char`/`keyUp`
/// triple per character. CDP's `dispatchKeyEvent type=char` is what
/// actually inserts text into focused editable fields — `keyDown`
/// alone leaves the input empty for most letters. The `keyDown`
/// + `keyUp` pair is still needed so listeners (autocomplete,
/// keystroke counters) see a normal keystroke.
pub async fn type_text(cdp: &mut CdpConn, session: &str, text: &str) -> Result<(), String> {
log::debug!(
"[cdp::input] type_text session={session} chars={}",
text.chars().count()
);
for ch in text.chars() {
let s = ch.to_string();
// keyDown — Gmail's command/keyboard router observes these.
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "keyDown",
"text": s,
"unmodifiedText": s,
"key": s,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent keyDown {ch:?}: {e}"))?;
// char — actual text insertion into the focused editable.
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "char",
"text": s,
"unmodifiedText": s,
"key": s,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent char {ch:?}: {e}"))?;
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "keyUp",
"text": s,
"unmodifiedText": s,
"key": s,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent keyUp {ch:?}: {e}"))?;
}
log::debug!("[cdp::input] type_text complete session={session}");
Ok(())
}

/// Press a non-character key (Enter, Esc, …). Sends `rawKeyDown` →
/// `keyUp`; no `char` because non-printables don't insert text.
pub async fn press_key(cdp: &mut CdpConn, session: &str, key: Key) -> Result<(), String> {
let (key_name, code, vk) = key.cdp_fields();
log::debug!("[cdp::input] press_key session={session} key={key_name}");
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "rawKeyDown",
"key": key_name,
"code": code,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent rawKeyDown {key_name}: {e}"))?;
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "keyUp",
"key": key_name,
"code": code,
"windowsVirtualKeyCode": vk,
"nativeVirtualKeyCode": vk,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent keyUp {key_name}: {e}"))?;
log::debug!("[cdp::input] press_key complete session={session} key={key_name}");
Ok(())
}

/// Dispatch Cmd/Ctrl+A to select-all in the focused contenteditable / input.
/// Useful when the search box already has a previous query in it that
/// we need to overwrite — Gmail keeps the last query rendered in the
/// search input so a fresh visit sees stale text.
pub async fn select_all_in_focused(cdp: &mut CdpConn, session: &str) -> Result<(), String> {
log::debug!(
"[cdp::input] select_all_in_focused session={session} modifier={SELECT_ALL_MODIFIER}"
);
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "rawKeyDown",
"key": "a",
"code": "KeyA",
"windowsVirtualKeyCode": 65,
"nativeVirtualKeyCode": 65,
"modifiers": SELECT_ALL_MODIFIER,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent select-all keyDown: {e}"))?;
cdp.call(
"Input.dispatchKeyEvent",
json!({
"type": "keyUp",
"key": "a",
"code": "KeyA",
"windowsVirtualKeyCode": 65,
"nativeVirtualKeyCode": 65,
"modifiers": SELECT_ALL_MODIFIER,
}),
Some(session),
)
.await
.map_err(|e| format!("Input.dispatchKeyEvent select-all keyUp: {e}"))?;
log::debug!("[cdp::input] select_all_in_focused complete session={session}");
Ok(())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Comment on lines +71 to +236

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add unit tests for new CDP input primitives before merge.

This introduces core input behavior but does not include matching Rust unit coverage in the changes shown. Please add tests that assert event order and payload fields for click, type_text, press_key, and select_all_in_focused (including platform modifier behavior).

As per coding guidelines, "Ship unit tests and coverage for behavior you are adding or changing before building additional features on top."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src-tauri/src/cdp/input.rs` around lines 71 - 236, Add unit tests that
mock or stub CdpConn to capture and assert the sequence and payloads of CDP
calls for click (mouseMoved → mousePressed → mouseReleased via mouse_event),
type_text (per-character keyDown → char → keyUp with correct
"text"/"key"/"unmodifiedText"), press_key (rawKeyDown → keyUp with cdp_fields
values), and select_all_in_focused (rawKeyDown/keyUp including
SELECT_ALL_MODIFIER); ensure tests validate order,
"type"/"button"/"clickCount"/"x"/"y"/"key"/"code"/"windowsVirtualKeyCode"/"nativeVirtualKeyCode"/"modifiers"
fields and include platform-specific modifier behavior for SELECT_ALL_MODIFIER
by parameterizing or simulating platforms in the mock CdpConn used by click,
mouse_event, type_text, press_key, and select_all_in_focused.

13 changes: 9 additions & 4 deletions app/src-tauri/src/cdp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

pub mod conn;
pub mod emulation;
pub mod input;
pub mod session;
pub mod snapshot;
pub mod target;
Expand All @@ -19,12 +20,16 @@ pub use emulation::{set_user_agent_override, UaSpec};
pub use session::{
placeholder_marker, placeholder_url, spawn_session, target_url_fragment, SpawnedSession,
};
pub use snapshot::Snapshot;
#[allow(unused_imports)] // `Rect` re-export consumed once turn 2 lands; keep stable.
pub use snapshot::{Rect, Snapshot};
pub use target::{
browser_ws_url, connect_and_attach_matching, detach_session, find_page_target_where,
};

/// Remote debugging host — matches `--remote-debugging-port=9222` in
/// `lib.rs`. Kept as constants so scanners and the session opener agree.
/// Remote debugging host — matches `--remote-debugging-port=19222` in
/// `lib.rs`. Kept as constants so scanners and the session opener
/// agree. Port was 9222 originally but collided with ollama's
/// `127.0.0.1:9222` listener (silent CDP-attach failure → blank
/// child webviews). If you change either constant, update both.
pub const CDP_HOST: &str = "127.0.0.1";
pub const CDP_PORT: u16 = 9222;
pub const CDP_PORT: u16 = 19222;
Comment on lines +29 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check that whatsapp_scanner uses CDP_PORT from the cdp module, not a hardcoded value
rg -n 'CDP_PORT|19222|9222' --type rust app/src-tauri/src/whatsapp_scanner/

Repository: tinyhumansai/openhuman

Length of output: 703


Consolidate CDP_PORT to avoid duplicate constant definitions.

The whatsapp_scanner/mod.rs file defines its own CDP_PORT constant (line 39) rather than importing from cdp::CDP_PORT. While the comment acknowledges it should match cdp::CDP_PORT, the code doesn't enforce this. The cdp/mod.rs doc comment explicitly states constants are "kept as constants so scanners and the session opener agree" and warns to "update both" if changed, but duplicate definitions create a desync risk.

Import CDP_PORT from the cdp module in whatsapp_scanner/mod.rs instead of defining a local duplicate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src-tauri/src/cdp/mod.rs` around lines 29 - 35, The whatsapp_scanner
module defines its own CDP_PORT constant which can drift from the canonical
cdp::CDP_PORT; remove the local CDP_PORT declaration in whatsapp_scanner/mod.rs
and import the canonical constant from the cdp module (e.g., use
crate::cdp::CDP_PORT or equivalent) wherever the local constant was referenced
so the scanner and session opener share the same single source of truth.

Loading
Loading