Files
openhuman/app/src-tauri/src/webview_accounts/mod.rs
T

3373 lines
132 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Franz-style embedded webview accounts.
//!
//! Hosts third-party web apps (WhatsApp Web, Slack, …) as a child Tauri
//! `Webview` positioned inside the main React window at a rect chosen by the
//! UI. A small per-provider "recipe" JS file is injected via
//! `initialization_script` to scrape the DOM and pipe state back to Rust as
//! `webview_recipe_event` invocations. Rust forwards each event up to the
//! React UI as a `webview:event` Tauri event; React is responsible for
//! persisting interesting payloads to memory via the existing core RPC.
//!
//! Architecture:
//! React → invoke('webview_account_open', …) → spawn child Webview
//! React → invoke('webview_account_bounds', …) → reposition / resize
//! recipe → invoke('webview_recipe_event', …) → emit('webview:event', …)
//!
//! Per-account session isolation: each account gets its own
//! `data_directory` under `{app_local_data_dir}/webview_accounts/{id}` so
//! cookies and storage don't bleed between accounts (best-effort on
//! WKWebView — see Tauri docs on `data_store_identifier` for the macOS path).
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Mutex;
#[cfg(target_os = "linux")]
use std::sync::{mpsc::sync_channel, OnceLock};
use std::time::{Duration, Instant};
use chrono::{TimeZone, Utc};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tauri::{
webview::NewWindowResponse, AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, Runtime,
Url, WebviewBuilder, WebviewUrl,
};
#[cfg(windows)]
use tauri_plugin_notification::NotificationExt;
// `ImplBrowser` exposes `Browser::identifier()` — bring the trait into scope
// so the `with_webview` callback can read the CEF browser id.
use cef::ImplBrowser;
use crate::cdp;
const RUNTIME_JS: &str = include_str!("runtime.js");
const LINKEDIN_RECIPE_JS: &str = include_str!("../../recipes/linkedin/recipe.js");
const GOOGLE_MEET_RECIPE_JS: &str = include_str!("../../recipes/google-meet/recipe.js");
/// Registered providers and their service URLs. Add a new arm here plus a
/// recipe.js file under `recipes/<id>/` to support another provider.
fn provider_url(provider: &str) -> Option<&'static str> {
match provider {
"whatsapp" => Some("https://web.whatsapp.com/"),
"wechat" => Some("https://web.wechat.com/"),
"telegram" => Some("https://web.telegram.org/k/"),
"linkedin" => Some("https://www.linkedin.com/messaging/"),
"slack" => Some("https://app.slack.com/client/"),
"discord" => Some("https://discord.com/channels/@me"),
"gmail" => Some("https://mail.google.com/mail/u/0/"),
"outlook" => Some("https://outlook.live.com/mail/"),
"instagram" => Some("https://www.instagram.com/direct/inbox/"),
"twitter" => Some("https://x.com/messages/"),
"google-meet" => Some("https://meet.google.com/"),
"zoom" => Some("https://zoom.us/"),
"browserscan" => Some("https://www.browserscan.net/bot-detection"),
_ => None,
}
}
/// Returns the injected recipe.js for providers that still rely on the
/// JS-bridge ingest path. Migrated providers (whatsapp, wechat, telegram,
/// slack, discord, browserscan) return `None` — their scraping runs natively
/// via CDP in the per-provider scanner modules.
fn provider_recipe_js(provider: &str) -> Option<&'static str> {
match provider {
"linkedin" => Some(LINKEDIN_RECIPE_JS),
"google-meet" => Some(GOOGLE_MEET_RECIPE_JS),
_ => None,
}
}
/// Whether this provider is supported at all. Derived from
/// `provider_url` so there's one canonical list — new providers added
/// to the `provider_url` match automatically become "supported" here.
fn provider_is_supported(provider: &str) -> bool {
provider_url(provider).is_some()
}
/// Host suffixes the embedded webview is allowed to navigate within. Any
/// navigation to a host outside this set is cancelled and opened in the
/// user's default browser instead. Meet includes Google's auth and
/// static asset hosts so the OAuth redirect loop works; Discord includes
/// its CDN subdomains for the same reason.
fn provider_allowed_hosts(provider: &str) -> &'static [&'static str] {
match provider {
"whatsapp" => &["whatsapp.com", "whatsapp.net", "wa.me"],
"wechat" => &[
"wechat.com",
"wx.qq.com",
"weixin.qq.com",
"login.weixin.qq.com",
],
"telegram" => &["telegram.org", "t.me"],
"linkedin" => &[
"linkedin.com",
"licdn.com",
"accounts.google.com",
"accounts.googleusercontent.com",
"ssl.gstatic.com",
"fonts.gstatic.com",
"lh3.googleusercontent.com",
"oauth2.googleapis.com",
"www.googleapis.com",
],
"slack" => &[
"slack.com",
"slack-edge.com",
"slackb.com",
"accounts.google.com",
"accounts.googleusercontent.com",
"ssl.gstatic.com",
"fonts.gstatic.com",
"lh3.googleusercontent.com",
"oauth2.googleapis.com",
"www.googleapis.com",
],
"discord" => &[
"discord.com",
"discord.gg",
"discordapp.com",
"discordapp.net",
],
"gmail" => &[
"google.com",
"googleusercontent.com",
"gstatic.com",
"googleapis.com",
"accounts.google.com",
"accounts.googleusercontent.com",
],
"outlook" => &[
"outlook.live.com",
"outlook.office.com",
"outlook.office365.com",
"live.com",
"login.live.com",
"microsoft.com",
"microsoftonline.com",
"office.com",
"office365.com",
"msftauth.net",
"msauth.net",
"office.net",
],
"instagram" => &[
"instagram.com",
"cdninstagram.com",
"fbcdn.net",
"facebook.com",
],
"twitter" => &[
"x.com",
"twitter.com",
"twimg.com",
"t.co",
// X offers "Continue with Google" — keep the OAuth
// account-chooser popup inside this account's CEF profile.
"accounts.google.com",
"accounts.googleusercontent.com",
"ssl.gstatic.com",
"fonts.gstatic.com",
"lh3.googleusercontent.com",
"oauth2.googleapis.com",
"www.googleapis.com",
],
"google-meet" => &[
"google.com",
"googleusercontent.com",
"gstatic.com",
"googleapis.com",
],
"zoom" => &[
"zoom.us",
"zoom.com",
"zoomgov.com",
"zdassets.com",
"accounts.google.com",
"accounts.googleusercontent.com",
"ssl.gstatic.com",
"fonts.gstatic.com",
"lh3.googleusercontent.com",
"oauth2.googleapis.com",
"www.googleapis.com",
],
"browserscan" => &["browserscan.net"],
_ => &[],
}
}
/// Rewrite a provider-specific native-app deep link (e.g. Zoom's
/// `zoomus://zoom.us/join?...`) into a web-client URL so the meeting stays
/// inside the embedded webview instead of failing with
/// ERR_UNKNOWN_URL_SCHEME (CEF has no handler for these schemes).
///
/// Returns `Some(rewritten)` when the provider claims the scheme and a
/// valid web-client URL can be built; `None` otherwise (caller should
/// leave the navigation alone).
fn rewrite_provider_deep_link(provider: &str, url: &Url) -> Option<Url> {
if provider != "zoom" {
return None;
}
if !matches!(url.scheme(), "zoomus" | "zoommtg") {
return None;
}
// Pull the meeting id out of the query string. Zoom uses `confno` on
// both `action=join` (joining) and `action=start` (hosting) flows.
let confno = url
.query_pairs()
.find(|(k, _)| k == "confno")
.map(|(_, v)| v.into_owned());
let pwd = url
.query_pairs()
.find(|(k, _)| k == "pwd" || k == "tk")
.map(|(_, v)| v.into_owned());
// Build the rewritten URL via `Url` so `confno` and `pwd` are
// percent-encoded — inbound Zoom tokens can contain reserved chars
// (`&`, `#`, `%`, `+`, …) that would corrupt a hand-rolled
// `format!(…)` string and silently break the join/host flow.
match confno {
Some(id) if !id.is_empty() => {
// Base without trailing slash; `path_segments_mut().push(id)`
// appends `/id` cleanly. A trailing `/` on the base would yield
// `/wc/join//id` (empty segment preserved by the Url spec).
let mut rewritten = Url::parse("https://app.zoom.us/wc/join").ok()?;
rewritten.path_segments_mut().ok()?.push(&id);
if let Some(p) = pwd.filter(|p| !p.is_empty()) {
rewritten.query_pairs_mut().append_pair("pwd", &p);
}
Some(rewritten)
}
_ => Url::parse("https://app.zoom.us/wc/home").ok(),
}
}
/// `true` if `url` is considered in-app for `provider`. Non-HTTP(S)
/// schemes (`about:blank`, `data:`, `blob:`) have no host and are always
/// allowed so the webview's own internal navigations keep working.
/// Unknown providers are also permissive — better to accidentally keep a
/// link in-app than to leak it to the system browser.
fn url_is_internal(provider: &str, url: &Url) -> bool {
let Some(host) = url.host_str() else {
return true;
};
// Google services route the post-2FA `SetSID` cookie-setting hop
// through `accounts.youtube.com` and ccTLD `accounts.google.<rest>`
// hosts that aren't covered by the suffix-based allowlist. Without
// this, the auth chain breaks mid-flight and leaks to the system
// browser (#1053 sign-in leak surfaced in dev:app log line:
// "external navigation https://accounts.youtube.com/accounts/SetSID?...
// → system browser"). Whitelist the full Google SSO host family for
// any provider that uses Google identity.
if (provider == "gmail" || provider_supports_google_sso(provider)) && is_google_sso_host(host) {
return true;
}
let allowed = provider_allowed_hosts(provider);
if allowed.is_empty() {
return true;
}
allowed
.iter()
.any(|suffix| host == *suffix || host.ends_with(&format!(".{}", suffix)))
}
/// `true` if the provider needs `window.open(url)` to return a live
/// window-handle (i.e. the calling site reads the return value and aborts
/// on falsey). Slack Huddles go through `openManagedChildWindow` which
/// calls `window.open("about:blank", …)` and then programmatically
/// navigates the returned popup to the huddle UI. Denying the popup
/// makes the huddle call fail silently with a `beacon/error`. For these
/// cases we allow the default popup so CEF spawns an in-app child window
/// and returns a real handle to the caller.
///
/// Match is intentionally narrow — only the popup URLs the provider
/// actually needs in-app pass. Cmd/Ctrl-click and `target="_blank"`
/// on ordinary links (which carry a concrete URL) still route out to
/// the user's default browser.
fn popup_should_stay_in_app(provider: &str, url: &Url) -> bool {
match provider {
"slack" => {
// Slack's huddle flow opens `about:blank` first, then navigates
// the popup to the huddle URL — at popup-creation time there is
// no host yet. Also accept same-origin slack.com hosts so direct
// `window.open("https://app.slack.com/...")` calls stay in-app.
if url.scheme() == "about" {
return true;
}
match url.host_str() {
Some(host) => host == "app.slack.com" || host.ends_with(".slack.com"),
None => false,
}
}
"zoom" => {
// Zoom's "Join from browser" / WebClient launch can go through a
// `window.open("https://app.zoom.us/wc/...")` popup instead of an
// in-page navigation. Keep those (and any deep-link-rewritten
// popup targeting the same path) inside the embedded webview so
// the meeting doesn't pop out to the system browser.
match url.host_str() {
Some(host) => {
(host == "app.zoom.us" || host == "zoom.us") && url.path().starts_with("/wc/")
}
None => false,
}
}
// LinkedIn (#1021) and X/Twitter (#5009) render "Sign in with Google"
// as a Google Identity Services (GSI) popup that the GIS SDK drives via
// window.open() and completes by postMessage-ing the credential back to
// window.opener (the provider page). Every leg of that popup — the
// account chooser and the /o/oauth2 auth hop — MUST stay as a real
// in-app child window: routing it to the system browser leaves the
// embedded webview on a dead page, and navigating the parent to it
// destroys the opener so the credential can never post back. See
// `is_google_gsi_popup`.
"linkedin" | "twitter" => is_google_gsi_popup(url),
_ => false,
}
}
/// `true` for a Google Identity Services (GSI) **popup-mode** "Sign in with
/// Google" window — the opener-dependent flow the GIS SDK drives via
/// `window.open(...)` and completes by `postMessage`-ing the credential back to
/// `window.opener` (the provider page). Covers both the account-chooser
/// (`accounts.google.com/gsi/select`, #1021) and the `/o/oauth2/v2/auth` leg the
/// same popup navigates through (#5009).
///
/// Matched by Google SSO host (`is_google_sso_host`) AND either a `gsi` path
/// segment or one of the GIS-SDK popup-mode query markers. Deliberately narrow
/// — a redirect-mode Google sign-in (a real https `redirect_uri`, no GSI
/// markers) does NOT match, so it still replaces the parent. NOT a blanket
/// popup allow.
///
/// Keeping these popups in-app is what lets the postMessage handshake complete
/// inside the account's isolated CEF profile instead of leaking to the system
/// browser (#5009) or being dropped when the parent (the opener) is navigated
/// away (#5009 / #1021).
fn is_google_gsi_popup(url: &Url) -> bool {
if !url.host_str().is_some_and(is_google_sso_host) {
return false;
}
if url.path().to_ascii_lowercase().contains("gsi") {
return true;
}
// GIS-SDK popup-mode markers. These appear on the popup's auth URLs (incl.
// `/o/oauth2/v2/auth`) and never on a plain redirect-mode sign-in.
url.query_pairs().any(|(key, value)| {
let k = key.to_ascii_lowercase();
let v = value.to_ascii_lowercase();
((k == "ux_mode" || k == "display") && v == "popup")
|| k == "gsiwebsdk"
|| (k == "redirect_uri" && v == "gis_transform")
})
}
/// `true` if `scheme` is a known provider native-desktop-app deep-link
/// scheme. We suppress these instead of routing them to the system
/// browser because macOS hands them to the native provider app
/// (e.g. `slack://magic-login/<token>` signs the native Slack app into
/// the workspace, breaking embedded-webview isolation: the workspace's
/// session ends up inside the native client even though the user only
/// signed in via OpenHuman's embedded webview).
///
/// The HTTPS fallback in each provider's web flow handles sign-in
/// without the deep link, so suppression is safe — the page just
/// continues on the next link in the sequence.
///
/// Caller contract: only suppress when [`rewrite_provider_deep_link`]
/// has already returned `None` for the URL. Schemes we DO know how to
/// rewrite into a web-client URL (e.g. `zoomus://`) must take the
/// rewrite path first; those flows expect to stay in-app, not be
/// silently dropped.
fn is_provider_native_deep_link_scheme(scheme: &str) -> bool {
matches!(
scheme,
"slack" | "discord" | "tg" | "msteams" | "zoomus" | "zoommtg"
)
}
/// `true` if this provider lets users sign in with their Google
/// account from inside the embedded webview.
///
/// Slack workspaces commonly enable "Sign in with Google" SSO, so the
/// Google OAuth popup flow (`window.open("https://accounts.google.com/...")`)
/// must stay in the per-account CEF session — exactly the same way it
/// has to for Google Meet. Routing it to the system browser leaks the
/// auth cookie into the wrong jar and breaks sign-in (#1036).
///
/// Keep this list narrow: only providers that actually need to issue
/// `accounts.google.com` popups should be listed. Other providers
/// continue to fall through to the default popup-handling path.
fn provider_supports_google_sso(provider: &str) -> bool {
matches!(
provider,
"google-meet" | "slack" | "zoom" | "linkedin" | "gmail" | "twitter"
)
}
/// `true` if a popup request should be denied AND the parent webview
/// should be navigated to the popup URL instead.
///
/// Used for Google's "Sign in" / "Use another account" flow on embedded
/// providers that support Google SSO: clicking the link issues
/// `window.open("https://accounts.google.com/...")`. We can't route
/// that to the system browser (the auth cookie would land in the
/// wrong jar) and we don't want to let CEF spawn an unmanaged child
/// window (it has no host rect, so it renders blank/black). The safe
/// option is to deny the popup and replace the parent's URL so the
/// in-app webview finishes the auth flow inside the embedded session.
fn popup_should_navigate_parent(provider: &str, url: &Url) -> Option<Url> {
if !provider_supports_google_sso(provider) {
return None;
}
if url.scheme() == "about" {
return None;
}
// #5009: X/Twitter (and LinkedIn) sign in with Google via the GSI popup —
// an opener-dependent flow where the popup postMessages the credential back
// to `window.opener` (the provider page). Navigating the parent to any leg
// of that popup (chooser or the `/o/oauth2` hop) destroys the opener, so the
// sign-in never completes and the pane paints blank. Keep it in-app instead
// (`popup_should_stay_in_app` catches it for these providers). Redirect-mode
// Google sign-in (a real https `redirect_uri`, no GSI markers) is NOT a GSI
// popup, so it still falls through to the parent-navigation below.
if matches!(provider, "linkedin" | "twitter") && is_google_gsi_popup(url) {
return None;
}
if is_google_auth_popup(url) {
return Some(url.clone());
}
// Gmeet: "Start an instant meeting" / "New meeting" / clicking
// a meeting code link calls `window.open(meet.google.com/<roomid>)`
// to launch the room. Default popup handling would route the
// URL to the user's system browser, leaking the Meet session
// out of OpenHuman entirely. Deny the popup and navigate the
// embedded parent into the room URL instead — matches the
// user's expectation that the meeting stays in-app.
if provider == "google-meet" {
if let Some(host) = url.host_str() {
if host == "meet.google.com" {
return Some(url.clone());
}
}
}
None
}
/// `true` if `host` is a Google SSO / account-handoff host that may
/// participate in the OAuth flow for any Google service (Meet, Gmail,
/// Drive, etc.). Google rotates the post-2FA `SetSID` cookie-setting hop
/// across `accounts.google.<cctld>` and `accounts.youtube.com` (sic — the
/// YouTube subdomain is part of the Google identity infra), so a literal
/// `accounts.google.com` match misses real auth popups and leaks them to
/// the system browser.
///
/// Match family:
/// - `accounts.google.com`
/// - `accounts.google.<cctld>` — e.g. `accounts.google.co.in`, `accounts.google.co.uk`,
/// `accounts.google.de`
/// - `accounts.googleusercontent.com`
/// - `accounts.youtube.com` (post-2FA `SetSID` hop)
/// - `myaccount.google.com`
fn is_google_sso_host(host: &str) -> bool {
let host = host.to_ascii_lowercase();
if host == "accounts.google.com"
|| host == "accounts.googleusercontent.com"
|| host == "accounts.youtube.com"
|| host == "myaccount.google.com"
{
return true;
}
// ccTLD variants: `accounts.google.<cctld>`. We must reject phishing
// shapes like `accounts.google.com.evil` and `accounts.google.co.attacker`
// — the dots-only check we used previously accepted both because
// `com.evil` and `co.attacker` each have one dot. Anchor the suffix
// against a real ccTLD shape: either a single 2-letter cc tld
// (`accounts.google.de`, `accounts.google.fr`) OR a 2-label form
// `<sld>.<cc>` where sld ∈ {co, com, net, org} (`accounts.google.co.in`,
// `accounts.google.com.au`).
if let Some(rest) = host.strip_prefix("accounts.google.") {
let labels: Vec<&str> = rest.split('.').collect();
let is_cc = |s: &str| s.len() == 2 && s.chars().all(|c| c.is_ascii_alphabetic());
return match labels.as_slice() {
[tld] => is_cc(tld),
[sld, tld] => matches!(*sld, "co" | "com" | "net" | "org") && is_cc(tld),
_ => false,
};
}
false
}
fn is_google_auth_popup(url: &Url) -> bool {
let Some(host) = url.host_str() else {
return false;
};
if !is_google_sso_host(host) {
return false;
}
let path = url.path().to_ascii_lowercase();
if path.contains("signin")
|| path.contains("servicelogin")
|| path.contains("accountchooser")
|| path.contains("chooseaccount")
|| path.contains("setsid")
|| path.contains("oauth2")
{
return true;
}
url.query_pairs().any(|(key, value)| {
let k = key.to_ascii_lowercase();
let v = value.to_ascii_lowercase();
matches!(k.as_str(), "flowname" | "service" | "continue")
&& (v.contains("signin")
|| v.contains("servicelogin")
|| v.contains("accountchooser")
|| v.contains("chooseaccount")
|| v.contains("meet.google.com")
|| v.contains("mail.google.com")
|| v.contains("linkedin.com"))
})
}
/// `true` if a gmeet navigation lands on Google's Workspace marketing page
/// for Meet — the host bounce that fires when an unauthenticated webview hits
/// `meet.google.com`.
///
/// The `on_navigation` rewrite is scoped to this exact path family so we
/// don't hijack legitimate `workspace.google.com` pages a user might reach
/// from inside Meet (admin console links, Workspace Status, support pages,
/// etc.). Matches `workspace.google.com` (and any subdomain) AND a path
/// starting with `/products/meet` — empirically the only path Google's
/// edge bounces unauthenticated Meet GETs to (`/products/meet/` or
/// `/products/meet/<sub>`).
fn is_gmeet_marketing_redirect(host: &str, path: &str) -> bool {
let host = host.to_ascii_lowercase();
let host_matches = host == "workspace.google.com" || host.ends_with(".workspace.google.com");
if !host_matches {
return false;
}
let p = path.to_ascii_lowercase();
p == "/products/meet" || p == "/products/meet/" || p.starts_with("/products/meet/")
}
fn redact_navigation_url(url: &Url) -> String {
let mut safe = url.clone();
safe.set_query(None);
safe.set_fragment(None);
safe.to_string()
}
fn redact_native_deep_link_url(url: &Url) -> String {
format!("{}://<redacted>", url.scheme())
}
/// Unwrap provider-side "link safety" redirects so the system browser
/// lands on the real destination.
///
/// These wrappers (LinkedIn's `/safety/go/?url=…`, etc.) require the
/// user to be logged into the provider in the destination browser. In
/// our setup the session lives inside the embedded CEF webview's cookie
/// jar, not the user's default browser — opening the wrapper URL there
/// shows a broken safety page instead of completing the redirect.
/// Extract the `url` query param and return the resolved destination.
fn unwrap_provider_redirect(url: &Url) -> Option<Url> {
let host = url.host_str()?;
let path = url.path();
let matches_linkedin = (host == "www.linkedin.com" || host == "linkedin.com")
&& (path == "/safety/go/" || path == "/safety/go" || path == "/redir/redirect");
if !matches_linkedin {
return None;
}
let (_, raw) = url.query_pairs().find(|(k, _)| k == "url")?;
Url::parse(&raw).ok()
}
/// Fire-and-forget handoff to the OS default URL handler. Any error is
/// logged but not propagated — we've already cancelled the in-app
/// navigation so there's nowhere to surface a failure to.
///
/// On macOS we shell out to `/usr/bin/open` directly rather than via
/// `tauri_plugin_opener::open_url`: the plugin returned Ok but no browser
/// actually launched in the CEF runtime (suspected sandbox/launch-service
/// interaction with the `open` crate's detached spawn). The direct
/// Command call is equivalent to what a user would type in Terminal and
/// works reliably.
fn open_in_system_browser(url: &str) {
#[cfg(target_os = "macos")]
{
match std::process::Command::new("/usr/bin/open").arg(url).spawn() {
Ok(_) => log::info!("[webview-accounts] opened externally (macos open): {}", url),
Err(e) => log::warn!(
"[webview-accounts] /usr/bin/open {} failed: {} — falling back to opener plugin",
url,
e
),
}
}
#[cfg(not(target_os = "macos"))]
{
match tauri_plugin_opener::open_url(url, None::<&str>) {
Ok(()) => log::info!("[webview-accounts] opened externally: {}", url),
Err(e) => log::warn!("[webview-accounts] open_url({}) failed: {}", url, e),
}
}
}
fn payload_string(payload: &serde_json::Value, key: &str) -> Option<String> {
payload
.get(key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn payload_bool(payload: &serde_json::Value, key: &str) -> Option<bool> {
payload.get(key).and_then(|v| v.as_bool())
}
fn payload_i64(payload: &serde_json::Value, key: &str) -> Option<i64> {
payload.get(key).and_then(|v| v.as_i64())
}
fn first_message_field(payload: &serde_json::Value, key: &str) -> Option<String> {
payload
.get("messages")
.and_then(|v| v.as_array())
.and_then(|messages| messages.first())
.and_then(|message| message.get(key))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn event_timestamp_rfc3339(ts_ms: Option<i64>) -> String {
ts_ms
.and_then(|ts| Utc.timestamp_millis_opt(ts).single())
.unwrap_or_else(Utc::now)
.to_rfc3339()
}
fn normalize_provider_surfaces_event(args: &RecipeEventArgs) -> Option<serde_json::Value> {
if args.kind != "ingest" {
return None;
}
let entity_id = payload_string(&args.payload, "entity_id")
.or_else(|| payload_string(&args.payload, "threadId"))
.or_else(|| payload_string(&args.payload, "chatId"))
.or_else(|| payload_string(&args.payload, "snapshotKey"))
.unwrap_or_else(|| {
format!(
"{}:{}:{}",
args.provider,
args.account_id,
args.ts.unwrap_or_else(|| Utc::now().timestamp_millis())
)
});
let thread_id = payload_string(&args.payload, "threadId")
.or_else(|| payload_string(&args.payload, "chatId"))
.or_else(|| payload_string(&args.payload, "conversationId"));
let title = payload_string(&args.payload, "title")
.or_else(|| payload_string(&args.payload, "chatName"))
.or_else(|| payload_string(&args.payload, "channelName"));
let snippet = payload_string(&args.payload, "snippet")
.or_else(|| first_message_field(&args.payload, "body"));
let sender_name = payload_string(&args.payload, "senderName")
.or_else(|| first_message_field(&args.payload, "from"));
let sender_handle = payload_string(&args.payload, "senderHandle");
let deep_link = payload_string(&args.payload, "deepLink");
let unread = payload_i64(&args.payload, "unread").unwrap_or(0);
let requires_attention = payload_bool(&args.payload, "requires_attention")
.unwrap_or(unread > 0 || sender_name.is_some() || snippet.is_some());
Some(json!({
"provider": args.provider,
"account_id": args.account_id,
"event_kind": args.kind,
"entity_id": entity_id,
"thread_id": thread_id,
"title": title,
"snippet": snippet,
"sender_name": sender_name,
"sender_handle": sender_handle,
"timestamp": event_timestamp_rfc3339(args.ts),
"deep_link": deep_link,
"requires_attention": requires_attention,
"raw_payload": args.payload,
}))
}
async fn post_provider_surfaces_event(args: &RecipeEventArgs) -> Result<(), String> {
let Some(params) = normalize_provider_surfaces_event(args) else {
return Ok(());
};
let body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "openhuman.provider_surfaces_ingest_event",
"params": params,
});
let url = std::env::var("OPENHUMAN_CORE_RPC_URL")
.unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".to_string());
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| format!("http client: {e}"))?;
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("POST {url}: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("{status}: {body}"));
}
let v: serde_json::Value = resp.json().await.map_err(|e| format!("decode: {e}"))?;
if let Some(err) = v.get("error") {
return Err(format!("rpc error: {err}"));
}
Ok(())
}
/// Human-readable label used as the title prefix on native notifications
/// so users can tell which provider fired the ping. Matches the labels
/// in the frontend `PROVIDERS` registry.
pub fn provider_display_name(provider: &str) -> &'static str {
match provider {
"whatsapp" => "WhatsApp",
"wechat" => "WeChat",
"telegram" => "Telegram",
"linkedin" => "LinkedIn",
"slack" => "Slack",
"discord" => "Discord",
"gmail" => "Gmail",
"outlook" => "Outlook",
"instagram" => "Instagram",
"twitter" => "X",
"google-meet" => "Google Meet",
"zoom" => "Zoom",
"browserscan" => "BrowserScan",
_ => "OpenHuman",
}
}
#[derive(Default)]
pub struct WebviewAccountsState {
/// account_id -> webview label (we use `acct_<id>` as the label).
inner: Mutex<HashMap<String, String>>,
/// account_id -> provider id. Kept so late reveal/close paths can log
/// provider-scoped diagnostics without trusting frontend echo fields.
account_providers: Mutex<HashMap<String, String>>,
/// account_id -> CEF `Browser::identifier()`. Populated asynchronously
/// inside the `with_webview` callback once the renderer hands us the
/// browser handle, and consumed at close/purge time so we can call
/// `tauri_runtime_cef::notification::unregister` without leaking
/// per-browser handler entries across account churn.
browser_ids: Mutex<HashMap<String, i32>>,
/// account_id -> CDP session task. One long-lived task per account
/// keeps the UA override resident (see `cdp::session`); aborted on
/// close/purge so reopen cycles don't stack multiple live loops.
cdp_sessions: Mutex<HashMap<String, tokio::task::JoinHandle<()>>>,
/// account_id -> 15s `webview-account:load{state:"timeout"}` watchdog.
/// Aborted in close/purge so a watchdog spawned for a now-closed
/// account can't fire a stale timeout against a freshly-reused id.
load_watchdogs: Mutex<HashMap<String, tokio::task::JoinHandle<()>>>,
/// account_id of webviews that have already emitted their first
/// `webview-account:load{state:"finished"}` event. Used to dedup
/// triple-signal fires (native on_page_load, CDP `Page.loadEventFired`,
/// 15 s watchdog) so the frontend only reveals once per cold open.
loaded_accounts: Mutex<HashSet<String>>,
/// Last bounds requested by the frontend for a given account, captured at
/// `webview_account_open` time so the off-screen-spawned webview can be
/// revealed at the right rect without the frontend having to round-trip
/// them again.
requested_bounds: Mutex<HashMap<String, Bounds>>,
/// account_id -> `Instant` captured at the moment the cold spawn returns
/// from `add_child`. Consumed by `webview_account_reveal` to compute
/// `elapsed_ms` (spawn -> frontend reveal call) for the diagnostic log
/// instrumented for the Slack first-load investigation (#1036). Cleared
/// alongside `loaded_accounts` on close/purge so a subsequent reopen
/// starts fresh.
spawn_started_at: Mutex<HashMap<String, Instant>>,
/// Runtime notification-bypass controls used by the settings UI.
notification_bypass: Mutex<NotificationBypassPrefs>,
/// Per-label rewrite counter for the gmeet `workspace.google.com`
/// marketing intercept. Google's edge SSR-redirects unauthenticated
/// `meet.google.com` GETs back to `workspace.google.com/products/meet/`,
/// so an unguarded rewrite (see `:1534-1559`) ping-pongs forever and
/// the page never lands. We track `(last_attempt, attempts_in_window)`
/// per webview label and bail to the Google sign-in flow after a
/// threshold so the user breaks out of the loop.
gmeet_marketing_rewrites: Mutex<HashMap<String, (Instant, u32)>>,
/// Per-label "awaiting post-auth handoff" flag. Set when the gmeet
/// rewrite-loop bail navigates to `accounts.google.com/ServiceLogin`,
/// consumed (single-shot) by the `myaccount.google.com` intercept so
/// only the immediate post-auth bounce gets rewritten back to Meet —
/// legitimate user-initiated `myaccount.google.com` navigations (e.g.
/// "Manage your Google Account" from the avatar menu) are passed
/// through unchanged.
gmeet_awaiting_handoff: Mutex<HashSet<String>>,
/// account_ids spawned via `webview_account_prewarm` that have not yet
/// been opened by the user. Issue #1233 — emit_load_finished suppresses
/// `webview-account:load` events for these so the React UI never sees
/// load/timeout signals for an account it didn't ask to open. The flag
/// is cleared on the first user-initiated `webview_account_open`
/// (warm-reopen branch) and on close/purge so subsequent reopens flow
/// through the normal cold-load lifecycle.
prewarm_accounts: Mutex<HashSet<String>>,
}
/// Threshold and window for the gmeet workspace-marketing rewrite loop
/// breaker. After `GMEET_REWRITE_MAX_ATTEMPTS` rewrites within
/// `GMEET_REWRITE_WINDOW`, we bail to the Google sign-in URL instead of
/// rewriting again.
pub(crate) const GMEET_REWRITE_MAX_ATTEMPTS: u32 = 3;
pub(crate) const GMEET_REWRITE_WINDOW: Duration = Duration::from_secs(5);
/// Result of consulting the gmeet marketing-redirect counter.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum GmeetRewriteAction {
/// Allow the rewrite — fewer than `GMEET_REWRITE_MAX_ATTEMPTS` attempts in
/// the current window.
Rewrite,
/// Loop detected — caller should navigate to the Google sign-in URL
/// instead of rewriting back to `meet.google.com`.
Bail,
}
impl WebviewAccountsState {
/// Drop the gmeet marketing-rewrite counter for `label`. Called after
/// the post-auth handoff so a future workspace-marketing bounce (which
/// shouldn't occur post-auth, but could on session expiry) gets a fresh
/// counter window instead of inheriting half-saturated state from the
/// pre-auth loop.
pub(crate) fn clear_gmeet_marketing_rewrite(&self, label: &str) {
if let Ok(mut g) = self.gmeet_marketing_rewrites.lock() {
g.remove(label);
}
}
/// Mark `label` as awaiting the post-auth `myaccount.google.com` →
/// `meet.google.com` handoff. Set by the rewrite-loop bail right
/// before navigating to `accounts.google.com/ServiceLogin?continue=`,
/// so the next `myaccount.google.com` commit on this label is treated
/// as the auth chain's terminal hop (the `?utm_source=sign_in_no_continue`
/// dump-page) and gets force-redirected to Meet.
pub(crate) fn mark_awaiting_gmeet_handoff(&self, label: &str) {
if let Ok(mut g) = self.gmeet_awaiting_handoff.lock() {
g.insert(label.to_string());
}
}
/// Single-shot consume of the post-auth handoff flag for `label`.
/// Returns `true` exactly once after `mark_awaiting_gmeet_handoff`,
/// then resets so subsequent `myaccount.google.com` navigations
/// (e.g. user-initiated profile/settings visits) pass through as
/// normal and aren't hijacked back to Meet.
pub(crate) fn take_awaiting_gmeet_handoff(&self, label: &str) -> bool {
match self.gmeet_awaiting_handoff.lock() {
Ok(mut g) => g.remove(label),
Err(_) => false,
}
}
/// Increment the per-label gmeet marketing-rewrite counter for `now` and
/// decide whether to rewrite or bail. Resets the counter when the last
/// attempt was outside `GMEET_REWRITE_WINDOW` so a future genuine
/// `workspace.google.com` navigation (e.g. user clicks a link inside Meet
/// after sign-in) gets intercepted normally.
pub(crate) fn track_gmeet_marketing_rewrite(
&self,
label: &str,
now: Instant,
) -> GmeetRewriteAction {
let mut map = match self.gmeet_marketing_rewrites.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
let entry = map.entry(label.to_string()).or_insert((now, 0));
if now.duration_since(entry.0) > GMEET_REWRITE_WINDOW {
*entry = (now, 0);
}
entry.1 += 1;
entry.0 = now;
if entry.1 > GMEET_REWRITE_MAX_ATTEMPTS {
GmeetRewriteAction::Bail
} else {
GmeetRewriteAction::Rewrite
}
}
/// Drain every per-account resource owned by this state and abort the
/// associated background tasks. Returns the `(account_id, label)`
/// pairs of webviews that still need closing — the caller does the
/// actual `wv.close()` because that needs an `AppHandle`. Splitting
/// it out keeps the rest of the teardown unit-testable without
/// constructing a Tauri runtime.
///
/// Aborts CDP session tasks and load watchdogs, unregisters CEF
/// notification handlers, and clears the loaded-accounts /
/// requested-bounds bookkeeping. All collections are drained — a
/// repeat call returns an empty `Vec` and is a safe no-op.
fn drain_for_shutdown(&self) -> Vec<(String, String)> {
let cdp_tasks: Vec<_> = self
.cdp_sessions
.lock()
.ok()
.map(|mut g| g.drain().collect())
.unwrap_or_default();
for (acct, task) in cdp_tasks {
task.abort();
log::debug!("[webview-accounts] shutdown abort cdp account={}", acct);
}
let watchdogs: Vec<_> = self
.load_watchdogs
.lock()
.ok()
.map(|mut g| g.drain().collect())
.unwrap_or_default();
for (acct, task) in watchdogs {
task.abort();
log::debug!(
"[webview-accounts] shutdown abort watchdog account={}",
acct
);
}
let browser_ids: Vec<_> = self
.browser_ids
.lock()
.ok()
.map(|mut g| g.drain().collect())
.unwrap_or_default();
for (acct, browser_id) in browser_ids {
tauri_runtime_cef::notification::unregister(browser_id);
log::debug!(
"[notify-cef] shutdown unregistered handler account={} browser_id={}",
acct,
browser_id
);
}
if let Ok(mut g) = self.loaded_accounts.lock() {
g.clear();
}
if let Ok(mut g) = self.requested_bounds.lock() {
g.clear();
}
if let Ok(mut g) = self.spawn_started_at.lock() {
g.clear();
}
if let Ok(mut g) = self.account_providers.lock() {
g.clear();
}
// Per-label gmeet rewrite counter must clear too — `label_for()`
// reuses the same label on reopen, so a stale saturated entry
// would jump a fresh open straight to the bail URL.
if let Ok(mut g) = self.gmeet_marketing_rewrites.lock() {
g.clear();
}
// Drop the post-auth handoff flag too — a stale flag would
// hijack the first user-initiated `myaccount.google.com` visit
// after a relaunch back to Meet.
if let Ok(mut g) = self.gmeet_awaiting_handoff.lock() {
g.clear();
}
// Issue #1233 — clear prewarm flags so a relaunch can't suppress
// load events for accounts that were prewarmed in the previous
// session.
if let Ok(mut g) = self.prewarm_accounts.lock() {
g.clear();
}
self.inner
.lock()
.ok()
.map(|mut g| g.drain().collect())
.unwrap_or_default()
}
/// Tear down every per-account resource owned by this state — used by
/// the app's `RunEvent::ExitRequested` path so nothing outlives the
/// tokio runtime / `AppHandle` (issue #920).
///
/// On top of [`drain_for_shutdown`], this closes every `acct_*` child
/// webview so CEF browsers tear down before `cef::shutdown()` runs,
/// and tells the per-account scanner registries to forget the
/// account so a future open of the same id starts from a clean slate.
/// All collections are drained — repeat calls are cheap no-ops.
pub fn shutdown_all<R: Runtime>(&self, app: &AppHandle<R>) -> Vec<String> {
teardown_all_account_scanners(app);
let labels = self.drain_for_shutdown();
let mut closed_labels = Vec::with_capacity(labels.len());
for (acct, label) in labels {
teardown_account_scanners(app, &acct);
if let Some(wv) = app.get_webview(&label) {
// Track the label as soon as the webview exists so a failed
// `close()` still participates in the post-close drain poll
// (issue #1120 / CodeRabbit).
closed_labels.push(label.clone());
if let Err(e) = wv.close() {
log::warn!(
"[webview-accounts] shutdown close({label}) failed account={acct}: {e}"
);
}
} else {
log::debug!(
"[webview-accounts] shutdown label already gone account={} label={}",
acct,
label
);
}
}
log::info!(
"[webview-accounts] shutdown_all complete closed_labels={:?}",
closed_labels
);
closed_labels
}
}
/// Abort every provider scanner task tracked by the per-provider
/// registries. Used by full-app shutdown before the per-account state is
/// drained so CDP loops stop even if an account label was already removed
/// from `WebviewAccountsState`.
fn teardown_all_account_scanners<R: Runtime>(app: &AppHandle<R>) {
let mut total = 0usize;
if let Some(registry) =
app.try_state::<std::sync::Arc<crate::whatsapp_scanner::ScannerRegistry>>()
{
total += registry.inner().forget_all();
}
if let Some(registry) = app.try_state::<std::sync::Arc<crate::slack_scanner::ScannerRegistry>>()
{
total += registry.inner().forget_all();
}
if let Some(registry) =
app.try_state::<std::sync::Arc<crate::discord_scanner::ScannerRegistry>>()
{
total += registry.inner().forget_all();
}
if let Some(registry) =
app.try_state::<std::sync::Arc<crate::telegram_scanner::ScannerRegistry>>()
{
total += registry.inner().forget_all();
}
if let Some(registry) =
app.try_state::<std::sync::Arc<crate::wechat_scanner::ScannerRegistry>>()
{
total += registry.inner().forget_all();
}
if total > 0 {
log::info!(
"[webview-accounts] aborted {} provider scanner task(s) for shutdown",
total
);
}
}
/// Tell the per-account scanner registries (whatsapp / slack / discord /
/// telegram) to forget `account_id`. Shared by `webview_account_close`,
/// `webview_account_purge`, and `WebviewAccountsState::shutdown_all` so
/// every exit path goes through the same teardown.
fn teardown_account_scanners<R: Runtime>(app: &AppHandle<R>, account_id: &str) {
if let Some(registry) =
app.try_state::<std::sync::Arc<crate::whatsapp_scanner::ScannerRegistry>>()
{
registry.inner().forget(account_id);
}
if let Some(registry) = app.try_state::<std::sync::Arc<crate::slack_scanner::ScannerRegistry>>()
{
registry.inner().forget(account_id);
}
if let Some(registry) =
app.try_state::<std::sync::Arc<crate::discord_scanner::ScannerRegistry>>()
{
registry.inner().forget(account_id);
}
if let Some(registry) =
app.try_state::<std::sync::Arc<crate::telegram_scanner::ScannerRegistry>>()
{
registry.inner().forget(account_id);
}
if let Some(registry) =
app.try_state::<std::sync::Arc<crate::wechat_scanner::ScannerRegistry>>()
{
registry.inner().forget(account_id);
}
// Drop the in-process CDP transport for this account so a reopen
// installs a fresh observer instead of re-using the dead one tied
// to the closed webview.
if let Some(registry) = app.try_state::<crate::cdp::CdpRegistry>() {
registry.inner().forget_account(account_id);
}
}
#[derive(Debug, Clone)]
struct NotificationBypassPrefs {
global_dnd: bool,
muted_accounts: HashSet<String>,
bypass_when_focused: bool,
focused_account: Option<String>,
}
impl Default for NotificationBypassPrefs {
fn default() -> Self {
Self {
global_dnd: false,
muted_accounts: HashSet::new(),
// Match the existing UI copy: focused account may suppress toast.
bypass_when_focused: true,
focused_account: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct NotificationBypassPrefsPayload {
pub global_dnd: bool,
pub muted_accounts: Vec<String>,
pub bypass_when_focused: bool,
}
impl From<&NotificationBypassPrefs> for NotificationBypassPrefsPayload {
fn from(value: &NotificationBypassPrefs) -> Self {
let mut muted_accounts = value.muted_accounts.iter().cloned().collect::<Vec<_>>();
muted_accounts.sort();
Self {
global_dnd: value.global_dnd,
muted_accounts,
bypass_when_focused: value.bypass_when_focused,
}
}
}
/// Title prefix applied to every OS toast fired from an embedded webview.
/// Owned by the shell crate so it doesn't take a build-time dep on the core
/// library. Disambiguates from natively-installed apps (Slack, Discord,
/// Telegram desktop) firing the same message twice.
const OPENHUMAN_TITLE_PREFIX: &str = "OpenHuman: ";
fn slack_scanner_enabled() -> bool {
std::env::var("OPENHUMAN_DISABLE_SLACK_SCANNER")
.map(|v| {
let v = v.trim().to_ascii_lowercase();
!(v == "1" || v == "true" || v == "yes" || v == "on")
})
.unwrap_or(true)
}
/// Serialised fire-event payload shipped to the frontend over the
/// `webview-notification:fired` Tauri event. Carries `account_id` +
/// `provider` so the React side can route a subsequent click back to
/// the originating webview via Redux.
#[derive(Debug, Clone, Serialize)]
struct WebviewNotificationFired {
account_id: String,
provider: String,
title: String,
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
tag: Option<String>,
}
/// Linux: one worker thread + bounded queue so a burst of toasts does not
/// spawn unbounded `std::thread` handles (each would block in `wait_for_action`).
#[cfg(target_os = "linux")]
const LINUX_NOTIFY_QUEUE_CAP: usize = 16;
#[cfg(target_os = "linux")]
static LINUX_NOTIFY_TX: OnceLock<std::sync::mpsc::SyncSender<Box<dyn FnOnce() + Send>>> =
OnceLock::new();
#[cfg(target_os = "linux")]
fn enqueue_linux_notification(job: Box<dyn FnOnce() + Send>) {
let tx = LINUX_NOTIFY_TX.get_or_init(|| {
let (tx, rx) = sync_channel::<Box<dyn FnOnce() + Send>>(LINUX_NOTIFY_QUEUE_CAP);
std::thread::Builder::new()
.name("openhuman-linux-notify".to_string())
.spawn(move || {
while let Ok(j) = rx.recv() {
j();
}
})
.expect("spawn openhuman-linux-notify");
tx
});
if let Err(e) = tx.try_send(job) {
log::warn!(
"[notify-cef] linux notification queue full (cap={}), dropping toast: {}",
LINUX_NOTIFY_QUEUE_CAP,
e
);
}
}
/// Translate a `tauri-runtime-cef` notification payload into a native OS
/// toast via `tauri-plugin-notification`, and mirror the fire to the
/// React frontend so it can drive click-to-focus routing.
///
/// Gated on the runtime `NotificationSettings` flag (OFF by default) so
/// v1 ships the plumbing without surprising users with a toast storm the
/// first time they open a busy Slack tab.
fn forward_native_notification<R: Runtime>(
app: &AppHandle<R>,
account_id: &str,
provider: &str,
payload: &tauri_runtime_cef::notification::NotificationPayload,
) {
if let Some(state) = app.try_state::<WebviewAccountsState>() {
let prefs = state.notification_bypass.lock().unwrap().clone();
if prefs.global_dnd {
log::debug!(
"[notify-bypass][{}] suppressed global_dnd provider={}",
account_id,
provider
);
return;
}
if prefs.muted_accounts.contains(account_id) {
log::debug!(
"[notify-bypass][{}] suppressed muted_account provider={}",
account_id,
provider
);
return;
}
if prefs.bypass_when_focused && prefs.focused_account.as_deref() == Some(account_id) {
log::debug!(
"[notify-bypass][{}] suppressed focused_account provider={}",
account_id,
provider
);
return;
}
}
// Feature flag — bail early when the user hasn't opted in.
if let Some(settings) =
app.try_state::<crate::notification_settings::NotificationSettingsState>()
{
if !settings.enabled() {
log::debug!(
"[notify-cef][{}] suppressed (feature flag off) provider={}",
account_id,
provider
);
return;
}
}
let provider_label = provider_display_name(provider);
let raw_title = payload.title.as_str().trim();
let notify_title = if raw_title.is_empty() {
format!("{OPENHUMAN_TITLE_PREFIX}{provider_label}")
} else {
format!("{OPENHUMAN_TITLE_PREFIX}{provider_label}{raw_title}")
};
let body = payload.body.as_deref().unwrap_or("");
log::info!(
"[notify-cef][{}] source={:?} tag={:?} silent={} title_chars={} body_chars={}",
account_id,
payload.source,
payload.tag,
payload.silent,
raw_title.chars().count(),
body.chars().count()
);
log::debug!("[notify-cef][{}] raw_title={:?}", account_id, raw_title);
// Mirror to the frontend BEFORE firing the OS toast so the Redux
// store has the routing context ready by the time the user clicks.
let fired = WebviewNotificationFired {
account_id: account_id.to_string(),
provider: provider.to_string(),
title: notify_title.clone(),
body: body.to_string(),
tag: payload.tag.clone(),
};
if let Err(err) = app.emit("webview-notification:fired", &fired) {
log::warn!(
"[notify-cef][{}] emit webview-notification:fired failed: {}",
account_id,
err
);
}
// Respect the Web Notification `silent` flag — the mirror event above
// still updates the in-app notification center, but the OS toast is
// suppressed so the user is not audibly/visually interrupted for
// notifications the page explicitly marked as silent.
if payload.silent {
log::debug!(
"[notify-cef][{}] silent=true, suppressing OS toast",
account_id
);
return;
}
// Fire the OS toast and wire a click callback that emits `notification:click`
// so the frontend can bring the originating account into focus.
//
// macOS: mac-notification-sys blocks in wait_for_click mode — run on a
// blocking thread so the async executor is not stalled.
// Linux: notify_rust's wait_for_action hooks D-Bus action delivery.
// Windows: no click callback available; fall back to fire-and-forget.
let acct_for_click = account_id.to_string();
let prov_for_click = provider.to_string();
let app_for_click = app.clone();
#[cfg(target_os = "macos")]
{
use std::sync::atomic::{AtomicUsize, Ordering};
// Each `wait_for_click` thread blocks at ~100% CPU until the user
// clicks or the toast auto-dismisses. Under notification bursts this
// can pin many cores; cap concurrent click-wait threads and fall back
// to fire-and-forget (no click callback) once the budget is reached.
const MAX_CLICK_WAIT_THREADS: usize = 8;
static IN_FLIGHT: AtomicUsize = AtomicUsize::new(0);
let title_c = notify_title.clone();
let body_c = body.to_string();
let app_id = app.config().identifier.clone();
let prev = IN_FLIGHT.fetch_add(1, Ordering::AcqRel);
if prev >= MAX_CLICK_WAIT_THREADS {
IN_FLIGHT.fetch_sub(1, Ordering::AcqRel);
log::debug!(
"[notify-cef][{}] click-wait budget exhausted ({}), firing without click callback",
account_id,
prev
);
std::thread::spawn(move || {
let _ = mac_notification_sys::set_application(if tauri::is_dev() {
"com.apple.Terminal"
} else {
&app_id
});
use mac_notification_sys::Notification as MacNotif;
let mut n = MacNotif::new();
n.title(&title_c).message(&body_c);
let _ = n.send();
});
return;
}
std::thread::spawn(move || {
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
IN_FLIGHT.fetch_sub(1, Ordering::AcqRel);
}
}
let _guard = Guard;
let _ = mac_notification_sys::set_application(if tauri::is_dev() {
"com.apple.Terminal"
} else {
&app_id
});
use mac_notification_sys::{Notification as MacNotif, NotificationResponse};
let t = title_c;
let b = body_c;
let mut n = MacNotif::new();
n.title(&t).message(&b).wait_for_click(true);
match n.send() {
Ok(NotificationResponse::Click) | Ok(NotificationResponse::ActionButton(_)) => {
log::info!(
"[notify-click][{}] clicked provider={}",
acct_for_click,
prov_for_click
);
if let Err(e) = app_for_click.emit(
"notification:click",
serde_json::json!({
"account_id": acct_for_click,
"provider": prov_for_click,
}),
) {
log::warn!(
"[notify-click][{}] emit notification:click failed: {}",
acct_for_click,
e
);
}
}
Ok(other) => {
log::info!("[notify-click][{}] response={:?}", acct_for_click, other);
}
Err(e) => {
log::warn!("[notify-click][{}] send error: {}", acct_for_click, e);
}
}
});
}
#[cfg(target_os = "linux")]
{
let title_c = notify_title.clone();
let body_c = body.to_string();
enqueue_linux_notification(Box::new(move || {
let t = title_c;
let b = body_c;
let mut n = notify_rust::Notification::new();
n.summary(&t).body(&b);
match n.show() {
Ok(handle) => {
handle.wait_for_action(|action| {
// "__closed" is the synthetic dismiss action; skip it.
if action != "__closed" && !action.is_empty() {
log::info!(
"[notify-click][{}] action={} provider={}",
acct_for_click,
action,
prov_for_click
);
if let Err(e) = app_for_click.emit(
"notification:click",
serde_json::json!({
"account_id": acct_for_click,
"provider": prov_for_click,
}),
) {
log::warn!(
"[notify-click][{}] emit notification:click failed: {}",
acct_for_click,
e
);
}
}
});
}
Err(e) => {
log::warn!("[notify-click][{}] show failed: {}", acct_for_click, e);
}
}
}));
}
#[cfg(windows)]
{
let mut builder = app.notification().builder().title(&notify_title);
if !body.is_empty() {
builder = builder.body(body);
}
if let Err(e) = builder.show() {
log::warn!(
"[notify-cef][{}] notification show failed: {}",
account_id,
e
);
}
}
}
pub(crate) fn forward_synthetic_notification<R: Runtime>(
app: &AppHandle<R>,
account_id: &str,
provider: &str,
title: impl Into<String>,
body: impl Into<String>,
) {
let payload = tauri_runtime_cef::notification::NotificationPayload {
source: tauri_runtime_cef::notification::NotificationSource::Window,
title: title.into(),
body: Some(body.into()),
icon: None,
tag: None,
silent: false,
origin: format!("synthetic://{}", provider),
};
forward_native_notification(app, account_id, provider, &payload);
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct Bounds {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[derive(Debug, Deserialize)]
pub struct OpenArgs {
pub account_id: String,
pub provider: String,
/// Optional URL override (debug tooling) — falls back to `provider_url`.
pub url: Option<String>,
pub bounds: Option<Bounds>,
/// Issue #1233 — when true, spawn the webview off-screen and route the
/// load through the prewarm-suppression path. The full handler/scanner/
/// notification setup is identical to a normal cold open; only the
/// initial position and the load-event emit are different. Defaults
/// to false so the field is forwards-compatible with frontends that
/// don't pass it.
#[serde(default)]
pub prewarm: bool,
}
/// Issue #1233 — args for the background `webview_account_prewarm` command.
/// No bounds — prewarm always spawns at a fixed off-screen 1×1 rect; the
/// user-initiated open later supplies the visible rect via the warm-reopen
/// branch in `webview_account_open`.
#[derive(Debug, Deserialize)]
pub struct PrewarmArgs {
pub account_id: String,
pub provider: String,
/// Optional URL override (debug tooling) — falls back to `provider_url`.
#[serde(default)]
pub url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct BoundsArgs {
pub account_id: String,
pub bounds: Bounds,
}
#[derive(Debug, Deserialize)]
pub struct RevealArgs {
pub account_id: String,
pub bounds: Bounds,
#[serde(default)]
pub trigger: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RevealTrigger {
Load,
Watchdog,
}
impl RevealTrigger {
fn as_str(self) -> &'static str {
match self {
Self::Load => "load",
Self::Watchdog => "watchdog",
}
}
fn from_ipc(raw: Option<&str>) -> Self {
match raw {
Some("load") | None => Self::Load,
Some("watchdog") => Self::Watchdog,
Some(other) => {
log::warn!(
"[webview-accounts] unknown reveal trigger {:?}; defaulting to load",
other
);
Self::Load
}
}
}
}
#[derive(Debug, Deserialize)]
pub struct AccountIdArgs {
pub account_id: String,
}
#[derive(Debug, Deserialize)]
pub struct RecipeEventArgs {
pub account_id: String,
pub provider: String,
pub kind: String,
pub payload: serde_json::Value,
pub ts: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WebviewEvent {
pub account_id: String,
pub provider: String,
pub kind: String,
pub payload: serde_json::Value,
pub ts: Option<i64>,
}
/// Strip query string and fragment from a URL before emitting to the log.
/// Provider URLs occasionally embed auth material (Telegram WebApp data,
/// OAuth callback codes, sometimes session tokens) and we don't want those
/// to land in the long-lived shell log file. Returns the original input on
/// parse failure so we still surface *something* useful for debugging.
pub(crate) fn redact_url_for_log(raw: &str) -> String {
match Url::parse(raw) {
Ok(mut u) => {
u.set_query(None);
u.set_fragment(None);
u.to_string()
}
Err(_) => {
// Fallback: drop everything from the first '?' or '#'.
raw.split(['?', '#']).next().unwrap_or(raw).to_string()
}
}
}
/// Grow the first-cold-open webview back to its full requested bounds and
/// notify the frontend once the page is actually loaded. Called from three
/// signals (native `WebviewBuilder::on_page_load`, CDP `Page.loadEventFired`,
/// and the 15 s watchdog).
///
/// Timeout is a non-terminal state: we emit `webview-account:load{state:
/// "timeout"}` so the frontend can show retry/help UI, but we deliberately do
/// NOT reveal or mark the account as loaded yet. If a later `finished` signal
/// arrives, that call still reveals and emits `state:"finished"`.
///
/// Resetting the terminal loaded marker happens in `webview_account_close` /
/// `webview_account_purge` so a reopen fires again.
///
/// Doing the `set_size` server-side (instead of waiting for the frontend to
/// invoke `webview_account_reveal`) avoids an extra IPC round-trip and the
/// brief blank frame that would otherwise sit between the load event and
/// the frontend's reveal call.
pub(crate) fn emit_load_finished<R: Runtime>(
app: &AppHandle<R>,
account_id: &str,
state: &str,
url: &str,
trigger: RevealTrigger,
) {
let Some(app_state) = app.try_state::<WebviewAccountsState>() else {
// No state => emit anyway so the frontend doesn't hang; best-effort.
log::warn!(
"[webview-accounts][{}] WebviewAccountsState missing — emitting without reveal",
account_id
);
let _ = app.emit(
"webview-account:load",
serde_json::json!({
"account_id": account_id,
"state": state,
"trigger": trigger.as_str(),
"url": url,
}),
);
return;
};
// Issue #1233 — accounts in prewarm mode have no React UI listening
// for their load events; the user hasn't clicked the rail icon yet.
// Suppress emit + reveal so the prewarm cycle finishes silently. The
// page is still painted in the off-screen 1×1 webview so the eventual
// user click hits the warm-reopen branch and emits `state:"reused"`.
if app_state
.prewarm_accounts
.lock()
.unwrap()
.contains(account_id)
{
log::info!(
"[webview-accounts][{}] prewarm load suppressed state={} url={}",
account_id,
state,
redact_url_for_log(url)
);
// Mark the account as loaded so any later signals from the same
// cold-load (native on_page_load + CDP Page.loadEventFired both
// arriving) don't double-fire if the prewarm flag flips off in
// between.
if state != "timeout" {
app_state
.loaded_accounts
.lock()
.unwrap()
.insert(account_id.to_string());
}
return;
}
if state == "timeout" {
// If we've already observed a terminal load, ignore late watchdogs.
let already_loaded = app_state
.loaded_accounts
.lock()
.unwrap()
.contains(account_id);
if already_loaded {
log::debug!(
"[webview-accounts][{}] timeout deduped after terminal load url={}",
account_id,
url
);
return;
}
log::info!(
"[webview-accounts][{}] load timeout event trigger={} url={}",
account_id,
trigger.as_str(),
redact_url_for_log(url)
);
if let Err(err) = app.emit(
"webview-account:load",
serde_json::json!({
"account_id": account_id,
"state": state,
"trigger": trigger.as_str(),
"url": url,
}),
) {
log::warn!(
"[webview-accounts][{}] emit webview-account:load(timeout) failed: {}",
account_id,
err
);
}
return;
}
let is_first_terminal = app_state
.loaded_accounts
.lock()
.unwrap()
.insert(account_id.to_string());
if !is_first_terminal {
log::debug!(
"[webview-accounts][{}] load event deduped state={} url={}",
account_id,
state,
url
);
return;
}
// Restore the webview to its full requested size. The spawn path created
// it at 1×1 so the React loading spinner wasn't covered; now that the page
// is painted we can grow it into the placeholder rect.
let label = app_state.inner.lock().unwrap().get(account_id).cloned();
let bounds = app_state
.requested_bounds
.lock()
.unwrap()
.get(account_id)
.copied();
match (label, bounds) {
(Some(label), Some(b)) => {
if let Some(wv) = app.get_webview(&label) {
if let Err(e) = wv.set_size(LogicalSize::new(b.width, b.height)) {
log::warn!(
"[webview-accounts][{}] reveal set_size failed: {}",
account_id,
e
);
}
if let Err(e) = wv.set_position(LogicalPosition::new(b.x, b.y)) {
log::warn!(
"[webview-accounts][{}] reveal set_position failed: {}",
account_id,
e
);
}
let _ = wv.show();
log::info!(
"[webview-accounts][{}] revealed label={} bounds={:?} state={}",
account_id,
label,
b,
state
);
} else {
log::warn!(
"[webview-accounts][{}] reveal: webview {} missing",
account_id,
label
);
}
}
_ => {
log::info!(
"[webview-accounts][{}] reveal skipped (account closed before load) state={}",
account_id,
state
);
}
}
// Redact the URL in the log: providers like Telegram (`#tgWebAppData=…`)
// and OAuth callbacks embed auth material in the query/fragment. The full
// URL still flows to the frontend listener over the Tauri event so any
// consumer that needs it has access; we just don't persist it to the
// shell's log file.
log::info!(
"[webview-accounts][{}] load event state={} trigger={} url={}",
account_id,
state,
trigger.as_str(),
redact_url_for_log(url)
);
if let Err(err) = app.emit(
"webview-account:load",
serde_json::json!({
"account_id": account_id,
"state": state,
"trigger": trigger.as_str(),
"url": url,
}),
) {
log::warn!(
"[webview-accounts][{}] emit webview-account:load failed: {}",
account_id,
err
);
}
}
/// Reject any `account_id` that isn't strictly `[A-Za-z0-9_-]+`. The ID comes
/// from IPC (React shell, but also from injected recipe code running inside
/// third-party origins via `webview_recipe_event`), so treat it as untrusted.
/// Enforcing this early prevents `../` sequences from escaping the per-account
/// data directory in `data_directory_for` (which feeds `create_dir_all` and
/// `remove_dir_all`).
fn sanitize_account_id(account_id: &str) -> Result<&str, String> {
if account_id.is_empty()
|| !account_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(format!("invalid account_id: {account_id:?}"));
}
Ok(account_id)
}
fn label_for(account_id: &str) -> String {
// Webview labels must be alphanumeric + `-` / `_`. Callers that reached
// here without first going through `sanitize_account_id` still get a
// defensively-scrubbed label so invalid characters never reach the
// tauri webview-label parser.
let safe: String = account_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
format!("acct_{}", safe)
}
fn data_directory_for<R: Runtime>(app: &AppHandle<R>, account_id: &str) -> Result<PathBuf, String> {
// Guard against path traversal — `account_id` is joined into a filesystem
// path that is later passed to `create_dir_all` / `remove_dir_all`.
let account_id = sanitize_account_id(account_id)?;
let base = app
.path()
.app_local_data_dir()
.map_err(|e| format!("app_local_data_dir: {e}"))?;
Ok(base.join("webview_accounts").join(account_id))
}
/// Produce the `initialization_script` payload for this webview.
///
/// Empty for the 6 zero-injection providers (whatsapp, wechat, telegram,
/// slack, discord, browserscan) — they load with ZERO injected JS. Some have
/// native/CDP scraper paths (`wechat_scanner`, etc.). The per-account
/// CDP session opener (`cdp::session`) still injects the notification-permission
/// shim via `Page.addScriptToEvaluateOnNewDocument` before the real provider
/// URL loads. The 2 deferred providers (linkedin, google-meet) still get the
/// JS recipe bridge.
fn build_init_script(account_id: &str, provider: &str) -> String {
let Some(recipe_js) = provider_recipe_js(provider) else {
return String::new();
};
let ctx = serde_json::json!({
"accountId": account_id,
"provider": provider,
});
format!(
"window.__OPENHUMAN_RECIPE_CTX__ = {ctx};\n\n{runtime}\n\n{recipe}\n",
ctx = ctx,
runtime = RUNTIME_JS,
recipe = recipe_js
)
}
/// Spawn (or focus) the embedded webview for an account.
#[tauri::command]
pub async fn webview_account_open<R: Runtime>(
app: AppHandle<R>,
state: tauri::State<'_, WebviewAccountsState>,
args: OpenArgs,
) -> Result<String, String> {
let label = label_for(&args.account_id);
log::info!(
"[webview-accounts] open account_id={} provider={} label={}",
args.account_id,
args.provider,
label
);
// Reject unknown providers early. `provider_url` already errors when
// no URL override is supplied; the `provider_is_supported` check
// additionally gates custom-URL overrides so an arbitrary provider
// string can't ride in via the debug `url` field.
if !provider_is_supported(&args.provider) {
return Err(format!("unknown provider: {}", args.provider));
}
let real_url_str = args
.url
.as_deref()
.or_else(|| provider_url(&args.provider))
.ok_or_else(|| format!("no url for provider: {}", args.provider))?
.to_string();
// Validate the real URL up front — otherwise a malformed debug
// `args.url` would only fail later inside the async CDP session
// loop, which is much harder to surface to the caller. The parsed
// Url also feeds `scanner_url_prefix` so scanners match on the
// actual origin the user navigated to (honoring debug overrides).
let real_url: Url = real_url_str
.parse()
.map_err(|e| format!("invalid provider url {real_url_str}: {e}"))?;
// Scanner target-match uses `url.starts_with(prefix)`, so the
// prefix needs to be the ORIGIN (scheme + host), not the full URL
// — same-host intra-app navigations must keep matching after the
// initial load.
let scanner_url_prefix = format!("{}/", real_url.origin().ascii_serialization());
let skip_cdp_for_debug = args.provider == "slack" && !slack_scanner_enabled();
// We normally open the webview at a tiny placeholder URL so the CDP
// session opener can attach and inject the notification-permission
// shim (see `cdp/session.rs`) BEFORE the real provider URL loads;
// without it Slack surfaces in-app "enable notifications"
// banners. For Slack debug sessions we allow opting out via
// `OPENHUMAN_DISABLE_SLACK_SCANNER=1`, which also skips the long-lived
// CDP session so external DevTools can attach cleanly.
let initial_url_str = if skip_cdp_for_debug {
real_url_str.clone()
} else {
cdp::placeholder_url(&args.account_id)
};
let initial_url: Url = initial_url_str
.parse()
.map_err(|e| format!("invalid initial url {initial_url_str}: {e}"))?;
// If a webview for this account already exists, just reposition / show.
{
let map = state.inner.lock().unwrap();
if let Some(existing_label) = map.get(&args.account_id).cloned() {
drop(map);
if let Some(existing) = app.get_webview(&existing_label) {
// Issue #1233 — when this is a prewarm call landing on an
// already-prewarmed account, do nothing: the webview is
// already off-screen, the CDP session is already attached,
// and the prewarm flag should stay set so the eventual
// user-initiated open can promote it. Just return the label.
if args.prewarm {
log::debug!(
"[webview-accounts] prewarm idempotent skip: account={} already warm label={}",
args.account_id,
existing_label
);
return Ok(existing_label);
}
// Issue #1233 — a prewarmed webview is reaching its first
// user-initiated open. Clear the prewarm flag BEFORE we
// resize/reveal so any in-flight CDP load event still
// racing toward `emit_load_finished` flows through the
// normal path instead of being silently suppressed.
let was_prewarmed = state
.prewarm_accounts
.lock()
.unwrap()
.remove(&args.account_id);
if was_prewarmed {
log::info!(
"[webview-accounts] prewarm hit account={} label={} — promoting to live",
args.account_id,
existing_label
);
}
if let Some(b) = args.bounds {
let _ = existing.set_position(LogicalPosition::new(b.x, b.y));
let _ = existing.set_size(LogicalSize::new(b.width, b.height));
state
.requested_bounds
.lock()
.unwrap()
.insert(args.account_id.clone(), b);
}
let _ = existing.show();
log::info!(
"[webview-accounts] reused existing label={} for account={}",
existing_label,
args.account_id
);
// Warm re-open: the page is already painted, so skip the
// loading overlay cycle and tell the frontend to go straight
// to `open`. We bypass `emit_load_finished` because the
// `loaded_accounts` dedup set would swallow the emit after
// the first cold open of this account.
let reuse_url = existing.url().map(|u| u.to_string()).unwrap_or_default();
if let Err(err) = app.emit(
"webview-account:load",
serde_json::json!({
"account_id": args.account_id,
"state": "reused",
"trigger": RevealTrigger::Load.as_str(),
"url": reuse_url,
}),
) {
log::warn!(
"[webview-accounts][{}] emit reused event failed: {}",
args.account_id,
err
);
}
return Ok(existing_label);
}
// Stale entry — fall through and rebuild
log::warn!(
"[webview-accounts] stale label {} found for account {}, rebuilding",
existing_label,
args.account_id
);
}
}
// Grab the raw Window (not WebviewWindow) so `add_child` works even
// after we've attached sibling webviews — `get_webview_window` checks
// `is_webview_window()` which flips to false once a window has more
// than one webview.
let parent_window = app
.get_window("main")
.ok_or_else(|| "main window not found".to_string())?;
let data_dir = data_directory_for(&app, &args.account_id)?;
if let Err(err) = std::fs::create_dir_all(&data_dir) {
log::warn!(
"[webview-accounts] failed to create data dir {}: {}",
data_dir.display(),
err
);
}
let init_script = build_init_script(&args.account_id, &args.provider);
let mut builder = WebviewBuilder::new(label.clone(), WebviewUrl::External(initial_url))
.data_directory(data_dir);
if !init_script.is_empty() {
builder = builder.initialization_script(&init_script);
}
// Keep link clicks that leave the provider's host set in the OS
// browser, not the embedded webview. Same-host navigations (including
// OAuth hops to accounts.google.com etc., which we pre-declare per
// provider) stay in-app. Provider-specific native-app deep links
// (`zoomus://`, `zoommtg://`, …) are rewritten to the web-client URL
// and re-navigated in-app so meetings don't bounce out.
let nav_provider = args.provider.clone();
let nav_app = app.clone();
let nav_label = label.clone();
let nav_account_id = args.account_id.clone();
builder = builder.on_navigation(move |url| {
// Notify the frontend on every committed navigation. The
// `webview-account:load` event is dedup'd per cold open, so it
// can't be used to spot post-login redirects (e.g. Google
// Meet's accounts.google.com → meet.google.com hop). Frontends
// that
// care about live URL transitions — onboarding's auto-detect
// for "user finished signing in", for instance — listen here.
if let Err(err) = nav_app.emit(
"webview-account:navigate",
serde_json::json!({
"account_id": nav_account_id,
"provider": nav_provider,
"url": redact_navigation_url(url),
}),
) {
log::debug!(
"[webview-accounts] emit webview-account:navigate failed: {}",
err
);
}
// Google Meet: when Google's edge SSR-redirects the post-account-
// picker URL to `workspace.google.com/products/meet/...` (the
// marketing landing page), `workspace.google.com` matches the
// bare `google.com` suffix in `provider_allowed_hosts` so
// `url_is_internal` would commit the navigation and the user
// would land on the Workspace marketing page instead of Meet.
// Catch this here and replace the parent URL with the canonical
// Meet entry point so the embedded view stays on the app.
//
// BUT: unauthenticated users get bounced right back to
// `workspace.google.com/products/meet/` by Google's edge, so an
// unguarded rewrite ping-pongs forever (`navigate` → Google
// redirect → `on_navigation` → `navigate` → …). We track per-label
// attempts in `WebviewAccountsState::gmeet_marketing_rewrites` and
// bail to a Google sign-in URL after a small threshold so the user
// can break out of the loop. See #1213 (downstream watchdog
// symptom) and `track_gmeet_marketing_rewrite` for the policy.
if nav_provider == "google-meet" {
if let Some(host) = url.host_str() {
// Post-auth handoff: when the bail's `ServiceLogin?continue=`
// chain completes, Google sometimes drops the continue param
// (URL ends in `?utm_source=sign_in_no_continue`) and dumps
// the user on `myaccount.google.com` instead of Meet. The
// session cookie is now valid, so a direct navigation to
// `meet.google.com` will paint Meet without bouncing back
// through the workspace marketing redirect (which was the
// unauthenticated branch). Force the hop here so the user
// doesn't have to click through the apps grid manually.
//
// Gated on the per-label `gmeet_awaiting_handoff` flag —
// set by the Bail branch right before navigating to
// `ServiceLogin?continue=` — so legitimate user-initiated
// visits to `myaccount.google.com` (e.g. "Manage your
// Google Account" from the avatar menu) pass through and
// remain reachable in-app.
if host == "myaccount.google.com" {
let consumed = nav_app
.try_state::<WebviewAccountsState>()
.map(|s| s.take_awaiting_gmeet_handoff(&nav_label))
.unwrap_or(false);
if !consumed {
log::debug!(
"[webview-accounts] gmeet myaccount.google.com nav (label={}) not in handoff window; passing through",
nav_label
);
} else {
log::info!(
"[webview-accounts] gmeet post-auth handoff detected on myaccount.google.com; navigating parent to https://meet.google.com/"
);
let app = nav_app.clone();
let label = nav_label.clone();
// Reset the marketing-rewrite counter so the next
// workspace bounce (if any) gets a fresh window —
// the user is now authenticated and shouldn't
// loop again.
if let Some(s) = nav_app.try_state::<WebviewAccountsState>() {
s.clear_gmeet_marketing_rewrite(&nav_label);
}
tauri::async_runtime::spawn(async move {
if let Some(wv) = app.get_webview(&label) {
if let Ok(target) = Url::parse("https://meet.google.com/") {
if let Err(e) = wv.navigate(target) {
log::warn!(
"[webview-accounts] gmeet post-auth navigate failed label={} err={}",
label,
e
);
}
}
}
});
return false;
}
}
if is_gmeet_marketing_redirect(host, url.path()) {
let action = nav_app
.try_state::<WebviewAccountsState>()
.map(|s| s.track_gmeet_marketing_rewrite(&nav_label, Instant::now()))
.unwrap_or(GmeetRewriteAction::Rewrite);
let app = nav_app.clone();
let label = nav_label.clone();
let target_url = match action {
GmeetRewriteAction::Rewrite => {
log::info!(
"[webview-accounts] gmeet workspace marketing redirect intercepted ({}); rewriting parent to https://meet.google.com/",
url
);
"https://meet.google.com/"
}
GmeetRewriteAction::Bail => {
log::warn!(
"[webview-accounts] gmeet workspace rewrite loop detected on label={} (>{} attempts in {}s); falling through to Google sign-in",
nav_label,
GMEET_REWRITE_MAX_ATTEMPTS,
GMEET_REWRITE_WINDOW.as_secs()
);
// Arm the post-auth handoff flag so the next
// `myaccount.google.com` commit on this label
// (the `?utm_source=sign_in_no_continue` dump
// page Google sometimes drops users on after
// the ServiceLogin chain) gets force-redirected
// back to Meet. Without this gate, ANY
// `myaccount.google.com` visit was hijacked,
// breaking legitimate "Manage your Google
// Account" flows.
if let Some(s) = nav_app.try_state::<WebviewAccountsState>() {
s.mark_awaiting_gmeet_handoff(&nav_label);
}
// `service=meet` is rejected by Google (`400
// malformed`); the continue param must be
// URL-encoded since `&` would split it. Drop
// `service=` and encode `continue=` so the
// sign-in landing actually loads.
"https://accounts.google.com/ServiceLogin?continue=https%3A%2F%2Fmeet.google.com%2F"
}
};
tauri::async_runtime::spawn(async move {
if let Some(wv) = app.get_webview(&label) {
if let Ok(target) = Url::parse(target_url) {
if let Err(e) = wv.navigate(target) {
log::warn!(
"[webview-accounts] gmeet workspace rewrite navigate failed label={} target={} err={}",
label,
target_url,
e
);
}
}
}
});
return false;
}
}
}
if let Some(rewritten) = rewrite_provider_deep_link(&nav_provider, url) {
log::info!(
"[webview-accounts] deep-link rewrite {} → {} (provider={})",
url,
rewritten,
nav_provider
);
let app = nav_app.clone();
let label = nav_label.clone();
tauri::async_runtime::spawn(async move {
if let Some(wv) = app.get_webview(&label) {
if let Err(e) = wv.navigate(rewritten) {
log::warn!(
"[webview-accounts] post-rewrite navigate failed label={} err={}",
label,
e
);
}
}
});
return false;
}
if url_is_internal(&nav_provider, url) {
true
} else {
// Suppress provider native-desktop-app deep-link schemes that
// we don't know how to rewrite. macOS would otherwise hand
// these to the native provider app — `slack://magic-login/…`
// signs the native Slack app into the workspace, breaking
// embedded-webview isolation (#1074). The web flow's HTTPS
// fallback handles sign-in without the deep link.
if is_provider_native_deep_link_scheme(url.scheme()) {
log::warn!(
"[webview-accounts] suppressing native-app deep-link scheme={} url={} (would breach workspace isolation)",
url.scheme(),
redact_native_deep_link_url(url)
);
return false;
}
let target = unwrap_provider_redirect(url)
.map(|u| u.to_string())
.unwrap_or_else(|| url.to_string());
if target != url.as_str() {
log::info!(
"[webview-accounts] external navigation {} → (unwrapped) {} → system browser",
url,
target
);
} else {
log::info!(
"[webview-accounts] external navigation {} → system browser",
url
);
}
open_in_system_browser(&target);
false
}
});
// Cmd/Ctrl-click and `target="_blank"` / `window.open(...)` trigger a
// new-window request. Default policy: deny and hand the URL to the
// system browser — matches user intent of "open in new tab outside
// the app".
//
// Exception: some providers (Slack Huddles) spawn popups via
// `window.open()` and abort the flow if the return value is falsey.
// For those URLs we allow CEF's default popup handling so an in-app
// child window opens and the caller gets a real window handle.
let popup_provider = args.provider.clone();
let popup_app = app.clone();
let popup_label = label.clone();
builder = builder.on_new_window(move |url, _features| {
if let Some(rewritten) = rewrite_provider_deep_link(&popup_provider, &url) {
log::info!(
"[webview-accounts] new-window deep-link rewrite {} → {} (provider={})",
url,
rewritten,
popup_provider
);
let app = popup_app.clone();
let label = popup_label.clone();
tauri::async_runtime::spawn(async move {
if let Some(wv) = app.get_webview(&label) {
if let Err(e) = wv.navigate(rewritten) {
log::warn!(
"[webview-accounts] post-rewrite navigate (popup) failed label={} err={}",
label,
e
);
}
}
});
return NewWindowResponse::Deny;
}
if let Some(target) = popup_should_navigate_parent(&popup_provider, &url) {
log::info!(
"[webview-accounts] new-window {} → navigate parent (provider={})",
redact_navigation_url(&url),
popup_provider
);
let app = popup_app.clone();
let label = popup_label.clone();
tauri::async_runtime::spawn(async move {
if let Some(wv) = app.get_webview(&label) {
if let Err(e) = wv.navigate(target) {
log::warn!(
"[webview-accounts] popup→parent navigate failed label={} err={}",
label,
e
);
}
}
});
return NewWindowResponse::Deny;
}
if popup_should_stay_in_app(&popup_provider, &url) {
log::info!(
"[webview-accounts] new-window request {} → in-app popup (provider={})",
url,
popup_provider
);
NewWindowResponse::Allow
} else {
// Suppress provider native-desktop-app deep-link schemes that
// we don't know how to rewrite (matches the on_navigation
// fallback). Without this, a `slack://...` popup would land
// in the native Slack app via macOS's URL handler and
// breach embedded-webview workspace isolation (#1074).
if is_provider_native_deep_link_scheme(url.scheme()) {
log::warn!(
"[webview-accounts] suppressing native-app deep-link scheme={} url={} (would breach workspace isolation)",
url.scheme(),
redact_native_deep_link_url(&url)
);
return NewWindowResponse::Deny;
}
let target = unwrap_provider_redirect(&url)
.map(|u| u.to_string())
.unwrap_or_else(|| url.to_string());
if target != url.as_str() {
log::info!(
"[webview-accounts] new-window request {} → (unwrapped) {} → system browser",
url,
target
);
} else {
log::info!(
"[webview-accounts] new-window request {} → system browser",
url
);
}
open_in_system_browser(&target);
NewWindowResponse::Deny
}
});
// Enable devtools on child webviews in debug builds only so recipe
// diagnostics and IndexedDB state can be inspected. Access on macOS is via
// Safari → Develop → <App name> → <webview label>
// (the parent Tauri window's right-click "Inspect" does not propagate
// into child webviews on WKWebView). In release builds we leave CDP off
// so third-party-site webviews are not remotely inspectable.
if cfg!(debug_assertions) {
builder = builder.devtools(true);
}
// Wire the native page-load signal and forward only *usable* load
// completions to `emit_load_finished`:
// - skip placeholder `about:blank#openhuman-acct-*` commits (otherwise
// we reveal a blank viewport before real content arrives),
// - treat Chromium network error pages (`chrome-error://…`) as timeout
// signals so frontend shows retry/help UI instead of the dino page.
//
// Real provider commits still emit `finished`. Dedup against CDP
// `Page.loadEventFired` + watchdog happens in `emit_load_finished`.
let page_load_app = app.clone();
let page_load_account_id = args.account_id.clone();
let page_load_placeholder_fragment = format!("#{}", cdp::placeholder_marker(&args.account_id));
let page_load_real_url = real_url_str.clone();
builder = builder.on_page_load(move |_webview, payload| {
if !matches!(payload.event(), tauri::webview::PageLoadEvent::Finished) {
return;
}
let url = payload.url();
if url.scheme() == "data" {
return;
}
if !skip_cdp_for_debug && url.as_str().ends_with(&page_load_placeholder_fragment) {
log::debug!(
"[webview-accounts][{}] skipping placeholder native-finished url={}",
page_load_account_id,
redact_url_for_log(url.as_str())
);
return;
}
if url.scheme() == "chrome-error" {
emit_load_finished(
&page_load_app,
&page_load_account_id,
"timeout",
&page_load_real_url,
RevealTrigger::Load,
);
return;
}
emit_load_finished(
&page_load_app,
&page_load_account_id,
"finished",
url.as_str(),
RevealTrigger::Load,
);
});
let bounds = args.bounds.unwrap_or(Bounds {
x: 0.0,
y: 0.0,
width: 800.0,
height: 600.0,
});
// Park the webview off-screen during its first page load so the React
// placeholder's loading spinner is not covered by the native CEF subview.
// `webview_account_reveal` (invoked from the frontend after the load event
// arrives, or by the 15 s watchdog) moves it back to `bounds` + shows it.
//
// Warm-open reuse (when a webview already exists for this account) earlier
// in this function returns before we get here, so existing webviews keep
// their current position — we only off-screen the first cold spawn.
// Spawn strategy: keep the webview at the caller's requested position
// but shrink the initial size to 1×1 under CEF so the native subview
// doesn't paint over the React loading spinner. `webview_account_reveal`
// grows it back to `bounds.width × bounds.height` once the page-loaded
// signal arrives.
//
// Why not move off-screen: moving the NSView after a cold CEF spawn on
// macOS sometimes leaves the page painted but not repainted at the new
// origin, leaving the user looking at a blank viewport until they
// reload. Keeping the position stable and only toggling size sidesteps
// that repaint edge case while still keeping the webview visually
// hidden (1 px under the overlay) during load.
//
// Issue #1233 — when `args.prewarm == true`, the frontend has not asked
// for a visible rect (the user hasn't clicked the rail icon yet). Spawn
// the webview at a fixed off-screen position with size 1×1 so it never
// paints anywhere on screen until the eventual user-initiated open
// promotes it via the warm-reopen branch above.
let (initial_position, initial_size) = if args.prewarm {
(
LogicalPosition::new(PREWARM_OFFSCREEN_X, PREWARM_OFFSCREEN_Y),
LogicalSize::new(1.0, 1.0),
)
} else if skip_cdp_for_debug {
(
LogicalPosition::new(bounds.x, bounds.y),
LogicalSize::new(bounds.width, bounds.height),
)
} else {
(
LogicalPosition::new(bounds.x, bounds.y),
LogicalSize::new(1.0, 1.0),
)
};
// Issue #1233 — only remember `requested_bounds` for non-prewarm opens.
// Prewarm doesn't have a visible rect to restore to; the user-initiated
// open later supplies the bounds via the warm-reopen branch.
if !args.prewarm {
state
.requested_bounds
.lock()
.unwrap()
.insert(args.account_id.clone(), bounds);
}
// Issue #1233 — mark the account as prewarmed BEFORE add_child so the
// load-event suppression in `emit_load_finished` is in place by the time
// the CDP session or native on_page_load fires.
if args.prewarm {
state
.prewarm_accounts
.lock()
.unwrap()
.insert(args.account_id.clone());
}
// Defensive reset: if a prior close/purge was raced by a stale emit we
// could still have the account marked as "already loaded". Clear here so
// the fresh spawn is allowed to fire the first event again.
state
.loaded_accounts
.lock()
.unwrap()
.remove(&args.account_id);
let webview = parent_window
.add_child(builder, initial_position, initial_size)
.map_err(|e| format!("add_child failed: {e}"))?;
// Install the in-process CDP transport so the per-account session
// opener and the provider scanners can attach. Failure here is
// logged but not fatal — the scanners retry through
// `cdp::conn_for_account` once the registry is populated, so a
// transient install error just delays first attach by one backoff
// tick.
if let Err(err) = crate::cdp::install_for_account(&args.account_id) {
log::warn!(
"[webview-accounts] cdp install_for_account({}) failed: {} \
(scanners will retry)",
args.account_id,
err
);
}
// Capture the cold-spawn timestamp so the reveal-time log can compute
// spawn -> frontend reveal latency for the Slack first-load investigation.
state
.spawn_started_at
.lock()
.unwrap()
.insert(args.account_id.clone(), Instant::now());
state
.account_providers
.lock()
.unwrap()
.insert(args.account_id.clone(), args.provider.clone());
log::info!(
"[webview-accounts] spawned label={} requested_bounds={:?} initial_size={:?}",
webview.label(),
bounds,
initial_size
);
state
.inner
.lock()
.unwrap()
.insert(args.account_id.clone(), label.clone());
// Spawn the per-account CDP session opener: holds an attached session
// for the lifetime of the webview so `Emulation.setUserAgentOverride`
// (which reverts on detach) keeps applying, and drives the initial
// Page.navigate from our placeholder URL to the real provider URL.
// Also installs the `#openhuman-account-{id}` fragment the scanners
// match on for multi-account disambiguation.
// Spawn the per-account CDP session opener, replacing any prior
// handle for this account (the old one would still be trying to
// attach to a target that's been torn down).
{
if skip_cdp_for_debug {
log::info!(
"[webview-accounts] skipping CDP session via OPENHUMAN_DISABLE_SLACK_SCANNER for account={}",
args.account_id
);
} else {
let cdp::SpawnedSession { session, watchdog } =
cdp::spawn_session(app.clone(), args.account_id.clone(), real_url_str.clone());
if let Some(old) = state
.cdp_sessions
.lock()
.unwrap()
.insert(args.account_id.clone(), session)
{
old.abort();
}
if let Some(old) = state
.load_watchdogs
.lock()
.unwrap()
.insert(args.account_id.clone(), watchdog)
{
old.abort();
}
}
}
// For providers we know how to scrape via CDP, kick off the IndexedDB
// scanner. CDP requires the CEF runtime's remote-debugging port.
{
// Prefix is derived from the validated real URL's origin above
// so debug `args.url` overrides (alt hosts, localhost mirrors)
// resolve correctly — previously we always used the static
// `provider_url(...)` default even when the webview had
// navigated elsewhere.
if args.provider == "whatsapp" {
let registry = app
.try_state::<std::sync::Arc<crate::whatsapp_scanner::ScannerRegistry>>()
.map(|s| s.inner().clone());
if let Some(registry) = registry {
registry.ensure_scanner(
app.clone(),
args.account_id.clone(),
scanner_url_prefix.clone(),
);
} else {
log::warn!("[webview-accounts] CDP ScannerRegistry not in app state");
}
} else if args.provider == "slack" {
if slack_scanner_enabled() {
let registry = app
.try_state::<std::sync::Arc<crate::slack_scanner::ScannerRegistry>>()
.map(|s| s.inner().clone());
if let Some(registry) = registry {
registry.ensure_scanner(
app.clone(),
args.account_id.clone(),
scanner_url_prefix.clone(),
);
} else {
log::warn!("[webview-accounts] slack ScannerRegistry not in app state");
}
} else {
log::info!(
"[webview-accounts] slack scanner disabled via OPENHUMAN_DISABLE_SLACK_SCANNER for account={}",
args.account_id
);
}
} else if args.provider == "telegram" {
let registry = app
.try_state::<std::sync::Arc<crate::telegram_scanner::ScannerRegistry>>()
.map(|s| s.inner().clone());
if let Some(registry) = registry {
registry.ensure_scanner(
app.clone(),
args.account_id.clone(),
scanner_url_prefix.clone(),
);
} else {
log::warn!("[webview-accounts] telegram ScannerRegistry not in app state");
}
} else if args.provider == "discord" {
// Discord MITM uses CDP `Network.*` to capture HTTP API calls
// and gateway WebSocket frames — see `discord_scanner/mod.rs`.
let registry = app
.try_state::<std::sync::Arc<crate::discord_scanner::ScannerRegistry>>()
.map(|s| s.inner().clone());
if let Some(registry) = registry {
registry.ensure_scanner(
app.clone(),
args.account_id.clone(),
scanner_url_prefix.clone(),
);
} else {
log::warn!("[webview-accounts] discord ScannerRegistry not in app state");
}
} else if args.provider == "wechat" {
if let Some(registry) = app
.try_state::<std::sync::Arc<crate::wechat_scanner::ScannerRegistry>>()
.map(|s| s.inner().clone())
{
registry.ensure_scanner(
app.clone(),
args.account_id.clone(),
scanner_url_prefix.clone(),
);
} else {
log::warn!("[webview-accounts] wechat ScannerRegistry not in app state");
}
}
// Browser Notification interception, native CEF path. The renderer
// subprocess (cef-helper) has already replaced `window.Notification`
// and `ServiceWorkerRegistration.prototype.showNotification` with
// V8 native bindings that send a `"openhuman.notify"` ProcessMessage
// to the browser process. `tauri-runtime-cef::notification::register`
// installs a per-browser callback that the runtime invokes when that
// IPC arrives. We need the CEF browser id to key the registration —
// hence the `with_webview` downcast hop. The callback is dispatched
// from a CEF thread, so keep work inside it short / non-blocking.
let app_for_register = app.clone();
let acct_for_register = args.account_id.clone();
let provider_for_register = args.provider.clone();
if let Err(err) = webview.with_webview(move |raw| {
let Some(browser) = raw.downcast_ref::<cef::Browser>() else {
log::warn!(
"[notify-cef] with_webview returned non-cef::Browser handle for account={} — skipping notification registration",
acct_for_register
);
return;
};
let browser_id = browser.identifier();
if let Some(state) = app_for_register.try_state::<WebviewAccountsState>() {
state
.browser_ids
.lock()
.unwrap()
.insert(acct_for_register.clone(), browser_id);
}
let acct_in_handler = acct_for_register.clone();
let provider_in_handler = provider_for_register.clone();
let app_in_handler = app_for_register.clone();
tauri_runtime_cef::notification::register(browser_id, move |payload| {
forward_native_notification(
&app_in_handler,
&acct_in_handler,
&provider_in_handler,
&payload,
);
});
log::info!(
"[notify-cef] registered handler account={} provider={} browser_id={}",
acct_for_register,
provider_for_register,
browser_id
);
}) {
log::warn!(
"[notify-cef] with_webview dispatch failed for account={}: {}",
args.account_id,
err
);
}
}
Ok(label)
}
/// Off-screen position used for the prewarmed webview. Same magnitude as
/// the [`super::lib::CEF_PREWARM_LABEL`] warmup placeholder so the native
/// view is well outside any plausible monitor layout. Issue #1233.
pub(crate) const PREWARM_OFFSCREEN_X: f64 = -20_000.0;
pub(crate) const PREWARM_OFFSCREEN_Y: f64 = -20_000.0;
/// Issue #1233 — spawn a hidden 1×1 webview for `account_id` so its CEF
/// profile and provider page are warm before the user clicks the rail icon.
/// On the user's first click, the existing `webview_account_open` warm-reopen
/// branch reuses the prewarmed webview and emits `state:"reused"` so the React
/// loading overlay never has to wait for a cold load.
///
/// Implemented as a thin delegate to `webview_account_open` with
/// `prewarm: true`. Sharing the cold-open code path means the prewarmed
/// webview gets the full handler suite (`on_navigation`, `on_new_window`,
/// `on_page_load`), the per-provider scanner bootstrap, and the CEF
/// notification registration — none of which can be retroactively wired
/// when the warm-reopen branch later returns early.
///
/// Idempotent — calling for an already-warm account is a no-op. Best-effort —
/// the frontend can safely fire-and-forget; on failure the worst case is a
/// normal cold open later.
#[tauri::command]
pub async fn webview_account_prewarm<R: Runtime>(
app: AppHandle<R>,
state: tauri::State<'_, WebviewAccountsState>,
args: PrewarmArgs,
) -> Result<(), String> {
log::info!(
"[webview-accounts] prewarm account_id={} provider={}",
args.account_id,
args.provider
);
let open_args = OpenArgs {
account_id: args.account_id,
provider: args.provider,
url: args.url,
bounds: None,
prewarm: true,
};
webview_account_open(app, state, open_args)
.await
.map(|_| ())
}
#[tauri::command]
pub async fn webview_account_close<R: Runtime>(
app: AppHandle<R>,
state: tauri::State<'_, WebviewAccountsState>,
args: AccountIdArgs,
) -> Result<(), String> {
let label_opt = state.inner.lock().unwrap().remove(&args.account_id);
let Some(label) = label_opt else {
log::debug!(
"[webview-accounts] close: no webview for account {}",
args.account_id
);
return Ok(());
};
if let Some(wv) = app.get_webview(&label) {
if let Err(e) = wv.close() {
log::warn!("[webview-accounts] close({label}) failed: {e}");
}
}
teardown_account_scanners(&app, &args.account_id);
if let Some(browser_id) = state.browser_ids.lock().unwrap().remove(&args.account_id) {
tauri_runtime_cef::notification::unregister(browser_id);
log::debug!(
"[notify-cef] unregistered handler account={} browser_id={}",
args.account_id,
browser_id
);
}
if let Some(task) = state.cdp_sessions.lock().unwrap().remove(&args.account_id) {
task.abort();
log::debug!(
"[cdp-session] aborted session task for account={}",
args.account_id
);
}
if let Some(task) = state
.load_watchdogs
.lock()
.unwrap()
.remove(&args.account_id)
{
task.abort();
log::debug!(
"[webview-accounts] aborted load watchdog for account={}",
args.account_id
);
}
// Reset load-overlay bookkeeping so the next open of this account starts
// with a fresh "not yet loaded" state.
state
.loaded_accounts
.lock()
.unwrap()
.remove(&args.account_id);
state
.requested_bounds
.lock()
.unwrap()
.remove(&args.account_id);
state
.spawn_started_at
.lock()
.unwrap()
.remove(&args.account_id);
state
.account_providers
.lock()
.unwrap()
.remove(&args.account_id);
// Issue #1233 — drop the prewarm flag too so a future prewarm dispatch
// for the same id can re-attempt cleanly.
state
.prewarm_accounts
.lock()
.unwrap()
.remove(&args.account_id);
// Drop any gmeet workspace-rewrite counter for this label — labels are
// reused on reopen, so a stale entry from a closed-mid-loop session
// would saturate the next fresh open's window.
state.clear_gmeet_marketing_rewrite(&label);
log::info!("[webview-accounts] closed label={}", label);
Ok(())
}
/// Close the webview AND wipe its on-disk `data_directory` so cookies,
/// storage and cached credentials are forgotten. Use this for the
/// user-initiated "logout" action — `webview_account_close` keeps the
/// data dir intact so the next open restores the session.
#[tauri::command]
pub async fn webview_account_purge<R: Runtime>(
app: AppHandle<R>,
state: tauri::State<'_, WebviewAccountsState>,
args: AccountIdArgs,
) -> Result<(), String> {
// Close first so the native webview releases its file handles before we
// try to delete the data directory.
let label_opt = state.inner.lock().unwrap().remove(&args.account_id);
if let Some(label) = label_opt.as_ref() {
if let Some(wv) = app.get_webview(label) {
if let Err(e) = wv.close() {
log::warn!("[webview-accounts] purge close({label}) failed: {e}");
}
}
}
teardown_account_scanners(&app, &args.account_id);
if let Some(browser_id) = state.browser_ids.lock().unwrap().remove(&args.account_id) {
tauri_runtime_cef::notification::unregister(browser_id);
log::debug!(
"[notify-cef] purge unregistered handler account={} browser_id={}",
args.account_id,
browser_id
);
}
if let Some(task) = state.cdp_sessions.lock().unwrap().remove(&args.account_id) {
task.abort();
log::debug!(
"[cdp-session] purge aborted session task for account={}",
args.account_id
);
}
if let Some(task) = state
.load_watchdogs
.lock()
.unwrap()
.remove(&args.account_id)
{
task.abort();
log::debug!(
"[webview-accounts] purge aborted load watchdog for account={}",
args.account_id
);
}
state
.loaded_accounts
.lock()
.unwrap()
.remove(&args.account_id);
state
.requested_bounds
.lock()
.unwrap()
.remove(&args.account_id);
state
.spawn_started_at
.lock()
.unwrap()
.remove(&args.account_id);
state
.account_providers
.lock()
.unwrap()
.remove(&args.account_id);
// Issue #1233 — drop the prewarm flag too on purge.
state
.prewarm_accounts
.lock()
.unwrap()
.remove(&args.account_id);
if let Some(label) = label_opt.as_ref() {
state.clear_gmeet_marketing_rewrite(label);
// Drop any pending handoff flag for this label so a stale entry
// can't hijack the next genuine `myaccount.google.com` visit on
// a webview that re-uses the same label.
state.take_awaiting_gmeet_handoff(label);
}
let data_dir = data_directory_for(&app, &args.account_id)?;
purge_data_dir_with_retry(&data_dir)
.await
.map_err(|e| format!("purge data dir {}: {e}", data_dir.display()))?;
log::info!(
"[webview-accounts] purged account={} label={:?}",
args.account_id,
label_opt
);
Ok(())
}
/// CEF / WKWebView holds file handles briefly after `wv.close()` returns,
/// so a single `remove_dir_all` racing the close call routinely fails on
/// macOS and leaves the per-account cookie jar on disk. Re-adding the same
/// account after a logout then lands the user already signed in (#1076).
///
/// Retry the deletion a handful of times with exponential backoff so the
/// subprocess has a chance to drop its handles. Logs every attempt so a
/// stuck handle is diagnosable from the audit log.
async fn purge_data_dir_with_retry(data_dir: &std::path::Path) -> std::io::Result<()> {
if !data_dir.exists() {
return Ok(());
}
const MAX_ATTEMPTS: u32 = 5;
const INITIAL_BACKOFF_MS: u64 = 100;
let mut backoff = INITIAL_BACKOFF_MS;
for attempt in 1..=MAX_ATTEMPTS {
match std::fs::remove_dir_all(data_dir) {
Ok(()) => {
log::info!(
"[webview-accounts] purged data dir {} (attempt {}/{})",
data_dir.display(),
attempt,
MAX_ATTEMPTS
);
return Ok(());
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
log::info!(
"[webview-accounts] purge data dir {} already removed before attempt {}/{}",
data_dir.display(),
attempt,
MAX_ATTEMPTS
);
return Ok(());
}
Err(err) if attempt < MAX_ATTEMPTS => {
log::debug!(
"[webview-accounts] purge remove_dir_all {} attempt {}/{} failed: {} — retrying in {}ms",
data_dir.display(),
attempt,
MAX_ATTEMPTS,
err,
backoff
);
tokio::time::sleep(std::time::Duration::from_millis(backoff)).await;
backoff *= 2;
}
Err(err) => {
log::warn!(
"[webview-accounts] purge remove_dir_all {} failed after {} attempts: {} — cookies may persist; cross-launch fallback handled by schedule_cef_profile_purge",
data_dir.display(),
MAX_ATTEMPTS,
err
);
return Err(err);
}
}
}
Ok(())
}
#[tauri::command]
pub async fn webview_account_bounds<R: Runtime>(
app: AppHandle<R>,
state: tauri::State<'_, WebviewAccountsState>,
args: BoundsArgs,
) -> Result<(), String> {
let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned();
let Some(label) = label_opt else {
return Err(format!("no webview for account {}", args.account_id));
};
let wv = app
.get_webview(&label)
.ok_or_else(|| format!("webview {label} missing"))?;
wv.set_position(LogicalPosition::new(args.bounds.x, args.bounds.y))
.map_err(|e| format!("set_position: {e}"))?;
wv.set_size(LogicalSize::new(args.bounds.width, args.bounds.height))
.map_err(|e| format!("set_size: {e}"))?;
log::trace!(
"[webview-accounts] bounds label={} -> {:?}",
label,
args.bounds
);
// Keep the in-state bounds synced so `webview_account_reveal` has the
// latest rect even if the frontend's own cache is cleared between the
// `webview_account_open` call and the `webview-account:load` signal.
state
.requested_bounds
.lock()
.unwrap()
.insert(args.account_id.clone(), args.bounds);
Ok(())
}
/// Move an off-screen-spawned webview back to the frontend's desired rect and
/// show it. Invoked by the frontend when it receives the `webview-account:load`
/// event so the loading spinner is uncovered only after the page has painted.
///
/// Called as the final step of the first-open flow:
/// 1. `webview_account_open` — CEF subview spawned off-screen
/// 2. native `on_page_load` OR CDP `Page.loadEventFired` OR 15 s watchdog
/// 3. frontend listener → `webview_account_reveal`
#[tauri::command]
pub async fn webview_account_reveal<R: Runtime>(
app: AppHandle<R>,
state: tauri::State<'_, WebviewAccountsState>,
args: RevealArgs,
) -> Result<(), String> {
let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned();
let Some(label) = label_opt else {
// Reveal race: the webview was closed before the load event arrived.
// Return Ok so the frontend doesn't surface an error.
log::debug!(
"[webview-accounts] reveal: no webview for account {}",
args.account_id
);
return Ok(());
};
let wv = app
.get_webview(&label)
.ok_or_else(|| format!("webview {label} missing"))?;
wv.set_position(LogicalPosition::new(args.bounds.x, args.bounds.y))
.map_err(|e| format!("set_position: {e}"))?;
wv.set_size(LogicalSize::new(args.bounds.width, args.bounds.height))
.map_err(|e| format!("set_size: {e}"))?;
wv.show().map_err(|e| format!("show: {e}"))?;
state
.requested_bounds
.lock()
.unwrap()
.insert(args.account_id.clone(), args.bounds);
let provider = state
.account_providers
.lock()
.unwrap()
.get(&args.account_id)
.cloned()
.unwrap_or_else(|| "unknown".to_string());
let elapsed_ms = state
.spawn_started_at
.lock()
.unwrap()
.remove(&args.account_id)
.map(|started| started.elapsed().as_millis())
.map(|ms| ms.to_string())
.unwrap_or_else(|| "unknown".to_string());
let trigger = RevealTrigger::from_ipc(args.trigger.as_deref()).as_str();
log::info!(
"[webview-accounts][{}][{}] reveal trigger={} elapsed_ms={} bounds={:?}",
provider,
args.account_id,
trigger,
elapsed_ms,
args.bounds
);
Ok(())
}
#[tauri::command]
pub async fn webview_account_hide<R: Runtime>(
app: AppHandle<R>,
state: tauri::State<'_, WebviewAccountsState>,
args: AccountIdArgs,
) -> Result<(), String> {
let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned();
let Some(label) = label_opt else {
return Ok(());
};
if let Some(wv) = app.get_webview(&label) {
let _ = wv.hide();
log::debug!("[webview-accounts] hide label={}", label);
}
Ok(())
}
#[tauri::command]
pub async fn webview_account_show<R: Runtime>(
app: AppHandle<R>,
state: tauri::State<'_, WebviewAccountsState>,
args: AccountIdArgs,
) -> Result<(), String> {
let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned();
let Some(label) = label_opt else {
return Ok(());
};
if let Some(wv) = app.get_webview(&label) {
let _ = wv.show();
log::debug!("[webview-accounts] show label={}", label);
}
Ok(())
}
/// Web-shape notification permission state used by frontend parity code.
/// Effectively granted because interception is handled in-app via CEF.
#[tauri::command]
pub fn webview_notification_permission_state() -> String {
"granted".to_string()
}
/// Request notification permission and return web-shape state.
#[tauri::command]
pub fn webview_notification_permission_request() -> String {
webview_notification_permission_state()
}
/// Enable/disable global DND for embedded webview OS toasts.
#[tauri::command]
pub fn webview_notification_set_dnd(
state: tauri::State<'_, WebviewAccountsState>,
enabled: bool,
) -> Result<(), String> {
let mut prefs = state.notification_bypass.lock().unwrap();
prefs.global_dnd = enabled;
log::debug!("[notify-bypass] set global_dnd={enabled}");
Ok(())
}
/// Mute/unmute a specific embedded account for OS toasts.
#[tauri::command]
pub fn webview_notification_mute_account(
state: tauri::State<'_, WebviewAccountsState>,
account_id: String,
muted: bool,
) -> Result<(), String> {
let account_id = sanitize_account_id(&account_id)?.to_string();
let mut prefs = state.notification_bypass.lock().unwrap();
if muted {
prefs.muted_accounts.insert(account_id.clone());
} else {
prefs.muted_accounts.remove(&account_id);
}
log::debug!(
"[notify-bypass] set muted account_id={} muted={}",
account_id,
muted
);
Ok(())
}
/// Return current bypass preferences for the settings UI.
#[tauri::command]
pub fn webview_notification_get_bypass_prefs(
state: tauri::State<'_, WebviewAccountsState>,
) -> NotificationBypassPrefsPayload {
let prefs = state.notification_bypass.lock().unwrap();
NotificationBypassPrefsPayload::from(&*prefs)
}
/// Track which account is currently focused in the shell UI.
#[tauri::command]
pub fn webview_set_focused_account(
state: tauri::State<'_, WebviewAccountsState>,
account_id: Option<String>,
) -> Result<(), String> {
let mut prefs = state.notification_bypass.lock().unwrap();
prefs.focused_account = match account_id {
Some(id) => Some(sanitize_account_id(&id)?.to_string()),
None => None,
};
log::debug!(
"[notify-bypass] set focused_account={}",
prefs.focused_account.as_deref().unwrap_or("<none>")
);
Ok(())
}
/// Called from the injected runtime each time the recipe emits an event.
/// We forward to React via a Tauri event so the UI can render and persist.
#[tauri::command]
pub async fn webview_recipe_event<R: Runtime>(
app: AppHandle<R>,
webview: tauri::Webview<R>,
args: RecipeEventArgs,
) -> Result<(), String> {
// The event can only be trusted if the invoking webview is the
// `acct_<account_id>` webview for the account in the payload. A
// compromised renderer or a sibling child webview must not be able to
// forge events for another account.
let caller_label = webview.label().to_string();
let expected_label = label_for(&args.account_id);
if caller_label != expected_label {
log::warn!(
"[webview-accounts] recipe_event rejected: caller_label={} expected={} account={}",
caller_label,
expected_label,
args.account_id
);
return Err("webview label does not match account_id".to_string());
}
log::debug!(
"[webview-accounts] recipe_event account={} provider={} kind={}",
args.account_id,
args.provider,
args.kind
);
if args.provider == "google-meet" {
match args.kind.as_str() {
"meet_call_started" => {
let code = args
.payload
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("?");
log::info!("[gmeet][{}] call_started code={}", args.account_id, code);
}
"meet_captions" => {
let code = args
.payload
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("?");
let n = args
.payload
.get("captions")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(0);
log::info!(
"[gmeet][{}] captions code={} rows={}",
args.account_id,
code,
n
);
}
"meet_call_ended" => {
let code = args
.payload
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("?");
let reason = args
.payload
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
log::info!(
"[gmeet][{}] call_ended code={} reason={}",
args.account_id,
code,
reason
);
}
_ => {}
}
}
if args.kind == "ingest" {
if let Some(messages) = args.payload.get("messages").and_then(|v| v.as_array()) {
log::info!(
"[webview-accounts] ingest from acct_{}: {} messages",
args.account_id,
messages.len()
);
}
} else if args.kind == "ws_message" {
let direction = args
.payload
.get("direction")
.and_then(|v| v.as_str())
.unwrap_or("?");
let size = args
.payload
.get("size")
.and_then(|v| v.as_i64())
.unwrap_or(0);
log::trace!(
"[webview-accounts][{}] ws {} {} bytes",
args.account_id,
direction,
size
);
} else if args.kind == "log" {
let level = args
.payload
.get("level")
.and_then(|v| v.as_str())
.unwrap_or("info");
let msg = args
.payload
.get("msg")
.and_then(|v| v.as_str())
.unwrap_or("");
match level {
"warn" => log::warn!("[webview-accounts][{}] {}", args.account_id, msg),
"error" => log::error!("[webview-accounts][{}] {}", args.account_id, msg),
_ => log::info!("[webview-accounts][{}] {}", args.account_id, msg),
}
}
if let Err(err) = post_provider_surfaces_event(&args).await {
log::warn!(
"[webview-accounts] provider_surfaces ingest failed account={} provider={} kind={}: {}",
args.account_id,
args.provider,
args.kind,
err
);
}
let event = WebviewEvent {
account_id: args.account_id,
provider: args.provider,
kind: args.kind,
payload: args.payload,
ts: args.ts,
};
app.emit("webview:event", &event)
.map_err(|e| format!("emit failed: {e}"))?;
Ok(())
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;