Skip to main content

lingxia_webview/
events.rs

1//! Typed WebView delegate events: correlated navigation lifecycle, observable
2//! state snapshots, and the canonical derived-state folds every consumer must
3//! use instead of hand-rolled equivalents.
4
5pub(crate) mod normalizer;
6
7/// Register a read-only observer for a WebView's delivered events
8/// (automation waits, devtools). Observers run after the delegate, in
9/// registration order, on the same delivery drain.
10pub use normalizer::add_observer;
11
12use crate::traits::LoadError;
13use std::fmt;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU64, Ordering};
16
17/// Process-unique identity of one accepted top-level navigation attempt.
18///
19/// Allocated by the event normalizer from a process-wide monotonic sequence;
20/// never reused within a process, never persistent across launches.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub struct NavigationId(u64);
23
24static NAVIGATION_ID_SEQUENCE: AtomicU64 = AtomicU64::new(1);
25
26impl NavigationId {
27    /// Allocate the next process-wide id. Normalizer-internal.
28    pub(crate) fn next() -> Self {
29        Self(next_navigation_id(&NAVIGATION_ID_SEQUENCE))
30    }
31
32    pub fn get(self) -> u64 {
33        self.0
34    }
35
36    /// Construct an arbitrary id in consumer unit tests.
37    #[cfg(feature = "test-support")]
38    pub fn from_raw(raw: u64) -> Self {
39        Self(raw)
40    }
41}
42
43fn next_navigation_id(sequence: &AtomicU64) -> u64 {
44    sequence
45        .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
46            current.checked_add(1)
47        })
48        .expect("navigation identity space exhausted")
49}
50
51/// Formats as `nav#42` for logs and diagnostics.
52impl fmt::Display for NavigationId {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        write!(f, "nav#{}", self.0)
55    }
56}
57
58/// Why an active navigation attempt terminated without success or failure.
59/// Cancellation is control flow, not a load error: it must never surface
60/// error UI or count as a failed visit.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum NavigationCancellationReason {
63    /// A newer navigation replaced this attempt.
64    Superseded,
65    /// The caller explicitly stopped loading.
66    Stopped,
67    /// The WebView was destroyed while the attempt was active.
68    WebViewDestroyed,
69    /// The backend reported cancellation but cannot distinguish the cause.
70    Other,
71}
72
73/// Top-level navigation lifecycle. Every `Started` receives exactly one
74/// terminal `Succeeded`, `Failed`, or `Cancelled` with the same id.
75///
76/// - `requested_url` is the initially requested URL — non-empty, never
77///   updated on redirects, and not the final URL.
78/// - `Succeeded.final_url` is the non-empty top-level URL after redirects and
79///   is authoritative for persistence (`Location` state is authoritative for
80///   live display).
81/// - `Failed.error.failing_url` is the one authoritative failure URL.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum NavigationEvent {
84    Started {
85        id: NavigationId,
86        requested_url: String,
87    },
88    Succeeded {
89        id: NavigationId,
90        final_url: String,
91    },
92    Failed {
93        id: NavigationId,
94        error: LoadError,
95    },
96    Cancelled {
97        id: NavigationId,
98        reason: NavigationCancellationReason,
99    },
100}
101
102impl NavigationEvent {
103    pub fn id(&self) -> NavigationId {
104        match self {
105            NavigationEvent::Started { id, .. }
106            | NavigationEvent::Succeeded { id, .. }
107            | NavigationEvent::Failed { id, .. }
108            | NavigationEvent::Cancelled { id, .. } => *id,
109        }
110    }
111
112    pub fn is_terminal(&self) -> bool {
113        !matches!(self, NavigationEvent::Started { .. })
114    }
115}
116
117/// Observable WebView state snapshots. Not lifecycle transitions: `Location`
118/// alone is never evidence of a successful visit, and `None` explicitly
119/// clears a previously reported title/favicon (empty strings and empty byte
120/// arrays are not sentinels).
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum WebViewStateChange {
123    Location {
124        url: String,
125    },
126    Title {
127        /// `None` means the current document has no reported title.
128        title: Option<String>,
129    },
130    Favicon {
131        /// PNG bytes. `None` explicitly clears a previously reported favicon.
132        png_bytes: Option<Vec<u8>>,
133    },
134    BackForwardAvailability {
135        can_go_back: bool,
136        can_go_forward: bool,
137    },
138}
139
140/// A borrowed view of one delivered event, for read-only observers
141/// (automation waits, devtools) that watch a WebView without owning it.
142/// (`WebViewEvent` is taken by the creation-stage event in `webview.rs`.)
143pub enum WebViewObservedEvent<'a> {
144    Navigation(&'a NavigationEvent),
145    State(&'a WebViewStateChange),
146}
147
148/// Read-only event observer. Observers run after the delegate returns, in
149/// registration order, on the same delivery drain; they cannot affect
150/// delivery and must not block.
151pub type WebViewEventObserver = Arc<dyn Fn(WebViewObservedEvent<'_>) + Send + Sync>;
152
153/// Attempt bookkeeping every consumer otherwise re-implements: because
154/// attempts may overlap (WebView2), a terminal event for an older attempt
155/// must not clear loading UI for the newest one.
156#[derive(Debug, Default)]
157pub struct NavigationProgress {
158    newest: Option<NavigationId>,
159    newest_terminal: bool,
160}
161
162impl NavigationProgress {
163    /// Fold one event into the progress state.
164    pub fn apply(&mut self, event: &NavigationEvent) {
165        match event {
166            NavigationEvent::Started { id, .. } => {
167                self.newest = Some(*id);
168                self.newest_terminal = false;
169            }
170            terminal => {
171                if self.newest == Some(terminal.id()) {
172                    self.newest_terminal = true;
173                }
174            }
175        }
176    }
177
178    /// True while the newest attempt has no terminal event.
179    pub fn is_loading(&self) -> bool {
180        self.newest.is_some() && !self.newest_terminal
181    }
182
183    /// The newest attempt, until its terminal arrives.
184    pub fn current(&self) -> Option<NavigationId> {
185        if self.newest_terminal {
186            None
187        } else {
188            self.newest
189        }
190    }
191
192    /// Whether `id` is the newest attempt (terminal or not).
193    pub fn is_current(&self, id: NavigationId) -> bool {
194        self.newest == Some(id)
195    }
196
197    /// Folds `event` in and classifies it for a delegate that only acts on the
198    /// newest attempt. Every consumer needs the same rule — a stale terminal
199    /// must never mark a newer load as loaded or failed — so it lives here
200    /// rather than being re-derived per delegate.
201    pub fn classify<'a>(&mut self, event: &'a NavigationEvent) -> NavigationOutcome<'a> {
202        self.apply(event);
203        match event {
204            NavigationEvent::Started { requested_url, .. } => {
205                NavigationOutcome::Started { requested_url }
206            }
207            NavigationEvent::Succeeded { id, final_url } if self.is_current(*id) => {
208                NavigationOutcome::Loaded { final_url }
209            }
210            NavigationEvent::Failed { id, error } if self.is_current(*id) => {
211                NavigationOutcome::Failed { error }
212            }
213            // Cancellation is control flow, and a superseded attempt's terminal
214            // belongs to a document nobody is showing any more.
215            _ => NavigationOutcome::Superseded,
216        }
217    }
218}
219
220/// What one navigation event means to a delegate, once stale attempts have
221/// been filtered out.
222#[derive(Debug, PartialEq, Eq)]
223pub enum NavigationOutcome<'a> {
224    Started { requested_url: &'a str },
225    Loaded { final_url: &'a str },
226    Failed { error: &'a LoadError },
227    Superseded,
228}
229
230/// Fold of `WebViewStateChange` into the current observed state, including
231/// the `None`-clears semantics, so all consumers interpret clearing the same
232/// way.
233#[derive(Debug, Clone, Default, PartialEq, Eq)]
234pub struct ObservedWebViewState {
235    pub url: Option<String>,
236    pub title: Option<String>,
237    pub favicon_png: Option<Vec<u8>>,
238    pub can_go_back: bool,
239    pub can_go_forward: bool,
240}
241
242impl ObservedWebViewState {
243    /// Fold one change into the state. Takes the change by value so owned
244    /// payloads are retained without cloning.
245    pub fn apply(&mut self, change: WebViewStateChange) {
246        match change {
247            WebViewStateChange::Location { url } => self.url = Some(url),
248            WebViewStateChange::Title { title } => self.title = title,
249            WebViewStateChange::Favicon { png_bytes } => self.favicon_png = png_bytes,
250            WebViewStateChange::BackForwardAvailability {
251                can_go_back,
252                can_go_forward,
253            } => {
254                self.can_go_back = can_go_back;
255                self.can_go_forward = can_go_forward;
256            }
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::traits::{LoadError, LoadErrorKind};
265
266    #[test]
267    fn navigation_ids_exhaust_instead_of_wrapping() {
268        let sequence = AtomicU64::new(u64::MAX - 1);
269        assert_eq!(next_navigation_id(&sequence), u64::MAX - 1);
270        assert_eq!(sequence.load(Ordering::Relaxed), u64::MAX);
271        assert!(
272            std::panic::catch_unwind(|| next_navigation_id(&sequence)).is_err(),
273            "an exhausted process-wide identity must never wrap"
274        );
275        assert_eq!(sequence.load(Ordering::Relaxed), u64::MAX);
276    }
277
278    fn id(raw: u64) -> NavigationId {
279        NavigationId(raw)
280    }
281
282    fn started(raw: u64) -> NavigationEvent {
283        NavigationEvent::Started {
284            id: id(raw),
285            requested_url: format!("https://example.com/{raw}"),
286        }
287    }
288
289    fn succeeded(raw: u64) -> NavigationEvent {
290        NavigationEvent::Succeeded {
291            id: id(raw),
292            final_url: format!("https://example.com/{raw}"),
293        }
294    }
295
296    #[test]
297    fn classify_reports_the_current_attempt_only() {
298        let mut progress = NavigationProgress::default();
299        assert_eq!(
300            progress.classify(&started(1)),
301            NavigationOutcome::Started {
302                requested_url: "https://example.com/1",
303            }
304        );
305        assert_eq!(
306            progress.classify(&succeeded(1)),
307            NavigationOutcome::Loaded {
308                final_url: "https://example.com/1",
309            }
310        );
311
312        // A second attempt supersedes the first, so the first's terminal is
313        // no longer authoritative for anything.
314        progress.classify(&started(2));
315        assert_eq!(
316            progress.classify(&succeeded(1)),
317            NavigationOutcome::Superseded
318        );
319    }
320
321    #[test]
322    fn classify_treats_cancellation_as_control_flow() {
323        let mut progress = NavigationProgress::default();
324        progress.classify(&started(1));
325        assert_eq!(
326            progress.classify(&NavigationEvent::Cancelled {
327                id: id(1),
328                reason: NavigationCancellationReason::Superseded,
329            }),
330            NavigationOutcome::Superseded
331        );
332    }
333
334    #[test]
335    fn navigation_id_displays_for_diagnostics() {
336        assert_eq!(id(42).to_string(), "nav#42");
337    }
338
339    #[test]
340    fn progress_tracks_single_attempt() {
341        let mut progress = NavigationProgress::default();
342        assert!(!progress.is_loading());
343        progress.apply(&started(1));
344        assert!(progress.is_loading());
345        assert_eq!(progress.current(), Some(id(1)));
346        progress.apply(&succeeded(1));
347        assert!(!progress.is_loading());
348        assert_eq!(progress.current(), None);
349        assert!(progress.is_current(id(1)));
350    }
351
352    #[test]
353    fn terminal_for_older_attempt_keeps_newest_loading() {
354        let mut progress = NavigationProgress::default();
355        progress.apply(&started(1));
356        progress.apply(&started(2));
357        progress.apply(&NavigationEvent::Cancelled {
358            id: id(1),
359            reason: NavigationCancellationReason::Superseded,
360        });
361        assert!(progress.is_loading());
362        assert_eq!(progress.current(), Some(id(2)));
363        assert!(!progress.is_current(id(1)));
364    }
365
366    #[test]
367    fn failed_terminal_ends_loading_for_current_attempt() {
368        let mut progress = NavigationProgress::default();
369        progress.apply(&started(1));
370        progress.apply(&NavigationEvent::Failed {
371            id: id(1),
372            error: LoadError {
373                failing_url: Some("https://example.com/1".into()),
374                kind: LoadErrorKind::Network,
375                description: "boom".into(),
376            },
377        });
378        assert!(!progress.is_loading());
379    }
380
381    #[test]
382    fn observed_state_applies_none_clears() {
383        let mut state = ObservedWebViewState::default();
384        state.apply(WebViewStateChange::Title {
385            title: Some("Example".into()),
386        });
387        state.apply(WebViewStateChange::Favicon {
388            png_bytes: Some(vec![1, 2, 3]),
389        });
390        state.apply(WebViewStateChange::Location {
391            url: "https://example.com/".into(),
392        });
393        state.apply(WebViewStateChange::BackForwardAvailability {
394            can_go_back: true,
395            can_go_forward: false,
396        });
397        assert_eq!(state.title.as_deref(), Some("Example"));
398        assert_eq!(state.favicon_png.as_deref(), Some(&[1u8, 2, 3][..]));
399        assert!(state.can_go_back);
400
401        state.apply(WebViewStateChange::Title { title: None });
402        state.apply(WebViewStateChange::Favicon { png_bytes: None });
403        assert_eq!(state.title, None);
404        assert_eq!(state.favicon_png, None);
405        assert_eq!(state.url.as_deref(), Some("https://example.com/"));
406    }
407}