lingxia-webview 0.17.0

WebView abstraction layer for LingXia framework (Android, iOS, HarmonyOS, Windows)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! Typed WebView delegate events: correlated navigation lifecycle, observable
//! state snapshots, and the canonical derived-state folds every consumer must
//! use instead of hand-rolled equivalents.

pub(crate) mod normalizer;

/// Register a read-only observer for a WebView's delivered events
/// (automation waits, devtools). Observers run after the delegate, in
/// registration order, on the same delivery drain.
pub use normalizer::add_observer;

use crate::traits::LoadError;
use std::fmt;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};

/// Process-unique identity of one accepted top-level navigation attempt.
///
/// Allocated by the event normalizer from a process-wide monotonic sequence;
/// never reused within a process, never persistent across launches.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NavigationId(u64);

static NAVIGATION_ID_SEQUENCE: AtomicU64 = AtomicU64::new(1);

impl NavigationId {
    /// Allocate the next process-wide id. Normalizer-internal.
    pub(crate) fn next() -> Self {
        Self(next_navigation_id(&NAVIGATION_ID_SEQUENCE))
    }

    pub fn get(self) -> u64 {
        self.0
    }

    /// Construct an arbitrary id in consumer unit tests.
    #[cfg(feature = "test-support")]
    pub fn from_raw(raw: u64) -> Self {
        Self(raw)
    }
}

fn next_navigation_id(sequence: &AtomicU64) -> u64 {
    sequence
        .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
            current.checked_add(1)
        })
        .expect("navigation identity space exhausted")
}

/// Formats as `nav#42` for logs and diagnostics.
impl fmt::Display for NavigationId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "nav#{}", self.0)
    }
}

/// Why an active navigation attempt terminated without success or failure.
/// Cancellation is control flow, not a load error: it must never surface
/// error UI or count as a failed visit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavigationCancellationReason {
    /// A newer navigation replaced this attempt.
    Superseded,
    /// The caller explicitly stopped loading.
    Stopped,
    /// The WebView was destroyed while the attempt was active.
    WebViewDestroyed,
    /// The backend reported cancellation but cannot distinguish the cause.
    Other,
}

/// Top-level navigation lifecycle. Every `Started` receives exactly one
/// terminal `Succeeded`, `Failed`, or `Cancelled` with the same id.
///
/// - `requested_url` is the initially requested URL — non-empty, never
///   updated on redirects, and not the final URL.
/// - `Succeeded.final_url` is the non-empty top-level URL after redirects and
///   is authoritative for persistence (`Location` state is authoritative for
///   live display).
/// - `Failed.error.failing_url` is the one authoritative failure URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NavigationEvent {
    Started {
        id: NavigationId,
        requested_url: String,
    },
    Succeeded {
        id: NavigationId,
        final_url: String,
    },
    Failed {
        id: NavigationId,
        error: LoadError,
    },
    Cancelled {
        id: NavigationId,
        reason: NavigationCancellationReason,
    },
}

impl NavigationEvent {
    pub fn id(&self) -> NavigationId {
        match self {
            NavigationEvent::Started { id, .. }
            | NavigationEvent::Succeeded { id, .. }
            | NavigationEvent::Failed { id, .. }
            | NavigationEvent::Cancelled { id, .. } => *id,
        }
    }

    pub fn is_terminal(&self) -> bool {
        !matches!(self, NavigationEvent::Started { .. })
    }
}

/// Observable WebView state snapshots. Not lifecycle transitions: `Location`
/// alone is never evidence of a successful visit, and `None` explicitly
/// clears a previously reported title/favicon (empty strings and empty byte
/// arrays are not sentinels).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WebViewStateChange {
    Location {
        url: String,
    },
    Title {
        /// `None` means the current document has no reported title.
        title: Option<String>,
    },
    Favicon {
        /// PNG bytes. `None` explicitly clears a previously reported favicon.
        png_bytes: Option<Vec<u8>>,
    },
    BackForwardAvailability {
        can_go_back: bool,
        can_go_forward: bool,
    },
}

/// A borrowed view of one delivered event, for read-only observers
/// (automation waits, devtools) that watch a WebView without owning it.
/// (`WebViewEvent` is taken by the creation-stage event in `webview.rs`.)
pub enum WebViewObservedEvent<'a> {
    Navigation(&'a NavigationEvent),
    State(&'a WebViewStateChange),
}

/// Read-only event observer. Observers run after the delegate returns, in
/// registration order, on the same delivery drain; they cannot affect
/// delivery and must not block.
pub type WebViewEventObserver = Arc<dyn Fn(WebViewObservedEvent<'_>) + Send + Sync>;

/// Attempt bookkeeping every consumer otherwise re-implements: because
/// attempts may overlap (WebView2), a terminal event for an older attempt
/// must not clear loading UI for the newest one.
#[derive(Debug, Default)]
pub struct NavigationProgress {
    newest: Option<NavigationId>,
    newest_terminal: bool,
}

impl NavigationProgress {
    /// Fold one event into the progress state.
    pub fn apply(&mut self, event: &NavigationEvent) {
        match event {
            NavigationEvent::Started { id, .. } => {
                self.newest = Some(*id);
                self.newest_terminal = false;
            }
            terminal => {
                if self.newest == Some(terminal.id()) {
                    self.newest_terminal = true;
                }
            }
        }
    }

    /// True while the newest attempt has no terminal event.
    pub fn is_loading(&self) -> bool {
        self.newest.is_some() && !self.newest_terminal
    }

    /// The newest attempt, until its terminal arrives.
    pub fn current(&self) -> Option<NavigationId> {
        if self.newest_terminal {
            None
        } else {
            self.newest
        }
    }

    /// Whether `id` is the newest attempt (terminal or not).
    pub fn is_current(&self, id: NavigationId) -> bool {
        self.newest == Some(id)
    }

    /// Folds `event` in and classifies it for a delegate that only acts on the
    /// newest attempt. Every consumer needs the same rule — a stale terminal
    /// must never mark a newer load as loaded or failed — so it lives here
    /// rather than being re-derived per delegate.
    pub fn classify<'a>(&mut self, event: &'a NavigationEvent) -> NavigationOutcome<'a> {
        self.apply(event);
        match event {
            NavigationEvent::Started { requested_url, .. } => {
                NavigationOutcome::Started { requested_url }
            }
            NavigationEvent::Succeeded { id, final_url } if self.is_current(*id) => {
                NavigationOutcome::Loaded { final_url }
            }
            NavigationEvent::Failed { id, error } if self.is_current(*id) => {
                NavigationOutcome::Failed { error }
            }
            // Cancellation is control flow, and a superseded attempt's terminal
            // belongs to a document nobody is showing any more.
            _ => NavigationOutcome::Superseded,
        }
    }
}

/// What one navigation event means to a delegate, once stale attempts have
/// been filtered out.
#[derive(Debug, PartialEq, Eq)]
pub enum NavigationOutcome<'a> {
    Started { requested_url: &'a str },
    Loaded { final_url: &'a str },
    Failed { error: &'a LoadError },
    Superseded,
}

/// Fold of `WebViewStateChange` into the current observed state, including
/// the `None`-clears semantics, so all consumers interpret clearing the same
/// way.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ObservedWebViewState {
    pub url: Option<String>,
    pub title: Option<String>,
    pub favicon_png: Option<Vec<u8>>,
    pub can_go_back: bool,
    pub can_go_forward: bool,
}

impl ObservedWebViewState {
    /// Fold one change into the state. Takes the change by value so owned
    /// payloads are retained without cloning.
    pub fn apply(&mut self, change: WebViewStateChange) {
        match change {
            WebViewStateChange::Location { url } => self.url = Some(url),
            WebViewStateChange::Title { title } => self.title = title,
            WebViewStateChange::Favicon { png_bytes } => self.favicon_png = png_bytes,
            WebViewStateChange::BackForwardAvailability {
                can_go_back,
                can_go_forward,
            } => {
                self.can_go_back = can_go_back;
                self.can_go_forward = can_go_forward;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::traits::{LoadError, LoadErrorKind};

    #[test]
    fn navigation_ids_exhaust_instead_of_wrapping() {
        let sequence = AtomicU64::new(u64::MAX - 1);
        assert_eq!(next_navigation_id(&sequence), u64::MAX - 1);
        assert_eq!(sequence.load(Ordering::Relaxed), u64::MAX);
        assert!(
            std::panic::catch_unwind(|| next_navigation_id(&sequence)).is_err(),
            "an exhausted process-wide identity must never wrap"
        );
        assert_eq!(sequence.load(Ordering::Relaxed), u64::MAX);
    }

    fn id(raw: u64) -> NavigationId {
        NavigationId(raw)
    }

    fn started(raw: u64) -> NavigationEvent {
        NavigationEvent::Started {
            id: id(raw),
            requested_url: format!("https://example.com/{raw}"),
        }
    }

    fn succeeded(raw: u64) -> NavigationEvent {
        NavigationEvent::Succeeded {
            id: id(raw),
            final_url: format!("https://example.com/{raw}"),
        }
    }

    #[test]
    fn classify_reports_the_current_attempt_only() {
        let mut progress = NavigationProgress::default();
        assert_eq!(
            progress.classify(&started(1)),
            NavigationOutcome::Started {
                requested_url: "https://example.com/1",
            }
        );
        assert_eq!(
            progress.classify(&succeeded(1)),
            NavigationOutcome::Loaded {
                final_url: "https://example.com/1",
            }
        );

        // A second attempt supersedes the first, so the first's terminal is
        // no longer authoritative for anything.
        progress.classify(&started(2));
        assert_eq!(
            progress.classify(&succeeded(1)),
            NavigationOutcome::Superseded
        );
    }

    #[test]
    fn classify_treats_cancellation_as_control_flow() {
        let mut progress = NavigationProgress::default();
        progress.classify(&started(1));
        assert_eq!(
            progress.classify(&NavigationEvent::Cancelled {
                id: id(1),
                reason: NavigationCancellationReason::Superseded,
            }),
            NavigationOutcome::Superseded
        );
    }

    #[test]
    fn navigation_id_displays_for_diagnostics() {
        assert_eq!(id(42).to_string(), "nav#42");
    }

    #[test]
    fn progress_tracks_single_attempt() {
        let mut progress = NavigationProgress::default();
        assert!(!progress.is_loading());
        progress.apply(&started(1));
        assert!(progress.is_loading());
        assert_eq!(progress.current(), Some(id(1)));
        progress.apply(&succeeded(1));
        assert!(!progress.is_loading());
        assert_eq!(progress.current(), None);
        assert!(progress.is_current(id(1)));
    }

    #[test]
    fn terminal_for_older_attempt_keeps_newest_loading() {
        let mut progress = NavigationProgress::default();
        progress.apply(&started(1));
        progress.apply(&started(2));
        progress.apply(&NavigationEvent::Cancelled {
            id: id(1),
            reason: NavigationCancellationReason::Superseded,
        });
        assert!(progress.is_loading());
        assert_eq!(progress.current(), Some(id(2)));
        assert!(!progress.is_current(id(1)));
    }

    #[test]
    fn failed_terminal_ends_loading_for_current_attempt() {
        let mut progress = NavigationProgress::default();
        progress.apply(&started(1));
        progress.apply(&NavigationEvent::Failed {
            id: id(1),
            error: LoadError {
                failing_url: Some("https://example.com/1".into()),
                kind: LoadErrorKind::Network,
                description: "boom".into(),
            },
        });
        assert!(!progress.is_loading());
    }

    #[test]
    fn observed_state_applies_none_clears() {
        let mut state = ObservedWebViewState::default();
        state.apply(WebViewStateChange::Title {
            title: Some("Example".into()),
        });
        state.apply(WebViewStateChange::Favicon {
            png_bytes: Some(vec![1, 2, 3]),
        });
        state.apply(WebViewStateChange::Location {
            url: "https://example.com/".into(),
        });
        state.apply(WebViewStateChange::BackForwardAvailability {
            can_go_back: true,
            can_go_forward: false,
        });
        assert_eq!(state.title.as_deref(), Some("Example"));
        assert_eq!(state.favicon_png.as_deref(), Some(&[1u8, 2, 3][..]));
        assert!(state.can_go_back);

        state.apply(WebViewStateChange::Title { title: None });
        state.apply(WebViewStateChange::Favicon { png_bytes: None });
        assert_eq!(state.title, None);
        assert_eq!(state.favicon_png, None);
        assert_eq!(state.url.as_deref(), Some("https://example.com/"));
    }
}