feat(accounts): loading overlay for first-time webview opens (#867) (#887)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-04-24 15:46:00 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Steven Enamakel
parent d2f045769f
commit fc4b97abc2
9 changed files with 785 additions and 36 deletions
@@ -15,7 +15,9 @@ allow = [
"unregister_dictation_hotkey",
"webview_account_open",
"webview_account_close",
"webview_account_purge",
"webview_account_bounds",
"webview_account_reveal",
"webview_account_hide",
"webview_account_show",
"webview_recipe_event",
+3 -1
View File
@@ -16,7 +16,9 @@ pub mod target;
pub use conn::CdpConn;
pub use emulation::{set_user_agent_override, UaSpec};
pub use session::{placeholder_marker, placeholder_url, spawn_session, target_url_fragment};
pub use session::{
placeholder_marker, placeholder_url, spawn_session, target_url_fragment, SpawnedSession,
};
pub use snapshot::Snapshot;
pub use target::{
browser_ws_url, connect_and_attach_matching, detach_session, find_page_target_where,
+77 -19
View File
@@ -17,16 +17,23 @@
use std::time::Duration;
use serde_json::json;
use tauri::{AppHandle, Runtime};
use tokio::task::JoinHandle;
use tokio::time::sleep;
use super::{browser_ws_url, find_page_target_where, set_user_agent_override, CdpConn, UaSpec};
use crate::webview_accounts::emit_load_finished;
/// Backoff between failed attach attempts / reconnects. Intentionally
/// short — once the webview is open, the target usually shows up within
/// 500ms.
const ATTACH_BACKOFF: Duration = Duration::from_secs(2);
/// Watchdog budget before we synthesise a `webview-account:load` event with
/// `state: "timeout"` so the frontend never holds its loading spinner open on
/// a flaky network. Matches the timeout documented in issue #867.
const LOAD_TIMEOUT: Duration = Duration::from_secs(15);
/// Returns the unique marker substring that the account's initial
/// placeholder URL contains so `Target.getTargets` can identify it.
pub fn placeholder_marker(account_id: &str) -> String {
@@ -56,21 +63,53 @@ fn target_matches_account_url(target_url: &str, account_id: &str) -> bool {
target_url.ends_with(&marker_fragment) || target_url.ends_with(&fragment)
}
/// Spawn the per-account CDP session. Returns immediately; the background
/// task keeps the session alive and retries on disconnect. Idempotent at
/// the call site — the caller is expected to only call this once per
/// `webview_account_open`.
///
/// **Shutdown**: returns the `JoinHandle` for the spawned loop so the
/// caller (`webview_account_close` / `webview_account_purge`) can
/// `abort()` it when the account goes away. Without abort the loop
/// would keep retrying `attach_to_target` against a vanished target
/// forever and accumulate across reopen cycles.
pub fn spawn_session(account_id: String, real_url: String) -> JoinHandle<()> {
tokio::spawn(async move { run_session_forever(account_id, real_url).await })
/// Per-account spawn result. Both handles are owned by `WebviewAccountsState`
/// (see `cdp_sessions` and `load_watchdogs`) so close/purge can abort each one
/// without leaking tasks across reopen cycles.
pub struct SpawnedSession {
pub session: JoinHandle<()>,
pub watchdog: JoinHandle<()>,
}
async fn run_session_forever(account_id: String, real_url: String) {
/// Spawn the per-account CDP session. Returns immediately; the background
/// task keeps the session alive and retries on disconnect. Also spawns a
/// 15 s watchdog task that fires a `webview-account:load{state:"timeout"}`
/// event if neither the native `on_page_load` nor CDP `Page.loadEventFired`
/// signals arrive in time.
///
/// Both `JoinHandle`s inside the returned [`SpawnedSession`] must be stored
/// by the caller and aborted on account close/purge to prevent task leaks
/// across reopen cycles.
pub fn spawn_session<R: Runtime>(
app: AppHandle<R>,
account_id: String,
real_url: String,
) -> SpawnedSession {
// Load-overlay watchdog — independent of the session loop. Emits a
// `timeout` signal after LOAD_TIMEOUT so the frontend's loading spinner
// is always released even if neither the native `on_page_load` nor the
// CDP `Page.loadEventFired` signal arrives (flaky network, provider
// blocking, CDP socket hiccup).
//
// `emit_load_finished` dedups via `WebviewAccountsState.loaded_accounts`
// so a late watchdog is a no-op once either signal has fired. The
// returned `JoinHandle` is stored in `WebviewAccountsState.load_watchdogs`
// and aborted on close/purge so a watchdog spawned for a vanished
// account can't fire a stale timeout against a freshly-reused id.
let watchdog = {
let app = app.clone();
let account_id = account_id.clone();
let real_url = real_url.clone();
tokio::spawn(async move {
sleep(LOAD_TIMEOUT).await;
emit_load_finished(&app, &account_id, "timeout", &real_url);
})
};
let session = tokio::spawn(async move { run_session_forever(app, account_id, real_url).await });
SpawnedSession { session, watchdog }
}
async fn run_session_forever<R: Runtime>(app: AppHandle<R>, account_id: String, real_url: String) {
log::info!(
"[cdp-session][{}] up real_url={} marker={}",
account_id,
@@ -81,7 +120,7 @@ async fn run_session_forever(account_id: String, real_url: String) {
// `/json/version`. The placeholder URL is trivial so this is quick.
sleep(Duration::from_millis(500)).await;
loop {
match run_session_cycle(&account_id, &real_url).await {
match run_session_cycle(&app, &account_id, &real_url).await {
Ok(()) => {
log::info!(
"[cdp-session][{}] session ended cleanly, reconnecting",
@@ -96,7 +135,11 @@ async fn run_session_forever(account_id: String, real_url: String) {
}
}
async fn run_session_cycle(account_id: &str, real_url: &str) -> Result<(), String> {
async fn run_session_cycle<R: Runtime>(
app: &AppHandle<R>,
account_id: &str,
real_url: &str,
) -> Result<(), String> {
let browser_ws = browser_ws_url().await?;
let mut cdp = CdpConn::open(&browser_ws).await?;
@@ -200,6 +243,12 @@ async fn run_session_cycle(account_id: &str, real_url: &str) -> Result<(), Strin
account_id
);
// Enable the Page domain so `Page.loadEventFired` reaches our
// `pump_events` callback below. Must happen BEFORE `Page.navigate` so
// the first top-level load event for the real provider URL isn't missed.
cdp.call("Page.enable", json!({}), Some(&session_id))
.await?;
// Drive the webview from the placeholder to the real provider URL.
// Fragment survives same-origin navigations so scanners can match on
// it indefinitely. Skip navigation if the target is already on the
@@ -226,10 +275,19 @@ async fn run_session_cycle(account_id: &str, real_url: &str) -> Result<(), Strin
// override reverts when we detach, so we intentionally block here.
// pump_events returns when the CDP ws closes (browser process exits
// or `Target.detachFromTarget` is called from elsewhere).
cdp.pump_events(&session_id, |_method, _params| {
// We don't subscribe to any domain here — the session exists
// purely to keep the UA override resident. Per-provider scanners
// attach their own sessions for Network / IndexedDB / DOMSnapshot.
//
// The callback emits `webview-account:load{state:"finished"}` on the
// first `Page.loadEventFired` as a belt-and-braces fallback to the
// native `WebviewBuilder::on_page_load` handler wired in
// `webview_account_open`. `emit_load_finished` dedups across both paths
// so the frontend only sees one signal per cold open.
let cb_app = app.clone();
let cb_account_id = account_id.to_string();
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);
}
})
.await
}
+1
View File
@@ -1049,6 +1049,7 @@ pub fn run() {
webview_accounts::webview_account_close,
webview_accounts::webview_account_purge,
webview_accounts::webview_account_bounds,
webview_accounts::webview_account_reveal,
webview_accounts::webview_account_hide,
webview_accounts::webview_account_show,
webview_accounts::webview_recipe_event,
+382 -10
View File
@@ -340,6 +340,21 @@ pub struct WebviewAccountsState {
/// close/purge so reopen cycles don't stack multiple live loops.
#[cfg(feature = "cef")]
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.
#[cfg(feature = "cef")]
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>>,
/// Runtime notification-bypass controls used by the settings UI.
notification_bypass: Mutex<NotificationBypassPrefs>,
}
@@ -773,6 +788,150 @@ pub struct WebviewEvent {
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.
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 — exactly once per account open. Called from the
/// three independent signals (native `WebviewBuilder::on_page_load`, CDP
/// `Page.loadEventFired`, 15 s watchdog) — the first one wins and the rest
/// short-circuit via `WebviewAccountsState.loaded_accounts`. Resetting
/// 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,
) {
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, "url": url}),
);
return;
};
let is_first = app_state
.loaded_accounts
.lock()
.unwrap()
.insert(account_id.to_string());
if !is_first {
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={} url={}",
account_id,
state,
redact_url_for_log(url)
);
if let Err(err) = app.emit(
"webview-account:load",
serde_json::json!({
"account_id": account_id,
"state": state,
"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.
@@ -949,6 +1108,11 @@ pub async fn webview_account_open<R: Runtime>(
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!(
@@ -956,6 +1120,26 @@ pub async fn webview_account_open<R: Runtime>(
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",
"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
@@ -1124,6 +1308,33 @@ pub async fn webview_account_open<R: Runtime>(
builder = builder.user_agent(ua);
}
// Wire the native page-load signal so the frontend can hide its spinner as
// soon as CEF's LoadHandler reports the main frame finished. Dedup against
// the CDP `Page.loadEventFired` subscription and the 15 s watchdog through
// `emit_load_finished` so we only fire the first winning signal per open.
//
// Skip `data:` URLs: the initial CDP placeholder also fires `Finished`
// before the real provider URL is navigated to — emitting on the
// placeholder would release the overlay too early and the user would
// briefly see the blank placeholder before the real page paints.
let page_load_app = app.clone();
let page_load_account_id = args.account_id.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;
}
emit_load_finished(
&page_load_app,
&page_load_account_id,
"finished",
url.as_str(),
);
});
let bounds = args.bounds.unwrap_or(Bounds {
x: 0.0,
y: 0.0,
@@ -1131,18 +1342,67 @@ pub async fn webview_account_open<R: Runtime>(
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.
// We use positive coords well below the parent window rather than large
// negative values so multi-monitor layouts stay well-defined.
//
// 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.
//
// Warm-open reuse returned earlier in this function, so this only
// affects the first cold spawn.
#[cfg(feature = "cef")]
let initial_size = if skip_cdp_for_debug {
LogicalSize::new(bounds.width, bounds.height)
} else {
LogicalSize::new(1.0, 1.0)
};
#[cfg(not(feature = "cef"))]
let initial_size = LogicalSize::new(bounds.width, bounds.height);
let initial_position = LogicalPosition::new(bounds.x, bounds.y);
// Remember the bounds the frontend wanted so `webview_account_reveal` has a
// rect to restore to even if the frontend's bounds cache is empty (e.g.
// after a page reload races the load event).
state
.requested_bounds
.lock()
.unwrap()
.insert(args.account_id.clone(), bounds);
// 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,
LogicalPosition::new(bounds.x, bounds.y),
LogicalSize::new(bounds.width, bounds.height),
)
.add_child(builder, initial_position, initial_size)
.map_err(|e| format!("add_child failed: {e}"))?;
log::info!(
"[webview-accounts] spawned label={} bounds={:?}",
"[webview-accounts] spawned label={} requested_bounds={:?} initial_size={:?}",
webview.label(),
bounds
bounds,
initial_size
);
state
@@ -1168,9 +1428,22 @@ pub async fn webview_account_open<R: Runtime>(
args.account_id
);
} else {
let handle = cdp::spawn_session(args.account_id.clone(), real_url_str.clone());
let mut sessions = state.cdp_sessions.lock().unwrap();
if let Some(old) = sessions.insert(args.account_id.clone(), handle) {
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();
}
}
@@ -1374,7 +1647,31 @@ pub async fn webview_account_close<R: Runtime>(
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);
log::info!("[webview-accounts] closed label={}", label);
Ok(())
}
@@ -1445,7 +1742,29 @@ pub async fn webview_account_purge<R: Runtime>(
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);
let data_dir = data_directory_for(&app, &args.account_id)?;
if data_dir.exists() {
@@ -1492,6 +1811,59 @@ pub async fn webview_account_bounds<R: Runtime>(
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: BoundsArgs,
) -> 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);
log::info!(
"[webview-accounts] revealed label={} -> {:?}",
label,
args.bounds
);
Ok(())
}
+30 -3
View File
@@ -6,7 +6,8 @@ import {
openWebviewAccount,
setWebviewAccountBounds,
} from '../../services/webviewAccountService';
import type { AccountProvider } from '../../types/accounts';
import { useAppSelector } from '../../store/hooks';
import type { AccountProvider, AccountStatus } from '../../types/accounts';
const log = debug('webview-accounts:host');
@@ -15,12 +16,19 @@ interface WebviewHostProps {
provider: AccountProvider;
}
const LOADING_STATUSES: ReadonlySet<AccountStatus> = new Set(['pending', 'loading']);
/**
* Reserves a rectangular slot in the React layout that the native child
* webview is glued to. We measure the placeholder's bounding rect and
* tell Rust to position the webview at the same spot. On unmount or
* route change the webview is hidden (not destroyed) so its session
* stays warm in the background.
*
* During the first-open cycle the CEF subview is parked off-screen by Rust so
* the React loading overlay below isn't covered by an empty native view. The
* overlay is dismissed when the `webview-account:load` event flips the account
* status out of `pending`/`loading`.
*/
const WebviewHost = ({ accountId, provider }: WebviewHostProps) => {
const ref = useRef<HTMLDivElement | null>(null);
@@ -28,6 +36,14 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => {
null
);
const openedRef = useRef(false);
const status = useAppSelector(s => s.accounts.accounts[accountId]?.status);
// Only render the spinner when the account is *actively* loading. We used
// to also treat `status === undefined` as loading, but that meant a host
// mounted for an account that's not in the store (e.g. a render race with
// `addAccount`) would spin forever. The brief microtask between mount and
// the `setAccountStatus('pending')` dispatch in `openWebviewAccount` is
// visually indistinguishable from no overlay, so this is safe.
const isLoading = status !== undefined && LOADING_STATUSES.has(status);
// Spawn / show + keep bounds synced on every layout change.
// IMPORTANT: both refs are reset on cleanup so switching accountIds
@@ -105,8 +121,19 @@ const WebviewHost = ({ accountId, provider }: WebviewHostProps) => {
<div
ref={ref}
className="relative h-full w-full overflow-hidden rounded-lg border border-stone-200 bg-stone-100"
aria-label={`webview host for account ${accountId}`}
/>
aria-label={`webview host for account ${accountId}`}>
{isLoading ? (
<div
data-testid={`webview-loading-${accountId}`}
className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-stone-500"
role="status"
aria-live="polite"
aria-label="Loading account">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-stone-300 border-t-stone-600" />
<span className="text-xs font-medium tracking-wide">Loading</span>
</div>
) : null}
</div>
);
};
@@ -0,0 +1,173 @@
import { invoke } from '@tauri-apps/api/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { store } from '../../store';
import { addAccount, resetAccountsState } from '../../store/accountsSlice';
import {
closeWebviewAccount,
openWebviewAccount,
setWebviewAccountBounds,
startWebviewAccountService,
stopWebviewAccountService,
} from '../webviewAccountService';
// Capture the handlers attached via `listen(...)` so tests can fire synthetic
// events and verify downstream behaviour without actually wiring Tauri IPC.
type EventHandler = (evt: { payload: unknown }) => void;
const listeners = new Map<string, EventHandler>();
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn().mockResolvedValue(undefined),
isTauri: vi.fn().mockReturnValue(true),
}));
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(async (event: string, handler: EventHandler) => {
listeners.set(event, handler);
return () => {
listeners.delete(event);
};
}),
}));
// The service pulls in heavy deps for unrelated flows (Meet transcript + core
// RPC). Stub them so the listener test doesn't drag the whole dependency graph
// through its setup.
vi.mock('../api/threadApi', () => ({ threadApi: { createNewThread: vi.fn() } }));
vi.mock('../chatService', () => ({ chatSend: vi.fn() }));
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
vi.mock('../notificationService', () => ({ ingestNotification: vi.fn() }));
const ACCOUNT_ID = 'acct-123';
function seedAccount(): void {
store.dispatch(resetAccountsState());
store.dispatch(
addAccount({
id: ACCOUNT_ID,
provider: 'telegram',
label: 'Test',
createdAt: new Date().toISOString(),
status: 'closed',
})
);
}
async function fireLoadEvent(payload: { state: 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 } });
// Drain to a macrotask so chained `.catch()` / `.then()` on the
// `invoke()` promise inside the handler also settle before we assert.
await new Promise(r => setTimeout(r, 0));
}
describe('webviewAccountService load listener', () => {
beforeEach(async () => {
listeners.clear();
stopWebviewAccountService();
// Tear down any per-account state left from the previous test (bounds
// cache + loading flag) before re-arming the listener for this one.
// `stopWebviewAccountService` already clears the module-level Maps;
// `closeWebviewAccount` is the no-Tauri-side close path (the invoke is
// mocked) and is here only as belt-and-braces.
await closeWebviewAccount(ACCOUNT_ID);
// Single mock reset so individual tests can rely on the `invoke`
// resolved-value config they set up after this hook returns.
vi.clearAllMocks();
seedAccount();
startWebviewAccountService();
});
it('transitions pending → loading on openWebviewAccount resolve', async () => {
const bounds = { x: 10, y: 20, width: 800, height: 600 };
await openWebviewAccount({ accountId: ACCOUNT_ID, provider: 'telegram', bounds });
expect(store.getState().accounts.accounts[ACCOUNT_ID]?.status).toBe('loading');
expect(vi.mocked(invoke)).toHaveBeenCalledWith(
'webview_account_open',
expect.objectContaining({
args: expect.objectContaining({ account_id: ACCOUNT_ID, provider: 'telegram' }),
})
);
});
it('reveals with cached bounds + flips to open on finished event', async () => {
const bounds = { x: 5, y: 15, width: 1024, height: 768 };
await openWebviewAccount({ accountId: ACCOUNT_ID, provider: 'telegram', bounds });
vi.mocked(invoke).mockClear();
await fireLoadEvent({ state: 'finished', url: 'https://web.telegram.org/' });
expect(vi.mocked(invoke)).toHaveBeenCalledWith('webview_account_reveal', {
args: { account_id: ACCOUNT_ID, bounds },
});
expect(store.getState().accounts.accounts[ACCOUNT_ID]?.status).toBe('open');
});
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 });
// Resize during loading — invoke should be skipped, cache should still update.
vi.mocked(invoke).mockClear();
const resized = { x: 0, y: 0, width: 1200, height: 900 };
await setWebviewAccountBounds(ACCOUNT_ID, resized);
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('webview_account_bounds', expect.anything());
await fireLoadEvent({ state: 'finished', url: 'x' });
expect(vi.mocked(invoke)).toHaveBeenCalledWith('webview_account_reveal', {
args: { account_id: ACCOUNT_ID, bounds: resized },
});
});
it('forwards bounds once loading is done', async () => {
const initial = { x: 0, y: 0, width: 800, height: 600 };
await openWebviewAccount({ accountId: ACCOUNT_ID, provider: 'telegram', bounds: initial });
await fireLoadEvent({ state: 'finished', url: 'x' });
vi.mocked(invoke).mockClear();
const next = { x: 10, y: 10, width: 900, height: 700 };
await setWebviewAccountBounds(ACCOUNT_ID, next);
expect(vi.mocked(invoke)).toHaveBeenCalledWith('webview_account_bounds', {
args: { account_id: ACCOUNT_ID, bounds: next },
});
});
it('still reveals on timeout fallback', 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: 'timeout', url: '' });
expect(vi.mocked(invoke)).toHaveBeenCalledWith('webview_account_reveal', {
args: { account_id: ACCOUNT_ID, bounds },
});
expect(store.getState().accounts.accounts[ACCOUNT_ID]?.status).toBe('open');
});
it('treats `reused` event as finished (warm re-open path)', 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: 'reused', url: 'https://web.telegram.org/' });
expect(vi.mocked(invoke)).toHaveBeenCalledWith('webview_account_reveal', {
args: { account_id: ACCOUNT_ID, bounds },
});
expect(store.getState().accounts.accounts[ACCOUNT_ID]?.status).toBe('open');
});
it('skips reveal when the account has already unmounted', async () => {
// Fire load event without ever having opened the account (no cached bounds).
vi.mocked(invoke).mockClear();
await fireLoadEvent({ state: 'finished', url: 'x' });
expect(vi.mocked(invoke)).not.toHaveBeenCalled();
});
});
+109 -2
View File
@@ -59,6 +59,22 @@ interface NotificationClickPayload {
provider: string;
}
interface WebviewAccountLoadPayload {
account_id: string;
// `'finished'` — native `on_page_load` or CDP `Page.loadEventFired` fired
// `'timeout'` — 15 s watchdog elapsed; reveal anyway so spinner isn't stuck
// `'reused'` — warm re-open of already-loaded account; reveal synchronously
state: 'finished' | 'timeout' | 'reused' | string;
url: string;
}
interface WebviewAccountBounds {
x: number;
y: number;
width: number;
height: number;
}
interface RecipeNotifyPayload {
title?: string;
body?: string;
@@ -70,9 +86,23 @@ interface RecipeNotifyPayload {
let unlisten: UnlistenFn | null = null;
let unlistenNotifyClick: UnlistenFn | null = null;
let unlistenLoad: UnlistenFn | null = null;
let started = false;
let permissionChecked = false;
// Last bounds the frontend handed to Rust per account. Updated on every
// `setWebviewAccountBounds` call (even when the invoke itself is skipped
// because the account is still loading). The `webview-account:load` listener
// reads back from here so it can issue `webview_account_reveal` with the
// correct rect without a second round-trip.
const lastBoundsByAccount = new Map<string, WebviewAccountBounds>();
// Track which accounts are still in their initial load cycle (spawned
// off-screen, waiting for the first page-loaded signal). Bounds updates for
// these are cached but NOT forwarded to Rust — moving the off-screen webview
// to the on-screen rect prematurely would defeat the loading overlay.
const loadingAccounts = new Set<string>();
export function startWebviewAccountService(): void {
if (started) return;
if (!isTauri()) {
@@ -100,6 +130,17 @@ export function startWebviewAccountService(): void {
} catch (err) {
errLog('failed to attach notification:click listener', err);
}
try {
// Rust emits `webview-account:load` from three independent signals
// (native `on_page_load`, CDP `Page.loadEventFired`, 15 s watchdog).
// It dedups server-side so we see exactly one event per cold open.
unlistenLoad = await listen<WebviewAccountLoadPayload>('webview-account:load', evt => {
handleWebviewAccountLoad(evt.payload);
});
log('webview-account:load listener attached');
} catch (err) {
errLog('failed to attach webview-account:load listener', err);
}
})();
}
@@ -112,9 +153,55 @@ export function stopWebviewAccountService(): void {
unlistenNotifyClick();
unlistenNotifyClick = null;
}
if (unlistenLoad) {
unlistenLoad();
unlistenLoad = null;
}
// Drop module-level state so a subsequent start (HMR / shutdown→restart)
// doesn't see stale per-account entries that survived the listener
// teardown. Otherwise an account whose webview was destroyed mid-load
// would resurface as "still loading" on restart and silently drop bounds
// updates because `loadingAccounts.has(...)` is true.
lastBoundsByAccount.clear();
loadingAccounts.clear();
started = false;
}
function handleWebviewAccountLoad(payload: WebviewAccountLoadPayload) {
const accountId = payload?.account_id;
if (!accountId) {
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);
loadingAccounts.delete(accountId);
// Rust already resized the webview to `requested_bounds` as part of
// `emit_load_finished`, so the native side is already correct. We still
// issue `webview_account_reveal` here as a belt-and-braces idempotent
// no-op: if the frontend bounds diverged from the Rust-stored ones (e.g.
// a resize landed during the load window) this reapplies the latest
// measured rect. When the cache is empty (host already unmounted) we
// simply skip.
//
// Dispatch `'open'` after the reveal settles (success or failure) so the
// spinner is only dismissed once the webview is actually positioned. On
// error we still flip to `'open'` so the spinner never hangs indefinitely —
// the webview will have been positioned server-side by `emit_load_finished`.
const bounds = lastBoundsByAccount.get(accountId);
if (bounds) {
invoke('webview_account_reveal', { args: { account_id: accountId, bounds } })
.catch(err => {
errLog('webview_account_reveal failed account=%s: %o', accountId, err);
})
.finally(() => {
store.dispatch(setAccountStatus({ accountId, status: 'open' }));
});
} else {
store.dispatch(setAccountStatus({ accountId, status: 'open' }));
}
}
function handleNotificationClick(payload: NotificationClickPayload) {
const accountId = payload?.account_id;
const provider = payload?.provider;
@@ -720,16 +807,23 @@ export async function openWebviewAccount(args: OpenAccountArgs): Promise<void> {
if (!isTauri()) throw new Error('webview accounts require the desktop app');
log('open account=%s provider=%s', args.accountId, args.provider);
store.dispatch(setAccountStatus({ accountId: args.accountId, status: 'pending' }));
lastBoundsByAccount.set(args.accountId, args.bounds);
loadingAccounts.add(args.accountId);
void ensureNotificationPermission();
try {
await invoke('webview_account_open', {
args: { account_id: args.accountId, provider: args.provider, bounds: args.bounds },
});
store.dispatch(setAccountStatus({ accountId: args.accountId, status: 'open' }));
// Rust confirmed `add_child`. The webview is spawned off-screen; keep us
// in the loading state until `webview-account:load` arrives (at which point
// the listener dispatches `'open'`). Warm re-opens are resolved by the
// `'reused'` event which the listener also handles.
store.dispatch(setAccountStatus({ accountId: args.accountId, status: 'loading' }));
void setFocusedAccount(args.accountId);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
errLog('open failed: %s', msg);
loadingAccounts.delete(args.accountId);
store.dispatch(
setAccountStatus({ accountId: args.accountId, status: 'error', lastError: msg })
);
@@ -739,9 +833,18 @@ export async function openWebviewAccount(args: OpenAccountArgs): Promise<void> {
export async function setWebviewAccountBounds(
accountId: string,
bounds: { x: number; y: number; width: number; height: number }
bounds: WebviewAccountBounds
): Promise<void> {
if (!isTauri()) return;
// Always keep the cache fresh — the load-event listener needs it whether or
// not we forward this particular call to Rust.
lastBoundsByAccount.set(accountId, bounds);
if (loadingAccounts.has(accountId)) {
// Webview is parked off-screen waiting for its first page-loaded signal.
// Skip the invoke so we don't drag the CEF subview back on-screen over
// the React loading overlay.
return;
}
try {
await invoke('webview_account_bounds', { args: { account_id: accountId, bounds } });
} catch (err) {
@@ -771,6 +874,8 @@ export async function closeWebviewAccount(accountId: string): Promise<void> {
if (!isTauri()) return;
log('close account=%s', accountId);
await flushMeetingIfAny(accountId, 'webview-closed');
lastBoundsByAccount.delete(accountId);
loadingAccounts.delete(accountId);
try {
await invoke('webview_account_close', { args: { account_id: accountId } });
store.dispatch(setAccountStatus({ accountId, status: 'closed' }));
@@ -787,6 +892,8 @@ export async function purgeWebviewAccount(accountId: string): Promise<void> {
if (!isTauri()) return;
log('purge account=%s', accountId);
await flushMeetingIfAny(accountId, 'webview-purged');
lastBoundsByAccount.delete(accountId);
loadingAccounts.delete(accountId);
try {
await invoke('webview_account_purge', { args: { account_id: accountId } });
store.dispatch(setAccountStatus({ accountId, status: 'closed' }));
+8 -1
View File
@@ -11,7 +11,14 @@ export type AccountProvider =
| 'zoom'
| 'browserscan';
export type AccountStatus = 'pending' | 'open' | 'error' | 'closed';
// Status lifecycle for an embedded webview account:
// 'pending' — openWebviewAccount invoked, Rust-side add_child not yet confirmed
// 'loading' — CEF child webview spawned off-screen, waiting for first page-loaded
// signal; WebviewHost shows its spinner
// 'open' — page loaded, webview_account_reveal completed, webview on-screen
// 'closed' — webview destroyed
// 'error' — open/reveal failed (lastError populated)
export type AccountStatus = 'pending' | 'loading' | 'open' | 'error' | 'closed';
export interface Account {
id: string;