mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
@@ -22,7 +22,7 @@ use tokio::task::JoinHandle;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use super::{browser_ws_url, find_page_target_where, CdpConn};
|
||||
use crate::webview_accounts::emit_load_finished;
|
||||
use crate::webview_accounts::{emit_load_finished, RevealTrigger};
|
||||
|
||||
/// Backoff between failed attach attempts / reconnects. Intentionally
|
||||
/// short — once the webview is open, the target usually shows up within
|
||||
@@ -161,7 +161,13 @@ pub fn spawn_session<R: Runtime>(
|
||||
let real_url = real_url.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(LOAD_TIMEOUT).await;
|
||||
emit_load_finished(&app, &account_id, "timeout", &real_url);
|
||||
emit_load_finished(
|
||||
&app,
|
||||
&account_id,
|
||||
"timeout",
|
||||
&real_url,
|
||||
RevealTrigger::Watchdog,
|
||||
);
|
||||
})
|
||||
};
|
||||
let session = tokio::spawn(async move { run_session_forever(app, account_id, real_url).await });
|
||||
@@ -449,7 +455,13 @@ async fn run_session_cycle<R: Runtime>(
|
||||
let cb_real_url = real_url.to_string();
|
||||
cdp.pump_events(&session_id, move |method, _params| {
|
||||
if method == "Page.loadEventFired" {
|
||||
emit_load_finished(&cb_app, &cb_account_id, "finished", &cb_real_url);
|
||||
emit_load_finished(
|
||||
&cb_app,
|
||||
&cb_account_id,
|
||||
"finished",
|
||||
&cb_real_url,
|
||||
RevealTrigger::Load,
|
||||
);
|
||||
}
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -89,7 +89,18 @@ fn provider_allowed_hosts(provider: &str) -> &'static [&'static str] {
|
||||
"whatsapp" => &["whatsapp.com", "whatsapp.net", "wa.me"],
|
||||
"telegram" => &["telegram.org", "t.me"],
|
||||
"linkedin" => &["linkedin.com", "licdn.com"],
|
||||
"slack" => &["slack.com", "slack-edge.com", "slackb.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",
|
||||
@@ -170,7 +181,7 @@ fn url_is_internal(provider: &str, url: &Url) -> bool {
|
||||
// "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 matches!(provider, "gmail" | "google-meet") && is_google_sso_host(host) {
|
||||
if (provider == "gmail" || provider_supports_google_sso(provider)) && is_google_sso_host(host) {
|
||||
return true;
|
||||
}
|
||||
let allowed = provider_allowed_hosts(provider);
|
||||
@@ -251,11 +262,27 @@ fn is_provider_native_deep_link_scheme(scheme: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// `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")
|
||||
}
|
||||
|
||||
/// `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 the
|
||||
/// embedded Google Meet webview: clicking the link issues
|
||||
/// 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
|
||||
@@ -263,7 +290,7 @@ fn is_provider_native_deep_link_scheme(scheme: &str) -> bool {
|
||||
/// 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 != "google-meet" {
|
||||
if !provider_supports_google_sso(provider) {
|
||||
return None;
|
||||
}
|
||||
if url.scheme() == "about" {
|
||||
@@ -279,9 +306,11 @@ fn popup_should_navigate_parent(provider: &str, url: &Url) -> Option<Url> {
|
||||
// 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 let Some(host) = url.host_str() {
|
||||
if host == "meet.google.com" {
|
||||
return Some(url.clone());
|
||||
if provider == "google-meet" {
|
||||
if let Some(host) = url.host_str() {
|
||||
if host == "meet.google.com" {
|
||||
return Some(url.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -589,6 +618,9 @@ pub fn provider_display_name(provider: &str) -> &'static str {
|
||||
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
|
||||
@@ -613,6 +645,13 @@ pub struct WebviewAccountsState {
|
||||
/// 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`
|
||||
@@ -777,6 +816,12 @@ impl WebviewAccountsState {
|
||||
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.
|
||||
@@ -1324,6 +1369,43 @@ pub struct BoundsArgs {
|
||||
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,
|
||||
@@ -1388,6 +1470,7 @@ pub(crate) fn emit_load_finished<R: Runtime>(
|
||||
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.
|
||||
@@ -1397,7 +1480,12 @@ pub(crate) fn emit_load_finished<R: Runtime>(
|
||||
);
|
||||
let _ = app.emit(
|
||||
"webview-account:load",
|
||||
serde_json::json!({"account_id": account_id, "state": state, "url": url}),
|
||||
serde_json::json!({
|
||||
"account_id": account_id,
|
||||
"state": state,
|
||||
"trigger": trigger.as_str(),
|
||||
"url": url,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
};
|
||||
@@ -1450,8 +1538,9 @@ pub(crate) fn emit_load_finished<R: Runtime>(
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[webview-accounts][{}] load timeout event url={}",
|
||||
"[webview-accounts][{}] load timeout event trigger={} url={}",
|
||||
account_id,
|
||||
trigger.as_str(),
|
||||
redact_url_for_log(url)
|
||||
);
|
||||
if let Err(err) = app.emit(
|
||||
@@ -1459,6 +1548,7 @@ pub(crate) fn emit_load_finished<R: Runtime>(
|
||||
serde_json::json!({
|
||||
"account_id": account_id,
|
||||
"state": state,
|
||||
"trigger": trigger.as_str(),
|
||||
"url": url,
|
||||
}),
|
||||
) {
|
||||
@@ -1544,9 +1634,10 @@ pub(crate) fn emit_load_finished<R: Runtime>(
|
||||
// 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={} url={}",
|
||||
"[webview-accounts][{}] load event state={} trigger={} url={}",
|
||||
account_id,
|
||||
state,
|
||||
trigger.as_str(),
|
||||
redact_url_for_log(url)
|
||||
);
|
||||
if let Err(err) = app.emit(
|
||||
@@ -1554,6 +1645,7 @@ pub(crate) fn emit_load_finished<R: Runtime>(
|
||||
serde_json::json!({
|
||||
"account_id": account_id,
|
||||
"state": state,
|
||||
"trigger": trigger.as_str(),
|
||||
"url": url,
|
||||
}),
|
||||
) {
|
||||
@@ -1755,6 +1847,7 @@ pub async fn webview_account_open<R: Runtime>(
|
||||
serde_json::json!({
|
||||
"account_id": args.account_id,
|
||||
"state": "reused",
|
||||
"trigger": RevealTrigger::Load.as_str(),
|
||||
"url": reuse_url,
|
||||
}),
|
||||
) {
|
||||
@@ -2166,6 +2259,7 @@ pub async fn webview_account_open<R: Runtime>(
|
||||
&page_load_account_id,
|
||||
"timeout",
|
||||
&page_load_real_url,
|
||||
RevealTrigger::Load,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2174,6 +2268,7 @@ pub async fn webview_account_open<R: Runtime>(
|
||||
&page_load_account_id,
|
||||
"finished",
|
||||
url.as_str(),
|
||||
RevealTrigger::Load,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2260,6 +2355,19 @@ pub async fn webview_account_open<R: Runtime>(
|
||||
.add_child(builder, initial_position, initial_size)
|
||||
.map_err(|e| format!("add_child failed: {e}"))?;
|
||||
|
||||
// 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(),
|
||||
@@ -2542,6 +2650,16 @@ pub async fn webview_account_close<R: Runtime>(
|
||||
.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
|
||||
@@ -2616,6 +2734,16 @@ pub async fn webview_account_purge<R: Runtime>(
|
||||
.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
|
||||
@@ -2749,7 +2877,7 @@ pub async fn webview_account_bounds<R: Runtime>(
|
||||
pub async fn webview_account_reveal<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: tauri::State<'_, WebviewAccountsState>,
|
||||
args: BoundsArgs,
|
||||
args: RevealArgs,
|
||||
) -> Result<(), String> {
|
||||
let label_opt = state.inner.lock().unwrap().get(&args.account_id).cloned();
|
||||
let Some(label) = label_opt else {
|
||||
@@ -2774,9 +2902,28 @@ pub async fn webview_account_reveal<R: Runtime>(
|
||||
.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] revealed label={} -> {:?}",
|
||||
label,
|
||||
"[webview-accounts][{}][{}] reveal trigger={} elapsed_ms={} bounds={:?}",
|
||||
provider,
|
||||
args.account_id,
|
||||
trigger,
|
||||
elapsed_ms,
|
||||
args.bounds
|
||||
);
|
||||
Ok(())
|
||||
@@ -3042,6 +3189,20 @@ mod tests {
|
||||
Url::parse(s).expect("valid url")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_trigger_from_ipc_warns_and_defaults_unknown_to_load() {
|
||||
assert_eq!(RevealTrigger::from_ipc(None), RevealTrigger::Load);
|
||||
assert_eq!(RevealTrigger::from_ipc(Some("load")), RevealTrigger::Load);
|
||||
assert_eq!(
|
||||
RevealTrigger::from_ipc(Some("watchdog")),
|
||||
RevealTrigger::Watchdog
|
||||
);
|
||||
assert_eq!(
|
||||
RevealTrigger::from_ipc(Some("watchdog-typo")),
|
||||
RevealTrigger::Load
|
||||
);
|
||||
}
|
||||
|
||||
// ── shutdown teardown ──────────────────────────────────
|
||||
|
||||
/// Smoke-test [`WebviewAccountsState::drain_for_shutdown`] in isolation
|
||||
@@ -3090,6 +3251,11 @@ mod tests {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct-1".into(), "acct_1".into());
|
||||
state
|
||||
.account_providers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct-1".into(), "slack".into());
|
||||
state
|
||||
.loaded_accounts
|
||||
.lock()
|
||||
@@ -3104,6 +3270,11 @@ mod tests {
|
||||
height: 600.0,
|
||||
},
|
||||
);
|
||||
state
|
||||
.spawn_started_at
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct-1".into(), Instant::now());
|
||||
// Saturate the gmeet rewrite counter so we can assert it gets
|
||||
// cleared by drain (otherwise the next reopen would inherit a
|
||||
// stale entry — `label_for()` reuses the same label).
|
||||
@@ -3129,8 +3300,10 @@ mod tests {
|
||||
assert!(state.load_watchdogs.lock().unwrap().is_empty());
|
||||
assert!(state.browser_ids.lock().unwrap().is_empty());
|
||||
assert!(state.inner.lock().unwrap().is_empty());
|
||||
assert!(state.account_providers.lock().unwrap().is_empty());
|
||||
assert!(state.loaded_accounts.lock().unwrap().is_empty());
|
||||
assert!(state.requested_bounds.lock().unwrap().is_empty());
|
||||
assert!(state.spawn_started_at.lock().unwrap().is_empty());
|
||||
assert!(
|
||||
state.gmeet_marketing_rewrites.lock().unwrap().is_empty(),
|
||||
"gmeet rewrite counter must clear on drain so reopens don't inherit stale entries"
|
||||
@@ -3141,6 +3314,7 @@ mod tests {
|
||||
assert!(labels2.is_empty());
|
||||
assert!(state.cdp_sessions.lock().unwrap().is_empty());
|
||||
assert!(state.inner.lock().unwrap().is_empty());
|
||||
assert!(state.account_providers.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ── provider registry match arms ──────────────────────────────────
|
||||
@@ -3167,6 +3341,58 @@ mod tests {
|
||||
assert!(hosts.contains(&"zdassets.com"), "zdassets.com in allowlist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_allowed_hosts_include_google_oauth() {
|
||||
let hosts = provider_allowed_hosts("slack");
|
||||
for host in [
|
||||
"accounts.google.com",
|
||||
"accounts.googleusercontent.com",
|
||||
"ssl.gstatic.com",
|
||||
"fonts.gstatic.com",
|
||||
"lh3.googleusercontent.com",
|
||||
"oauth2.googleapis.com",
|
||||
"www.googleapis.com",
|
||||
] {
|
||||
assert!(hosts.contains(&host), "{host} in Slack allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_allowed_hosts_still_internal_for_slack_origins() {
|
||||
assert!(url_is_internal(
|
||||
"slack",
|
||||
&url("https://app.slack.com/client/T123/C456"),
|
||||
));
|
||||
assert!(url_is_internal(
|
||||
"slack",
|
||||
&url("https://a.slack-edge.com/bv1/app.js"),
|
||||
));
|
||||
assert!(url_is_internal(
|
||||
"slack",
|
||||
&url("https://wss-primary.slack.com/?ticket=redacted"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_allowed_hosts_do_not_bare_allow_google() {
|
||||
let hosts = provider_allowed_hosts("slack");
|
||||
assert!(
|
||||
!hosts.contains(&"google.com"),
|
||||
"bare google.com not allowed"
|
||||
);
|
||||
assert!(!hosts.contains(&"googleusercontent.com"));
|
||||
assert!(!hosts.contains(&"gstatic.com"));
|
||||
assert!(!hosts.contains(&"googleapis.com"));
|
||||
|
||||
assert!(url_is_internal(
|
||||
"slack",
|
||||
&url("https://accounts.google.com/v3/signin/identifier"),
|
||||
));
|
||||
assert!(!url_is_internal("slack", &url("https://google.com/")));
|
||||
assert!(!url_is_internal("slack", &url("https://mail.google.com/")));
|
||||
assert!(!url_is_internal("slack", &url("https://apis.google.com/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_is_supported() {
|
||||
assert!(provider_is_supported("zoom"));
|
||||
@@ -3459,9 +3685,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unsupported_provider_popup_does_not_navigate_parent() {
|
||||
// Only the embedded google-meet webview opts into the
|
||||
// popup-takeover path. Every other provider (and any unknown
|
||||
// string) must fall through to the default popup-handling.
|
||||
// Only providers that explicitly support Google SSO opt into
|
||||
// the popup-takeover path. Every other provider (and any unknown
|
||||
// string) must fall through to the default popup handling.
|
||||
assert!(popup_should_navigate_parent(
|
||||
"linkedin",
|
||||
&url("https://accounts.google.com/signin/v2/identifier"),
|
||||
@@ -3478,6 +3704,46 @@ mod tests {
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_google_signin_popup_navigates_parent() {
|
||||
assert_eq!(
|
||||
popup_should_navigate_parent(
|
||||
"slack",
|
||||
&url("https://accounts.google.com/v3/signin/identifier"),
|
||||
)
|
||||
.map(|u| u.to_string()),
|
||||
Some("https://accounts.google.com/v3/signin/identifier".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_about_blank_popup_does_not_navigate_parent() {
|
||||
assert!(popup_should_navigate_parent("slack", &url("about:blank")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_same_origin_popup_does_not_navigate_parent() {
|
||||
assert!(popup_should_navigate_parent(
|
||||
"slack",
|
||||
&url("https://app.slack.com/client/T123/C456"),
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_unrelated_popup_does_not_navigate_parent() {
|
||||
assert!(popup_should_navigate_parent("slack", &url("https://example.com/blog"),).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_meet_google_com_popup_does_not_navigate_parent() {
|
||||
assert!(popup_should_navigate_parent(
|
||||
"slack",
|
||||
&url("https://meet.google.com/abc-defg-hij"),
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gmeet_room_popup_navigates_parent() {
|
||||
// "Start an instant meeting" / "New meeting" calls
|
||||
@@ -3528,6 +3794,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── provider_supports_google_sso ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn provider_supports_google_sso_matrix() {
|
||||
assert!(provider_supports_google_sso("google-meet"));
|
||||
assert!(provider_supports_google_sso("slack"));
|
||||
assert!(!provider_supports_google_sso("whatsapp"));
|
||||
assert!(!provider_supports_google_sso("telegram"));
|
||||
assert!(!provider_supports_google_sso("linkedin"));
|
||||
assert!(!provider_supports_google_sso("discord"));
|
||||
assert!(!provider_supports_google_sso("zoom"));
|
||||
assert!(!provider_supports_google_sso("browserscan"));
|
||||
assert!(!provider_supports_google_sso(""));
|
||||
assert!(!provider_supports_google_sso("unknown-provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn google_meet_service_login_popup_navigates_parent() {
|
||||
assert_eq!(
|
||||
@@ -3699,6 +3981,14 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_is_internal_allows_youtube_setsid_for_slack_google_sso() {
|
||||
assert!(url_is_internal(
|
||||
"slack",
|
||||
&url("https://accounts.youtube.com/accounts/SetSID?ssdc=1&continue=https://app.slack.com/"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_is_internal_allows_cctld_accounts_google_for_gmail() {
|
||||
assert!(url_is_internal(
|
||||
|
||||
@@ -54,7 +54,11 @@ function seedAccount(): void {
|
||||
);
|
||||
}
|
||||
|
||||
async function fireLoadEvent(payload: { state: string; url?: string }): Promise<void> {
|
||||
async function fireLoadEvent(payload: {
|
||||
state: string;
|
||||
trigger?: string;
|
||||
url?: string;
|
||||
}): Promise<void> {
|
||||
const handler = listeners.get('webview-account:load');
|
||||
if (!handler) throw new Error('webview-account:load listener not attached');
|
||||
handler({ payload: { account_id: ACCOUNT_ID, url: '', ...payload } });
|
||||
@@ -101,11 +105,27 @@ describe('webviewAccountService load listener', () => {
|
||||
await fireLoadEvent({ state: 'finished', url: 'https://web.telegram.org/' });
|
||||
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('webview_account_reveal', {
|
||||
args: { account_id: ACCOUNT_ID, bounds },
|
||||
args: { account_id: ACCOUNT_ID, bounds, trigger: 'load' },
|
||||
});
|
||||
expect(store.getState().accounts.accounts[ACCOUNT_ID]?.status).toBe('open');
|
||||
});
|
||||
|
||||
it('propagates watchdog trigger when a watchdog-originated finished signal arrives', async () => {
|
||||
const bounds = { x: 0, y: 0, width: 800, height: 600 };
|
||||
await openWebviewAccount({ accountId: ACCOUNT_ID, provider: 'telegram', bounds });
|
||||
vi.mocked(invoke).mockClear();
|
||||
|
||||
await fireLoadEvent({
|
||||
state: 'finished',
|
||||
trigger: 'watchdog',
|
||||
url: 'https://web.telegram.org/',
|
||||
});
|
||||
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('webview_account_reveal', {
|
||||
args: { account_id: ACCOUNT_ID, bounds, trigger: 'watchdog' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reveals with latest bounds when resize landed during loading', async () => {
|
||||
const initial = { x: 0, y: 0, width: 800, height: 600 };
|
||||
await openWebviewAccount({ accountId: ACCOUNT_ID, provider: 'telegram', bounds: initial });
|
||||
@@ -119,7 +139,7 @@ describe('webviewAccountService load listener', () => {
|
||||
await fireLoadEvent({ state: 'finished', url: 'x' });
|
||||
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('webview_account_reveal', {
|
||||
args: { account_id: ACCOUNT_ID, bounds: resized },
|
||||
args: { account_id: ACCOUNT_ID, bounds: resized, trigger: 'load' },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,7 +179,7 @@ describe('webviewAccountService load listener', () => {
|
||||
|
||||
await fireLoadEvent({ state: 'finished', url: 'https://web.telegram.org/' });
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('webview_account_reveal', {
|
||||
args: { account_id: ACCOUNT_ID, bounds },
|
||||
args: { account_id: ACCOUNT_ID, bounds, trigger: 'load' },
|
||||
});
|
||||
expect(store.getState().accounts.accounts[ACCOUNT_ID]?.status).toBe('open');
|
||||
});
|
||||
@@ -185,7 +205,7 @@ describe('webviewAccountService load listener', () => {
|
||||
await fireLoadEvent({ state: 'reused', url: 'https://web.telegram.org/' });
|
||||
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('webview_account_reveal', {
|
||||
args: { account_id: ACCOUNT_ID, bounds },
|
||||
args: { account_id: ACCOUNT_ID, bounds, trigger: 'load' },
|
||||
});
|
||||
expect(store.getState().accounts.accounts[ACCOUNT_ID]?.status).toBe('open');
|
||||
});
|
||||
|
||||
@@ -67,6 +67,9 @@ interface WebviewAccountLoadPayload {
|
||||
// `'timeout'` — 15 s watchdog elapsed; keep hidden and show retry UI
|
||||
// `'reused'` — warm re-open of already-loaded account; reveal synchronously
|
||||
state: 'finished' | 'timeout' | 'reused' | string;
|
||||
// `'load'` — native/CDP load signal caused this event
|
||||
// `'watchdog'` — fallback watchdog caused this event
|
||||
trigger?: 'load' | 'watchdog' | string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@@ -181,7 +184,13 @@ function handleWebviewAccountLoad(payload: WebviewAccountLoadPayload) {
|
||||
errLog('webview-account:load missing account_id — ignoring: %o', payload);
|
||||
return;
|
||||
}
|
||||
log('load event account=%s state=%s url=%s', accountId, payload.state, payload.url);
|
||||
log(
|
||||
'load event account=%s state=%s trigger=%s url=%s',
|
||||
accountId,
|
||||
payload.state,
|
||||
payload.trigger,
|
||||
payload.url
|
||||
);
|
||||
loadingAccounts.delete(accountId);
|
||||
|
||||
const timeoutLike =
|
||||
@@ -213,8 +222,9 @@ function handleWebviewAccountLoad(payload: WebviewAccountLoadPayload) {
|
||||
// the webview will have been positioned server-side by `emit_load_finished`.
|
||||
const bounds = lastBoundsByAccount.get(accountId);
|
||||
log('load finished account=%s state=%s reveal=%s', accountId, payload.state, Boolean(bounds));
|
||||
const trigger = payload.trigger === 'watchdog' ? 'watchdog' : 'load';
|
||||
if (bounds) {
|
||||
invoke('webview_account_reveal', { args: { account_id: accountId, bounds } })
|
||||
invoke('webview_account_reveal', { args: { account_id: accountId, bounds, trigger } })
|
||||
.catch(err => {
|
||||
errLog('webview_account_reveal failed account=%s: %o', accountId, err);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user