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
190/// Fold of `WebViewStateChange` into the current observed state, including
191/// the `None`-clears semantics, so all consumers interpret clearing the same
192/// way.
193#[derive(Debug, Clone, Default, PartialEq, Eq)]
194pub struct ObservedWebViewState {
195    pub url: Option<String>,
196    pub title: Option<String>,
197    pub favicon_png: Option<Vec<u8>>,
198    pub can_go_back: bool,
199    pub can_go_forward: bool,
200}
201
202impl ObservedWebViewState {
203    /// Fold one change into the state. Takes the change by value so owned
204    /// payloads are retained without cloning.
205    pub fn apply(&mut self, change: WebViewStateChange) {
206        match change {
207            WebViewStateChange::Location { url } => self.url = Some(url),
208            WebViewStateChange::Title { title } => self.title = title,
209            WebViewStateChange::Favicon { png_bytes } => self.favicon_png = png_bytes,
210            WebViewStateChange::BackForwardAvailability {
211                can_go_back,
212                can_go_forward,
213            } => {
214                self.can_go_back = can_go_back;
215                self.can_go_forward = can_go_forward;
216            }
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::traits::{LoadError, LoadErrorKind};
225
226    fn id(raw: u64) -> NavigationId {
227        NavigationId(raw)
228    }
229
230    fn started(raw: u64) -> NavigationEvent {
231        NavigationEvent::Started {
232            id: id(raw),
233            requested_url: format!("https://example.com/{raw}"),
234        }
235    }
236
237    fn succeeded(raw: u64) -> NavigationEvent {
238        NavigationEvent::Succeeded {
239            id: id(raw),
240            final_url: format!("https://example.com/{raw}"),
241        }
242    }
243
244    #[test]
245    fn navigation_id_displays_for_diagnostics() {
246        assert_eq!(id(42).to_string(), "nav#42");
247    }
248
249    #[test]
250    fn progress_tracks_single_attempt() {
251        let mut progress = NavigationProgress::default();
252        assert!(!progress.is_loading());
253        progress.apply(&started(1));
254        assert!(progress.is_loading());
255        assert_eq!(progress.current(), Some(id(1)));
256        progress.apply(&succeeded(1));
257        assert!(!progress.is_loading());
258        assert_eq!(progress.current(), None);
259        assert!(progress.is_current(id(1)));
260    }
261
262    #[test]
263    fn terminal_for_older_attempt_keeps_newest_loading() {
264        let mut progress = NavigationProgress::default();
265        progress.apply(&started(1));
266        progress.apply(&started(2));
267        progress.apply(&NavigationEvent::Cancelled {
268            id: id(1),
269            reason: NavigationCancellationReason::Superseded,
270        });
271        assert!(progress.is_loading());
272        assert_eq!(progress.current(), Some(id(2)));
273        assert!(!progress.is_current(id(1)));
274    }
275
276    #[test]
277    fn failed_terminal_ends_loading_for_current_attempt() {
278        let mut progress = NavigationProgress::default();
279        progress.apply(&started(1));
280        progress.apply(&NavigationEvent::Failed {
281            id: id(1),
282            error: LoadError {
283                failing_url: Some("https://example.com/1".into()),
284                kind: LoadErrorKind::Network,
285                description: "boom".into(),
286            },
287        });
288        assert!(!progress.is_loading());
289    }
290
291    #[test]
292    fn observed_state_applies_none_clears() {
293        let mut state = ObservedWebViewState::default();
294        state.apply(WebViewStateChange::Title {
295            title: Some("Example".into()),
296        });
297        state.apply(WebViewStateChange::Favicon {
298            png_bytes: Some(vec![1, 2, 3]),
299        });
300        state.apply(WebViewStateChange::Location {
301            url: "https://example.com/".into(),
302        });
303        state.apply(WebViewStateChange::BackForwardAvailability {
304            can_go_back: true,
305            can_go_forward: false,
306        });
307        assert_eq!(state.title.as_deref(), Some("Example"));
308        assert_eq!(state.favicon_png.as_deref(), Some(&[1u8, 2, 3][..]));
309        assert!(state.can_go_back);
310
311        state.apply(WebViewStateChange::Title { title: None });
312        state.apply(WebViewStateChange::Favicon { png_bytes: None });
313        assert_eq!(state.title, None);
314        assert_eq!(state.favicon_png, None);
315        assert_eq!(state.url.as_deref(), Some("https://example.com/"));
316    }
317}