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