Skip to main content

tauri_runtime_cef/
frame_navigation.rs

1// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5//! Native document generations shared by every CEF webview.
6
7use std::collections::BTreeMap;
8use std::collections::BTreeSet;
9
10use std::sync::{Arc, Mutex};
11
12const MAX_NATIVE_FRAMES: usize = 256;
13const MAX_NATIVE_FRAME_ID_BYTES: usize = 512;
14
15#[derive(Debug, Default)]
16struct NativeFrameState {
17  browser_id: Option<i32>,
18  generation: u64,
19  exhausted: bool,
20  loading: bool,
21  observed_load_state: bool,
22  main_frame: Option<String>,
23  frames: BTreeMap<String, bool>,
24}
25
26/// Read-only navigation state for one exact native CEF browser lifetime. The CEF UI thread
27/// advances it before input submission can be admitted on that same thread.
28/// No lock is held while calling CEF or awaiting a renderer response.
29#[derive(Clone, Debug)]
30pub struct FrameNavigationState {
31  state: Arc<Mutex<NativeFrameState>>,
32}
33
34/// Opaque process-local proof of an observed native browser/document lifetime.
35/// Compare it with `WebviewSnapshot::document` in the final UI-thread callback
36/// before an effect. A token alone does not authorize an account or profile.
37#[derive(Clone)]
38pub struct NativeDocumentToken {
39  state: Arc<Mutex<NativeFrameState>>,
40  generation: u64,
41}
42
43impl PartialEq for NativeDocumentToken {
44  fn eq(&self, other: &Self) -> bool {
45    Arc::ptr_eq(&self.state, &other.state) && self.generation == other.generation
46  }
47}
48impl Eq for NativeDocumentToken {}
49
50impl std::fmt::Debug for NativeDocumentToken {
51  fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52    formatter
53      .debug_struct("NativeDocumentToken")
54      .finish_non_exhaustive()
55  }
56}
57
58impl FrameNavigationState {
59  pub(crate) fn new() -> Self {
60    Self {
61      state: Arc::default(),
62    }
63  }
64
65  /// Identifies the same native browser lifetime independently of navigation.
66  /// A replacement browser never matches, even if a caller reuses its label.
67  pub fn is_same_browser(&self, other: &Self) -> bool {
68    Arc::ptr_eq(&self.state, &other.state)
69  }
70
71  pub(crate) fn has_browser_id(&self, browser_id: i32) -> bool {
72    self
73      .state
74      .lock()
75      .is_ok_and(|state| state.browser_id == Some(browser_id))
76  }
77
78  pub(crate) fn close(&self) {
79    if let Ok(mut state) = self.state.lock() {
80      state.exhausted = true;
81    }
82  }
83
84  pub(crate) fn observe_document(&self, browser: &cef::Browser) -> Option<NativeDocumentToken> {
85    use cef::{ImplBrowser, ImplFrame};
86    let generation = self.ready_generation()?;
87    if browser.is_valid() == 0 || browser.frame_count() > MAX_NATIVE_FRAMES {
88      return None;
89    }
90    let mut identifiers = cef::CefStringList::new();
91    browser.frame_identifiers(Some(&mut identifiers));
92    // CEF keeps listing a frame parked in the back/forward cache, detached from
93    // any renderer - Chrome style parks the internal initial document there on
94    // the first navigation. Tracking drops a frame on detach, so only attached
95    // frames are compared; a restored frame reattaches, advancing the generation.
96    let attached = identifiers
97      .into_iter()
98      .filter(|id| {
99        browser
100          .frame_by_identifier(Some(&cef::CefString::from(id.as_str())))
101          .is_some_and(|frame| frame.is_valid() != 0)
102      })
103      .collect();
104    self
105      .admits_native_frames(
106        generation,
107        browser.identifier(),
108        browser.is_loading() != 0,
109        &attached,
110      )
111      .then(|| NativeDocumentToken {
112        state: Arc::clone(&self.state),
113        generation,
114      })
115  }
116
117  /// Captures an observed document only when every known frame is attached and
118  /// native load completion has been observed. Compare with the final native
119  /// snapshot before dispatch; this read does not query current CEF frame IDs.
120  pub fn document(&self) -> Option<NativeDocumentToken> {
121    self
122      .ready_generation()
123      .map(|generation| NativeDocumentToken {
124        state: Arc::clone(&self.state),
125        generation,
126      })
127  }
128
129  fn ready_generation(&self) -> Option<u64> {
130    let state = self.state.lock().ok()?;
131    let ready = Self::ready(&state);
132    if !ready {
133      log::debug!(
134        "native document unavailable: exhausted={}, load_observed={}, loading={}, frames={}, attached={}, main_attached={}",
135        state.exhausted,
136        state.observed_load_state,
137        state.loading,
138        state.frames.len(),
139        state.frames.values().filter(|attached| **attached).count(),
140        state
141          .main_frame
142          .as_ref()
143          .is_some_and(|id| state.frames.get(id) == Some(&true)),
144      );
145    }
146    ready.then_some(state.generation)
147  }
148
149  fn ready(state: &NativeFrameState) -> bool {
150    !state.exhausted
151      && state.observed_load_state
152      && !state.loading
153      && !state.frames.is_empty()
154      // Exceeding the tracked frame cap fails admission closed for as long as
155      // it lasts, matching `observe_document`'s native `frame_count` guard.
156      // Frames keep being tracked exactly, so draining below the cap recovers.
157      && state.frames.len() <= MAX_NATIVE_FRAMES
158      && state.frames.values().all(|attached| *attached)
159      && state
160        .main_frame
161        .as_ref()
162        .is_some_and(|id| state.frames.get(id) == Some(&true))
163  }
164
165  pub(crate) fn on_frame_event(&self, event: &crate::FrameEvent) {
166    self.apply(
167      event.browser_id,
168      &event.frame_id,
169      event.is_main,
170      &event.kind,
171    );
172  }
173
174  fn apply(&self, browser_id: i32, frame_id: &str, is_main: bool, kind: &crate::FrameEventKind) {
175    use crate::FrameEventKind;
176
177    let Ok(mut state) = self.state.lock() else {
178      return;
179    };
180    if state.exhausted {
181      return;
182    }
183    // Runtime-managed popups can inherit an opener's event handlers. Their
184    // browser identity never advances or grants authority over the opener.
185    if state
186      .browser_id
187      .is_some_and(|expected| expected != browser_id)
188    {
189      return;
190    }
191    // Bind the native browser identity before any rejection below can latch
192    // exhaustion. IPC admission and event delivery are gated on this identity,
193    // so an unexpected first notification must never leave it unrecorded.
194    state.browser_id = Some(browser_id);
195    let browser_wide = matches!(
196      kind,
197      FrameEventKind::MainFrameChanged
198        | FrameEventKind::LoadingStateChanged { .. }
199        | FrameEventKind::RendererTerminated
200    );
201    if frame_id.is_empty() && !browser_wide || frame_id.len() > MAX_NATIVE_FRAME_ID_BYTES {
202      log::warn!(
203        "native frame tracking exhausted: invalid identifier, empty={}, length={}, main={}, teardown={}",
204        frame_id.is_empty(),
205        frame_id.len(),
206        is_main,
207        matches!(kind, FrameEventKind::Detached | FrameEventKind::Destroyed),
208      );
209      state.exhausted = true;
210      return;
211    }
212    let Some(generation) = state.generation.checked_add(1) else {
213      state.exhausted = true;
214      return;
215    };
216    state.generation = generation;
217    match kind {
218      FrameEventKind::Created => {
219        state.frames.insert(frame_id.to_string(), false);
220      }
221      FrameEventKind::Attached => {
222        state.frames.insert(frame_id.to_string(), true);
223      }
224      FrameEventKind::Detached | FrameEventKind::Destroyed => {
225        state.frames.remove(frame_id);
226        if state.main_frame.as_deref() == Some(frame_id) {
227          state.main_frame = None;
228        }
229      }
230      FrameEventKind::MainFrameChanged => {
231        state.main_frame = is_main.then(|| frame_id.to_string());
232      }
233      FrameEventKind::NavigationStarted { .. } => {
234        state.loading = true;
235      }
236      FrameEventKind::LoadingStateChanged { is_loading } => {
237        state.observed_load_state = true;
238        state.loading = *is_loading;
239      }
240      FrameEventKind::DocumentCommitted { .. }
241      | FrameEventKind::NavigationFailed { .. }
242      | FrameEventKind::AddressChanged { .. } => {}
243      FrameEventKind::RendererTerminated => {
244        state.frames.clear();
245        state.main_frame = None;
246        state.loading = true;
247        state.observed_load_state = false;
248      }
249    }
250  }
251
252  /// Final native admission compares the exact current CEF frame identities,
253  /// not just their count. A replacement cannot reuse a document capability.
254  pub(crate) fn admits_native_frames(
255    &self,
256    expected: u64,
257    browser_id: i32,
258    is_loading: bool,
259    frame_ids: &BTreeSet<String>,
260  ) -> bool {
261    let Ok(state) = self.state.lock() else {
262      return false;
263    };
264    Self::ready(&state)
265      && state.generation == expected
266      && state.browser_id == Some(browser_id)
267      && !is_loading
268      && state.frames.keys().eq(frame_ids.iter())
269  }
270}
271
272#[cfg(test)]
273mod tests {
274  use crate::FrameEventKind as Event;
275
276  use super::*;
277
278  fn apply(barrier: &FrameNavigationState, frame: &str, kind: Event) {
279    barrier.apply(1, frame, frame == "main", &kind);
280  }
281
282  fn ready_main() -> FrameNavigationState {
283    let barrier = FrameNavigationState::new();
284    apply(&barrier, "main", Event::Created);
285    apply(&barrier, "main", Event::MainFrameChanged);
286    apply(&barrier, "main", Event::Attached);
287    apply(
288      &barrier,
289      "main",
290      Event::LoadingStateChanged { is_loading: false },
291    );
292    barrier
293  }
294
295  fn admits(barrier: &FrameNavigationState, generation: u64, frames: &[&str]) -> bool {
296    barrier.admits_native_frames(
297      generation,
298      1,
299      false,
300      &frames.iter().map(|id| id.to_string()).collect(),
301    )
302  }
303
304  #[test]
305  fn child_navigation_replacement_and_detach_revoke_prior_documents() {
306    let barrier = ready_main();
307    let main_generation = barrier.ready_generation().unwrap();
308    assert!(admits(&barrier, main_generation, &["main"]));
309    apply(&barrier, "child-a", Event::Created);
310    assert!(barrier.ready_generation().is_none());
311    apply(&barrier, "child-a", Event::Attached);
312    let child_generation = barrier.ready_generation().unwrap();
313    assert!(!admits(&barrier, main_generation, &["main", "child-a"]));
314    assert!(admits(&barrier, child_generation, &["main", "child-a"]));
315    let url = url::Url::parse("https://example.test/frame").unwrap();
316    apply(
317      &barrier,
318      "child-a",
319      Event::NavigationStarted { url: url.clone() },
320    );
321    assert!(barrier.ready_generation().is_none());
322    apply(&barrier, "child-a", Event::DocumentCommitted { url });
323    assert!(barrier.ready_generation().is_none());
324    apply(
325      &barrier,
326      "main",
327      Event::LoadingStateChanged { is_loading: false },
328    );
329    assert!(!admits(&barrier, child_generation, &["main", "child-a"]));
330    let loaded_generation = barrier.ready_generation().unwrap();
331    assert!(admits(&barrier, loaded_generation, &["main", "child-a"]));
332    // Even an equal native frame count must reject different identities.
333    assert!(!admits(&barrier, loaded_generation, &["main", "child-b"]));
334    apply(&barrier, "child-a", Event::Detached);
335    apply(&barrier, "child-b", Event::Created);
336    apply(&barrier, "child-b", Event::Attached);
337    assert!(!admits(&barrier, loaded_generation, &["main", "child-b"]));
338    let replacement_generation = barrier.ready_generation().unwrap();
339    assert!(admits(
340      &barrier,
341      replacement_generation,
342      &["main", "child-b"]
343    ));
344    apply(&barrier, "child-b", Event::Detached);
345    assert!(!admits(&barrier, replacement_generation, &["main"]));
346    assert!(admits(
347      &barrier,
348      barrier.ready_generation().unwrap(),
349      &["main"]
350    ));
351  }
352
353  #[test]
354  fn pending_cancelled_navigation_waits_for_native_loading_completion() {
355    let barrier = ready_main();
356    let url = url::Url::parse("https://example.test/repeated").unwrap();
357    apply(
358      &barrier,
359      "main",
360      Event::NavigationStarted { url: url.clone() },
361    );
362    apply(
363      &barrier,
364      "main",
365      Event::NavigationStarted { url: url.clone() },
366    );
367    apply(&barrier, "main", Event::NavigationFailed { url });
368    assert!(barrier.ready_generation().is_none());
369    apply(
370      &barrier,
371      "main",
372      Event::LoadingStateChanged { is_loading: false },
373    );
374    assert!(barrier.ready_generation().is_some());
375  }
376
377  #[test]
378  fn foreign_browser_unobserved_frames_and_exhaustion_fail_closed() {
379    let barrier = ready_main();
380    let generation = barrier.ready_generation().unwrap();
381    barrier.apply(2, "popup", true, &Event::Created);
382    assert_eq!(barrier.ready_generation(), Some(generation));
383    assert!(!barrier.admits_native_frames(
384      generation,
385      2,
386      false,
387      &BTreeSet::from(["main".to_string()])
388    ));
389    assert!(!admits(&barrier, generation, &["main", "unobserved"]));
390    barrier.state.lock().unwrap().generation = u64::MAX;
391    apply(&barrier, "main", Event::Attached);
392    assert!(barrier.ready_generation().is_none());
393    apply(
394      &barrier,
395      "main",
396      Event::LoadingStateChanged { is_loading: false },
397    );
398    assert!(barrier.ready_generation().is_none());
399  }
400  #[test]
401  fn missing_main_frame_and_renderer_termination_revoke_prior_generations() {
402    let barrier = ready_main();
403    let generation = barrier.ready_generation().unwrap();
404    barrier.apply(1, "", false, &Event::MainFrameChanged);
405    assert!(barrier.ready_generation().is_none());
406    apply(&barrier, "main", Event::MainFrameChanged);
407    assert_ne!(barrier.ready_generation().unwrap(), generation);
408    barrier.apply(1, "", false, &Event::RendererTerminated);
409    assert!(barrier.ready_generation().is_none());
410    barrier.apply(
411      1,
412      "",
413      false,
414      &Event::LoadingStateChanged { is_loading: false },
415    );
416    assert!(barrier.ready_generation().is_none());
417    apply(&barrier, "replacement", Event::Created);
418    apply(&barrier, "replacement", Event::Attached);
419    barrier.apply(1, "replacement", true, &Event::MainFrameChanged);
420    assert_ne!(barrier.ready_generation().unwrap(), generation);
421  }
422  #[test]
423  fn document_tokens_cannot_cross_native_lifetimes_or_navigation() {
424    let first = ready_main();
425    let second = ready_main();
426    assert_eq!(first.ready_generation(), second.ready_generation());
427    assert!(first.is_same_browser(&first.clone()));
428    assert!(!first.is_same_browser(&second));
429    let before = first.document().unwrap();
430    assert_eq!(before, first.clone().document().unwrap());
431    assert_ne!(before, second.document().unwrap());
432    apply(&first, "child", Event::Created);
433    assert!(first.document().is_none());
434    apply(&first, "child", Event::Attached);
435    assert_ne!(before, first.document().unwrap());
436  }
437  #[test]
438  fn a_rejected_first_event_still_records_the_native_browser_identity() {
439    // `AddressChanged`/`NavigationFailed` tolerate a missing native frame, so a
440    // browser's very first notification can carry an empty identifier.
441    let url = url::Url::parse("https://example.test/frameless").unwrap();
442    let barrier = FrameNavigationState::new();
443    barrier.apply(1, "", false, &Event::AddressChanged { url });
444    // Document admission still fails closed, ...
445    assert!(barrier.document().is_none());
446    // ... but IPC and event delivery stay bound to this exact browser.
447    assert!(barrier.has_browser_id(1));
448    assert!(!barrier.has_browser_id(2));
449  }
450  #[test]
451  fn a_transient_frame_count_overflow_recovers_once_frames_drain() {
452    let barrier = ready_main();
453    for index in 0..MAX_NATIVE_FRAMES {
454      apply(&barrier, &format!("child-{index}"), Event::Created);
455      apply(&barrier, &format!("child-{index}"), Event::Attached);
456    }
457    assert!(barrier.ready_generation().is_none());
458    apply(&barrier, "child-0", Event::Detached);
459    assert!(barrier.ready_generation().is_some());
460    assert!(barrier.has_browser_id(1));
461  }
462}