fix(webview/slack): first-load + Google auth (#1036) (#1249)

This commit is contained in:
oxoxDev
2026-05-06 16:47:21 -07:00
committed by GitHub
parent 31cd0ab855
commit bbdb823eda
4 changed files with 359 additions and 27 deletions
+15 -3
View File
@@ -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
+307 -17
View File
@@ -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');
});
+12 -2
View File
@@ -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);
})