diff --git a/app/src-tauri/src/cdp/session.rs b/app/src-tauri/src/cdp/session.rs index 6d1b02f62..a4abd1a50 100644 --- a/app/src-tauri/src/cdp/session.rs +++ b/app/src-tauri/src/cdp/session.rs @@ -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( 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( 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 diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs index 5fd20d6ac..97fde1f73 100644 --- a/app/src-tauri/src/webview_accounts/mod.rs +++ b/app/src-tauri/src/webview_accounts/mod.rs @@ -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 { - 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 { // 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_` as the label). inner: Mutex>, + /// account_id -> provider id. Kept so late reveal/close paths can log + /// provider-scoped diagnostics without trusting frontend echo fields. + account_providers: Mutex>, /// 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>, + /// 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>, /// Runtime notification-bypass controls used by the settings UI. notification_bypass: Mutex, /// 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, +} + +#[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( account_id: &str, state: &str, url: &str, + trigger: RevealTrigger, ) { let Some(app_state) = app.try_state::() else { // No state => emit anyway so the frontend doesn't hang; best-effort. @@ -1397,7 +1480,12 @@ pub(crate) fn emit_load_finished( ); 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( } 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( 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( // 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( 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( 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( &page_load_account_id, "timeout", &page_load_real_url, + RevealTrigger::Load, ); return; } @@ -2174,6 +2268,7 @@ pub async fn webview_account_open( &page_load_account_id, "finished", url.as_str(), + RevealTrigger::Load, ); }); @@ -2260,6 +2355,19 @@ pub async fn webview_account_open( .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( .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( .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( pub async fn webview_account_reveal( app: AppHandle, 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( .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( diff --git a/app/src/services/__tests__/webviewAccountService.loadListener.test.ts b/app/src/services/__tests__/webviewAccountService.loadListener.test.ts index bf2d0967e..840121604 100644 --- a/app/src/services/__tests__/webviewAccountService.loadListener.test.ts +++ b/app/src/services/__tests__/webviewAccountService.loadListener.test.ts @@ -54,7 +54,11 @@ function seedAccount(): void { ); } -async function fireLoadEvent(payload: { state: string; url?: string }): Promise { +async function fireLoadEvent(payload: { + state: string; + trigger?: string; + url?: string; +}): Promise { 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'); }); diff --git a/app/src/services/webviewAccountService.ts b/app/src/services/webviewAccountService.ts index 91d34c3b2..e3f337feb 100644 --- a/app/src/services/webviewAccountService.ts +++ b/app/src/services/webviewAccountService.ts @@ -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); })