Skip to main content

lingxia_webview/
webview.rs

1#![cfg_attr(
2    not(any(
3        target_os = "android",
4        target_os = "ios",
5        target_os = "macos",
6        target_os = "windows",
7        all(target_os = "linux", target_env = "ohos")
8    )),
9    allow(dead_code)
10)]
11
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet, VecDeque};
14use std::future::Future;
15use std::panic::{AssertUnwindSafe, catch_unwind};
16use std::pin::Pin;
17use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
18use std::sync::mpsc::{Sender, SyncSender, channel, sync_channel};
19use std::sync::{Arc, Mutex, OnceLock, RwLock};
20use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
21use tokio::sync::watch;
22
23#[cfg(target_os = "android")]
24use crate::android::WebViewInner;
25
26#[cfg(any(target_os = "ios", target_os = "macos"))]
27use crate::apple::WebViewInner;
28
29#[cfg(all(target_os = "linux", target_env = "ohos"))]
30use crate::harmony::WebViewInner;
31
32#[cfg(target_os = "windows")]
33use crate::windows::WebViewInner;
34
35#[cfg(not(any(
36    target_os = "android",
37    target_os = "ios",
38    target_os = "macos",
39    target_os = "windows",
40    all(target_os = "linux", target_env = "ohos")
41)))]
42pub(crate) struct WebViewInner {
43    webtag: WebTag,
44}
45
46use crate::traits::{
47    AsyncSchemeHandler, ClickOptions, ContextualSchemeRequest, DownloadHandler, DownloadRequest,
48    FileChooserRequest, FileChooserResponse, FillOptions, NativeWebViewId, NavigationHandler,
49    NavigationPolicy, NavigationRequest, NewWindowHandler, NewWindowPolicy, PressOptions,
50    SchemeOutcome, SchemeRequestFrame, ScrollOptions, TypeOptions, UserAgentOverride,
51    WebMessageContext, WebMessageFrame, WebMessageSource, WebMessageTransport,
52    WebViewInputController,
53};
54use crate::{
55    ClearSiteDataOptions, ClearSiteDataResult, IncomingWebMessage, LoadDataRequest,
56    NetworkCaptureSnapshot, TrustedLoadIntent, WebResourceResponse, WebViewController,
57    WebViewCookie, WebViewCookieSetRequest, WebViewDelegate, WebViewError, WebViewInputError,
58    WebViewScriptError,
59};
60use async_trait::async_trait;
61
62const APPLE_INTERNAL_SCHEME: &str = "lx-apple";
63pub(crate) const MAX_WEB_MESSAGE_BYTES: usize = 64 * 1024;
64const MAX_PENDING_WEB_MESSAGES: usize = 1024;
65const MAX_PENDING_WEB_MESSAGE_BYTES: usize = 1024 * 1024;
66const WEB_MESSAGE_WORKER_COUNT: usize = 4;
67
68static NEXT_NATIVE_WEBVIEW_ID: AtomicU64 = AtomicU64::new(1);
69
70fn next_native_webview_id() -> NativeWebViewId {
71    // Zero is deliberately never allocated, so a platform's default integer
72    // cannot accidentally match a real native instance. Exhaustion is safer
73    // than wrapping and allowing a retired native callback to match again.
74    let raw = NEXT_NATIVE_WEBVIEW_ID
75        .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
76            current.checked_add(1)
77        })
78        .expect("native WebView identity space exhausted");
79    NativeWebViewId::new(raw)
80}
81
82#[derive(Default)]
83struct WebMessageIngress {
84    state: Mutex<WebMessageIngressState>,
85}
86
87#[derive(Default)]
88struct WebMessageIngressState {
89    queue: VecDeque<IncomingWebMessage>,
90    queued_bytes: usize,
91    scheduled: bool,
92    closed: bool,
93    in_flight: bool,
94    rejection_counts: [u64; WebMessageRejectReason::COUNT],
95}
96
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98enum WebMessageEnqueue {
99    Queued,
100    Schedule,
101    Rejected {
102        reason: WebMessageRejectReason,
103        count: u64,
104    },
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108enum WebMessageRejectReason {
109    MessageTooLarge,
110    QueueCountLimit,
111    QueueByteLimit,
112    Closed,
113}
114
115impl WebMessageRejectReason {
116    const COUNT: usize = 4;
117
118    const fn index(self) -> usize {
119        match self {
120            Self::MessageTooLarge => 0,
121            Self::QueueCountLimit => 1,
122            Self::QueueByteLimit => 2,
123            Self::Closed => 3,
124        }
125    }
126
127    const fn as_str(self) -> &'static str {
128        match self {
129            Self::MessageTooLarge => "message_too_large",
130            Self::QueueCountLimit => "queue_count_limit",
131            Self::QueueByteLimit => "queue_byte_limit",
132            Self::Closed => "ingress_closed",
133        }
134    }
135}
136
137fn should_sample_rejection(count: u64) -> bool {
138    count == 1 || count.is_power_of_two()
139}
140
141pub(crate) const fn web_message_bytes_within_limit(bytes: usize) -> bool {
142    bytes <= MAX_WEB_MESSAGE_BYTES
143}
144
145/// Pre-check for adapters that receive a native UTF-16 buffer. UTF-8 is never
146/// shorter than the UTF-16 unit count, so exceeding the cap in units already
147/// exceeds it in bytes; a copy that does proceed stays under three times the
148/// cap before the byte check runs.
149#[cfg(any(test, target_os = "windows"))]
150pub(crate) const fn web_message_utf16_units_within_limit(units: usize) -> bool {
151    units <= MAX_WEB_MESSAGE_BYTES
152}
153
154impl WebMessageIngress {
155    fn reject_locked(
156        state: &mut WebMessageIngressState,
157        reason: WebMessageRejectReason,
158    ) -> WebMessageEnqueue {
159        let count = &mut state.rejection_counts[reason.index()];
160        *count = count.saturating_add(1);
161        WebMessageEnqueue::Rejected {
162            reason,
163            count: *count,
164        }
165    }
166
167    #[cfg(any(
168        test,
169        target_os = "android",
170        target_os = "ios",
171        target_os = "macos",
172        target_os = "windows",
173        all(target_os = "linux", target_env = "ohos")
174    ))]
175    fn reject(&self, reason: WebMessageRejectReason) -> WebMessageEnqueue {
176        let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
177        Self::reject_locked(&mut state, reason)
178    }
179
180    /// Enqueue one message and report whether this instance must be scheduled.
181    /// A bounded queue prevents an untrusted page from growing native memory
182    /// without limit; accepted messages remain FIFO.
183    fn enqueue(&self, message: IncomingWebMessage) -> WebMessageEnqueue {
184        let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
185        let message_bytes = message.body().len();
186        if !web_message_bytes_within_limit(message_bytes) {
187            return Self::reject_locked(&mut state, WebMessageRejectReason::MessageTooLarge);
188        }
189        if state.closed {
190            return Self::reject_locked(&mut state, WebMessageRejectReason::Closed);
191        }
192        if state.queue.len() >= MAX_PENDING_WEB_MESSAGES {
193            return Self::reject_locked(&mut state, WebMessageRejectReason::QueueCountLimit);
194        }
195        let Some(next_bytes) = state.queued_bytes.checked_add(message_bytes) else {
196            return Self::reject_locked(&mut state, WebMessageRejectReason::QueueByteLimit);
197        };
198        if next_bytes > MAX_PENDING_WEB_MESSAGE_BYTES {
199            return Self::reject_locked(&mut state, WebMessageRejectReason::QueueByteLimit);
200        }
201        state.queued_bytes = next_bytes;
202        state.queue.push_back(message);
203        if state.scheduled {
204            WebMessageEnqueue::Queued
205        } else {
206            state.scheduled = true;
207            WebMessageEnqueue::Schedule
208        }
209    }
210
211    /// Stop accepting messages and discard all queued work.
212    ///
213    /// A message becomes in-flight while holding this mutex, immediately
214    /// before its delegate lookup. Closing does not interrupt that already
215    /// admitted delivery, but prevents every queued and future message from
216    /// reaching a delegate.
217    fn close(&self) {
218        let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
219        state.closed = true;
220        state.queue.clear();
221        state.queued_bytes = 0;
222        if !state.in_flight {
223            state.scheduled = false;
224        }
225    }
226
227    /// Pop the next admitted message for this instance's sole scheduled job.
228    /// Delegate code never runs while this lock is held, so re-entrant ingress
229    /// appends after the current message.
230    fn begin_delivery(&self) -> Option<IncomingWebMessage> {
231        let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
232        if state.closed {
233            state.scheduled = false;
234            return None;
235        }
236        match state.queue.pop_front() {
237            Some(message) => {
238                state.queued_bytes = state
239                    .queued_bytes
240                    .checked_sub(message.body().len())
241                    .expect("queued WebView message byte ledger underflow");
242                state.in_flight = true;
243                Some(message)
244            }
245            None => {
246                state.scheduled = false;
247                None
248            }
249        }
250    }
251
252    fn finish_delivery(&self) {
253        let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
254        debug_assert!(state.in_flight);
255        state.in_flight = false;
256    }
257
258    #[cfg(test)]
259    fn queued_bytes(&self) -> usize {
260        self.state
261            .lock()
262            .unwrap_or_else(|error| error.into_inner())
263            .queued_bytes
264    }
265
266    #[cfg(test)]
267    fn rejection_count(&self, reason: WebMessageRejectReason) -> u64 {
268        self.state
269            .lock()
270            .unwrap_or_else(|error| error.into_inner())
271            .rejection_counts[reason.index()]
272    }
273
274    fn drain<F>(&self, mut deliver: F)
275    where
276        F: FnMut(IncomingWebMessage),
277    {
278        while let Some(message) = self.begin_delivery() {
279            let result = catch_unwind(AssertUnwindSafe(|| deliver(message)));
280            self.finish_delivery();
281            if result.is_err() {
282                log::error!("WebView message delegate panicked; continuing ingress drain");
283            }
284        }
285    }
286}
287
288struct WebMessageJob {
289    ingress: Arc<WebMessageIngress>,
290    webview: std::sync::Weak<WebView>,
291}
292
293/// Process-lifetime, fixed-size executor for WebView message ingress.
294///
295/// Each ingress schedules no more than one job, so an instance stays serial;
296/// different instances are distributed across workers and can make progress in
297/// parallel without creating a thread per callback or idle burst.
298struct WebMessageExecutor {
299    senders: Vec<Sender<WebMessageJob>>,
300    next_worker: AtomicUsize,
301}
302
303impl WebMessageExecutor {
304    fn global() -> &'static Self {
305        static EXECUTOR: OnceLock<WebMessageExecutor> = OnceLock::new();
306        EXECUTOR.get_or_init(Self::new)
307    }
308
309    fn new() -> Self {
310        let mut senders = Vec::with_capacity(WEB_MESSAGE_WORKER_COUNT);
311        for worker_index in 0..WEB_MESSAGE_WORKER_COUNT {
312            let (sender, receiver) = channel::<WebMessageJob>();
313            std::thread::Builder::new()
314                .name(format!("lingxia-web-message-worker-{worker_index}"))
315                .spawn(move || {
316                    while let Ok(job) = receiver.recv() {
317                        let ingress = Arc::clone(&job.ingress);
318                        job.ingress.drain(|message| {
319                            let Some(webview) = job.webview.upgrade() else {
320                                ingress.close();
321                                return;
322                            };
323                            let document = message.context().document();
324                            let mut message = Some(message);
325                            let deliver = &mut || {
326                                if let Some(delegate) = webview.get_delegate() {
327                                    delegate.handle_post_message(
328                                        message
329                                            .take()
330                                            .expect("message delivery closure runs at most once"),
331                                    );
332                                } else {
333                                    log::debug!(
334                                        "Dropping WebView message before delegate installation ({})",
335                                        webview.webtag()
336                                    );
337                                }
338                            };
339                            match document {
340                                crate::DocumentBinding::Bound(generation) => {
341                                    // Check without holding: the delegate may
342                                    // post back synchronously, which re-enters
343                                    // the normalizer on this same thread. The
344                                    // generation the message carries is what
345                                    // downstream authorization reads, and each
346                                    // outbound path re-verifies it under its
347                                    // own document gate.
348                                    if crate::events::normalizer::document_binding_is_current(
349                                        webview.native_view_id(),
350                                        generation,
351                                    ) {
352                                        deliver();
353                                    } else {
354                                        log::debug!(
355                                            "Dropping WebView message after its document generation was revoked ({})",
356                                            webview.webtag()
357                                        );
358                                    }
359                                }
360                                crate::DocumentBinding::Unbound => deliver(),
361                            }
362                        });
363                    }
364                })
365                .expect("failed to start fixed WebView message worker");
366            senders.push(sender);
367        }
368        Self {
369            senders,
370            next_worker: AtomicUsize::new(0),
371        }
372    }
373
374    fn schedule(&self, job: WebMessageJob) -> Result<(), WebMessageJob> {
375        let worker = self.next_worker.fetch_add(1, Ordering::Relaxed) % self.senders.len();
376        self.senders[worker].send(job).map_err(|error| error.0)
377    }
378}
379
380#[cfg(not(any(
381    target_os = "android",
382    target_os = "ios",
383    target_os = "macos",
384    target_os = "windows",
385    all(target_os = "linux", target_env = "ohos")
386)))]
387fn unsupported_webview_error(action: &str) -> WebViewError {
388    WebViewError::Unsupported(action.to_string())
389}
390
391#[cfg(not(any(
392    target_os = "android",
393    target_os = "ios",
394    target_os = "macos",
395    target_os = "windows",
396    all(target_os = "linux", target_env = "ohos")
397)))]
398impl WebViewInner {
399    pub(crate) fn create(
400        appid: &str,
401        path: &str,
402        session_id: Option<u64>,
403        _effective_options: EffectiveWebViewCreateOptions,
404        sender: WebViewCreateSender,
405    ) {
406        let _webtag = WebTag::new(appid, path, session_id);
407        sender.fail(
408            WebViewCreateStage::Requested,
409            unsupported_webview_error("webview creation"),
410        );
411    }
412}
413
414#[cfg(not(any(
415    target_os = "android",
416    target_os = "ios",
417    target_os = "macos",
418    target_os = "windows",
419    all(target_os = "linux", target_env = "ohos")
420)))]
421#[async_trait]
422impl WebViewController for WebViewInner {
423    fn load_url(&self, _url: &str) -> Result<(), WebViewError> {
424        Err(unsupported_webview_error("load_url"))
425    }
426
427    fn load_data(&self, _request: LoadDataRequest<'_>) -> Result<(), WebViewError> {
428        Err(unsupported_webview_error("load_data"))
429    }
430
431    fn exec_js(&self, _js: &str) -> Result<(), WebViewError> {
432        Err(unsupported_webview_error("exec_js"))
433    }
434
435    async fn eval_js(&self, _js: &str) -> Result<serde_json::Value, WebViewScriptError> {
436        Err(WebViewScriptError::Unsupported(
437            "JavaScript evaluation is not supported on this platform",
438        ))
439    }
440
441    fn post_message(&self, _message: &str) -> Result<(), WebViewError> {
442        Err(unsupported_webview_error("post_message"))
443    }
444
445    fn clear_browsing_data(&self) -> Result<(), WebViewError> {
446        Err(unsupported_webview_error("clear_browsing_data"))
447    }
448
449    fn set_user_agent_override(&self, _user_agent: UserAgentOverride) -> Result<(), WebViewError> {
450        Err(unsupported_webview_error("set_user_agent_override"))
451    }
452}
453
454fn lock_or_recover<'a, T>(mutex: &'a Mutex<T>, name: &str) -> std::sync::MutexGuard<'a, T> {
455    match mutex.lock() {
456        Ok(guard) => guard,
457        Err(poisoned) => {
458            log::error!("Mutex poisoned at {}, recovering inner value", name);
459            poisoned.into_inner()
460        }
461    }
462}
463
464fn scheme_waker_from_sender(sender: SyncSender<()>) -> Waker {
465    // SAFETY: RawWaker functions maintain Arc refcounts correctly.
466    unsafe { Waker::from_raw(scheme_raw_waker(Arc::new(sender))) }
467}
468
469fn scheme_raw_waker(sender: Arc<SyncSender<()>>) -> RawWaker {
470    RawWaker::new(Arc::into_raw(sender) as *const (), &SCHEME_WAKER_VTABLE)
471}
472
473unsafe fn scheme_waker_clone(data: *const ()) -> RawWaker {
474    // SAFETY: data is created from Arc<SyncSender<()>> in scheme_raw_waker.
475    let arc = unsafe { Arc::<SyncSender<()>>::from_raw(data as *const SyncSender<()>) };
476    let cloned = Arc::clone(&arc);
477    let _ = Arc::into_raw(arc);
478    scheme_raw_waker(cloned)
479}
480
481unsafe fn scheme_waker_wake(data: *const ()) {
482    // SAFETY: data is created from Arc<SyncSender<()>> in scheme_raw_waker.
483    let arc = unsafe { Arc::<SyncSender<()>>::from_raw(data as *const SyncSender<()>) };
484    let _ = arc.try_send(());
485}
486
487unsafe fn scheme_waker_wake_by_ref(data: *const ()) {
488    // SAFETY: data is created from Arc<SyncSender<()>> in scheme_raw_waker.
489    let arc = unsafe { Arc::<SyncSender<()>>::from_raw(data as *const SyncSender<()>) };
490    let _ = arc.try_send(());
491    let _ = Arc::into_raw(arc);
492}
493
494unsafe fn scheme_waker_drop(data: *const ()) {
495    // SAFETY: data is created from Arc<SyncSender<()>> in scheme_raw_waker.
496    let _ = unsafe { Arc::<SyncSender<()>>::from_raw(data as *const SyncSender<()>) };
497}
498
499static SCHEME_WAKER_VTABLE: RawWakerVTable = RawWakerVTable::new(
500    scheme_waker_clone,
501    scheme_waker_wake,
502    scheme_waker_wake_by_ref,
503    scheme_waker_drop,
504);
505
506fn block_on_scheme_future<F>(future: F) -> F::Output
507where
508    F: Future,
509{
510    let (tx, rx) = sync_channel::<()>(1);
511    let waker = scheme_waker_from_sender(tx);
512    let mut context = Context::from_waker(&waker);
513    let mut future = Box::pin(future);
514
515    loop {
516        match Pin::as_mut(&mut future).poll(&mut context) {
517            Poll::Ready(value) => return value,
518            Poll::Pending => {
519                if rx.recv().is_err() {
520                    std::thread::yield_now();
521                }
522            }
523        }
524    }
525}
526
527/// Security profile for WebView creation.
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
529#[serde(rename_all = "snake_case")]
530pub(crate) enum SecurityProfile {
531    StrictDefault,
532    BrowserRelaxed,
533}
534
535/// Native console hooks are safe only for fixed-origin lxapp pages. A
536/// BrowserRelaxed WebView can contain arbitrary external content, so its
537/// BrowserControl document must use the V3-bound main message transport.
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub(crate) enum PlatformConsoleBackend {
540    #[cfg(any(target_os = "ios", target_os = "macos", test))]
541    Apple,
542    #[cfg(any(target_os = "android", test))]
543    Android,
544    #[cfg(any(all(target_os = "linux", target_env = "ohos"), test))]
545    Harmony,
546    #[cfg(any(target_os = "windows", test))]
547    Windows,
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub(crate) enum PlatformConsoleDelivery {
552    DirectDelegate,
553    RequiredV3Envelope,
554}
555
556pub(crate) const fn platform_console_delivery(
557    profile: SecurityProfile,
558    _backend: PlatformConsoleBackend,
559) -> PlatformConsoleDelivery {
560    match profile {
561        SecurityProfile::StrictDefault => PlatformConsoleDelivery::DirectDelegate,
562        SecurityProfile::BrowserRelaxed => PlatformConsoleDelivery::RequiredV3Envelope,
563    }
564}
565
566/// Website-data lifetime for a WebView.
567///
568/// This is independent of the security profile: a browser-profile WebView can
569/// use an ephemeral data store without giving up browser navigation features.
570#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
571#[serde(rename_all = "snake_case")]
572pub enum WebViewDataMode {
573    /// Keep the platform behavior associated with the selected security
574    /// profile. Browser-profile WebViews use the shared persistent store.
575    #[default]
576    ProfileDefault,
577    /// Isolate cookies and site storage from persistent/shared browser data
578    /// and discard them when the WebView is destroyed.
579    Ephemeral,
580}
581
582pub(crate) type FileChooserFuture =
583    Pin<Box<dyn Future<Output = FileChooserResponse> + Send + 'static>>;
584pub(crate) type FileChooserHandler =
585    Box<dyn Fn(FileChooserRequest) -> FileChooserFuture + Send + Sync>;
586
587/// Internal WebView creation options.
588pub(crate) struct WebViewCreateOptions {
589    pub(crate) profile: SecurityProfile,
590    pub(crate) data_mode: WebViewDataMode,
591    pub(crate) scheme_handlers: HashMap<String, AsyncSchemeHandler>,
592    pub(crate) navigation_handler: Option<NavigationHandler>,
593    pub(crate) new_window_handler: Option<NewWindowHandler>,
594    pub(crate) download_handler: Option<DownloadHandler>,
595    pub(crate) file_chooser_handler: Option<FileChooserHandler>,
596    pub(crate) delegate: Option<Arc<dyn WebViewDelegate>>,
597    /// The webview belongs to a surface, not the app's page container; the
598    /// platform shell must not adopt it into stack-page presentation.
599    pub(crate) surface_owned: bool,
600}
601
602impl std::fmt::Debug for WebViewCreateOptions {
603    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
604        f.debug_struct("WebViewCreateOptions")
605            .field("profile", &self.profile)
606            .field("data_mode", &self.data_mode)
607            .field(
608                "scheme_handlers",
609                &self.scheme_handlers.keys().collect::<Vec<_>>(),
610            )
611            .field("has_navigation_handler", &self.navigation_handler.is_some())
612            .field("has_new_window_handler", &self.new_window_handler.is_some())
613            .field("has_download_handler", &self.download_handler.is_some())
614            .field(
615                "has_file_chooser_handler",
616                &self.file_chooser_handler.is_some(),
617            )
618            .field("has_delegate", &self.delegate.is_some())
619            .finish()
620    }
621}
622
623/// Global HTTP proxy configuration shared by all WebViews in the process.
624#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
625pub struct ProxyConfig {
626    pub host: String,
627    pub port: u16,
628    #[serde(default)]
629    pub bypass: Vec<String>,
630}
631
632impl ProxyConfig {
633    pub fn new(host: impl Into<String>, port: u16) -> Result<Self, WebViewError> {
634        let cfg = Self {
635            host: host.into(),
636            port,
637            bypass: Vec::new(),
638        };
639        cfg.validate()
640    }
641
642    pub fn with_bypass<I, S>(mut self, bypass: I) -> Self
643    where
644        I: IntoIterator<Item = S>,
645        S: Into<String>,
646    {
647        self.bypass = bypass.into_iter().map(Into::into).collect();
648        self
649    }
650
651    fn validate(self) -> Result<Self, WebViewError> {
652        let host = self.host.trim().to_string();
653        if host.is_empty() {
654            return Err(WebViewError::InvalidCreateOptions(
655                "proxy host cannot be empty".to_string(),
656            ));
657        }
658        if host.contains(char::is_whitespace) {
659            return Err(WebViewError::InvalidCreateOptions(
660                "proxy host cannot contain whitespace".to_string(),
661            ));
662        }
663        if self.port == 0 {
664            return Err(WebViewError::InvalidCreateOptions(
665                "proxy port must be greater than 0".to_string(),
666            ));
667        }
668
669        let mut seen = HashSet::new();
670        let mut bypass = Vec::new();
671        for raw in self.bypass {
672            let rule = raw.trim();
673            if rule.is_empty() {
674                continue;
675            }
676            let key = rule.to_ascii_lowercase();
677            if seen.insert(key) {
678                bypass.push(rule.to_string());
679            }
680        }
681
682        Ok(Self {
683            host,
684            bypass,
685            ..self
686        })
687    }
688}
689
690#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
691#[serde(rename_all = "snake_case")]
692pub enum ProxyApplyStatus {
693    Applied,
694    Cleared,
695    Unsupported,
696}
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
699#[serde(rename_all = "snake_case")]
700pub enum ProxyActivation {
701    EffectiveNow,
702    NewWebViewsOnly,
703    EngineRecreateRequired,
704    NotApplied,
705}
706
707#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
708pub struct ProxyApplyReport {
709    pub status: ProxyApplyStatus,
710    pub activation: ProxyActivation,
711    #[serde(default, skip_serializing_if = "Option::is_none")]
712    pub detail: Option<String>,
713}
714
715impl ProxyApplyReport {
716    pub fn applied(activation: ProxyActivation) -> Self {
717        Self {
718            status: ProxyApplyStatus::Applied,
719            activation,
720            detail: None,
721        }
722    }
723
724    pub fn cleared(activation: ProxyActivation) -> Self {
725        Self {
726            status: ProxyApplyStatus::Cleared,
727            activation,
728            detail: None,
729        }
730    }
731
732    pub fn unsupported(detail: impl Into<String>) -> Self {
733        Self {
734            status: ProxyApplyStatus::Unsupported,
735            activation: ProxyActivation::NotApplied,
736            detail: Some(detail.into()),
737        }
738    }
739}
740
741/// Effective, normalized options actually applied to a concrete WebView instance.
742#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
743pub(crate) struct EffectiveWebViewCreateOptions {
744    pub(crate) profile: SecurityProfile,
745    /// Website-data lifetime, independent of the security profile.
746    #[serde(default)]
747    pub(crate) data_mode: WebViewDataMode,
748    /// Scheme names registered via `on_scheme` (serializable).
749    #[serde(default)]
750    pub(crate) registered_schemes: Vec<String>,
751    #[serde(default)]
752    pub(crate) has_navigation_handler: bool,
753    #[serde(default)]
754    pub(crate) has_new_window_handler: bool,
755    #[serde(default)]
756    pub(crate) has_download_handler: bool,
757    #[serde(default)]
758    pub(crate) has_file_chooser_handler: bool,
759    #[serde(default)]
760    pub(crate) has_delegate: bool,
761    #[serde(default)]
762    pub(crate) surface_owned: bool,
763}
764
765impl Default for WebViewCreateOptions {
766    fn default() -> Self {
767        Self::strict()
768    }
769}
770
771impl WebViewCreateOptions {
772    fn strict() -> Self {
773        Self {
774            profile: SecurityProfile::StrictDefault,
775            data_mode: WebViewDataMode::ProfileDefault,
776            scheme_handlers: HashMap::new(),
777            navigation_handler: None,
778            new_window_handler: None,
779            download_handler: None,
780            file_chooser_handler: None,
781            delegate: None,
782            surface_owned: false,
783        }
784    }
785
786    fn browser() -> Self {
787        Self {
788            profile: SecurityProfile::BrowserRelaxed,
789            data_mode: WebViewDataMode::ProfileDefault,
790            scheme_handlers: HashMap::new(),
791            navigation_handler: None,
792            new_window_handler: None,
793            download_handler: None,
794            file_chooser_handler: None,
795            delegate: None,
796            surface_owned: false,
797        }
798    }
799
800    /// Register a scheme handler for a custom URL scheme.
801    ///
802    /// The handler is async by design.
803    ///
804    /// Usage:
805    /// - Async workload:
806    ///   `options.on_scheme("lx", |req| async move { ... })`
807    /// - Immediate response:
808    ///   `options.on_scheme("lx", |req| async move { immediate(req).into() })`
809    fn on_contextual_scheme<F, Fut>(mut self, scheme: &str, handler: F) -> Self
810    where
811        F: Fn(ContextualSchemeRequest) -> Fut + Send + Sync + 'static,
812        Fut: std::future::Future<Output = SchemeOutcome> + Send + 'static,
813    {
814        let normalized = scheme.trim().to_ascii_lowercase();
815        if !normalized.is_empty() {
816            self.scheme_handlers.insert(
817                normalized,
818                Arc::new(move |req| {
819                    let fut = handler(req);
820                    Box::pin(fut)
821                }),
822            );
823        }
824        self
825    }
826
827    fn on_scheme<F, Fut>(self, scheme: &str, handler: F) -> Self
828    where
829        F: Fn(http::Request<Vec<u8>>) -> Fut + Send + Sync + 'static,
830        Fut: std::future::Future<Output = SchemeOutcome> + Send + 'static,
831    {
832        self.on_contextual_scheme(scheme, move |request| handler(request.into_request()))
833    }
834
835    /// Register a navigation handler that decides whether to allow or cancel navigations.
836    /// The handler receives the URL and available platform navigation metadata.
837    fn on_navigation<F>(mut self, handler: F) -> Self
838    where
839        F: Fn(&NavigationRequest) -> NavigationPolicy + Send + Sync + 'static,
840    {
841        self.navigation_handler = Some(Box::new(handler));
842        self
843    }
844
845    /// Register a new-window handler for `target="_blank"` / `window.open()`.
846    /// The handler receives the URL and returns a `NewWindowPolicy`.
847    fn on_new_window<F>(mut self, handler: F) -> Self
848    where
849        F: Fn(&str) -> NewWindowPolicy + Send + Sync + 'static,
850    {
851        self.new_window_handler = Some(Box::new(handler));
852        self
853    }
854
855    /// Register a download handler for browser-mode downloads.
856    ///
857    /// The handler runs synchronously on the platform callback thread. Keep it fast and
858    /// spawn background work onto your runtime inside the closure.
859    ///
860    /// This callback is only valid for browser profile.
861    /// Public API: `WebViewBuilder::browser(webtag).on_download(...).create()`.
862    /// In this mode, download requests are routed to the callback path instead of in-WebView
863    /// download UI.
864    fn on_download<F>(mut self, handler: F) -> Self
865    where
866        F: Fn(DownloadRequest) + Send + Sync + 'static,
867    {
868        self.download_handler = Some(Box::new(handler));
869        self
870    }
871
872    fn on_file_chooser<F, Fut>(mut self, handler: F) -> Self
873    where
874        F: Fn(FileChooserRequest) -> Fut + Send + Sync + 'static,
875        Fut: Future<Output = FileChooserResponse> + Send + 'static,
876    {
877        self.file_chooser_handler = Some(Box::new(move |request| Box::pin(handler(request))));
878        self
879    }
880
881    fn delegate(mut self, delegate: Arc<dyn WebViewDelegate>) -> Self {
882        self.delegate = Some(delegate);
883        self
884    }
885
886    fn data_mode(mut self, data_mode: WebViewDataMode) -> Self {
887        self.data_mode = data_mode;
888        self
889    }
890
891    fn surface_owned(mut self, surface_owned: bool) -> Self {
892        self.surface_owned = surface_owned;
893        self
894    }
895
896    pub(crate) fn normalize(
897        self,
898    ) -> Result<(EffectiveWebViewCreateOptions, PendingCallbacks), WebViewError> {
899        if self.profile != SecurityProfile::BrowserRelaxed && self.download_handler.is_some() {
900            return Err(WebViewError::InvalidCreateOptions(
901                "download callback is only supported in browser profile; use WebViewBuilder::browser(webtag).on_download(...).create()".to_string(),
902            ));
903        }
904        if self.scheme_handlers.contains_key(APPLE_INTERNAL_SCHEME) {
905            return Err(WebViewError::InvalidCreateOptions(format!(
906                "scheme '{APPLE_INTERNAL_SCHEME}' is reserved for LingXia Apple bridge transport"
907            )));
908        }
909        let mut registered_schemes: Vec<String> = self.scheme_handlers.keys().cloned().collect();
910        registered_schemes.sort_unstable();
911        registered_schemes.dedup();
912        let effective = EffectiveWebViewCreateOptions {
913            profile: self.profile,
914            data_mode: self.data_mode,
915            registered_schemes,
916            has_navigation_handler: self.navigation_handler.is_some(),
917            has_new_window_handler: self.new_window_handler.is_some(),
918            has_download_handler: self.download_handler.is_some(),
919            has_file_chooser_handler: self.file_chooser_handler.is_some(),
920            has_delegate: self.delegate.is_some(),
921            surface_owned: self.surface_owned,
922        };
923        let pending = PendingCallbacks {
924            scheme_handlers: self.scheme_handlers,
925            navigation_handler: self.navigation_handler,
926            new_window_handler: self.new_window_handler,
927            download_handler: self.download_handler,
928            file_chooser_handler: self.file_chooser_handler,
929            delegate: self.delegate,
930        };
931        Ok((effective, pending))
932    }
933}
934
935/// Entry point for mode-specific WebView creation.
936///
937/// Typical usage:
938/// - Strict lxapp page:
939///   `WebViewBuilder::strict(tag).on_scheme(...).on_navigation(...).create()`
940/// - Browser page:
941///   `WebViewBuilder::browser(tag).on_new_window(...).on_download(...).create()`
942pub struct WebViewBuilder;
943
944#[must_use = "call .create() to start WebView creation"]
945pub struct StrictWebViewBuilder {
946    webtag: WebTag,
947    options: WebViewCreateOptions,
948}
949
950#[must_use = "call .create() to start WebView creation"]
951pub struct BrowserWebViewBuilder {
952    webtag: WebTag,
953    options: WebViewCreateOptions,
954}
955
956impl WebViewBuilder {
957    /// Start a strict-profile WebView builder.
958    #[must_use = "call .create() to start WebView creation"]
959    pub fn strict(webtag: WebTag) -> StrictWebViewBuilder {
960        StrictWebViewBuilder {
961            webtag,
962            options: WebViewCreateOptions::strict(),
963        }
964    }
965
966    /// Start a browser-profile WebView builder.
967    #[must_use = "call .create() to start WebView creation"]
968    pub fn browser(webtag: WebTag) -> BrowserWebViewBuilder {
969        BrowserWebViewBuilder {
970            webtag,
971            options: WebViewCreateOptions::browser(),
972        }
973    }
974}
975
976impl StrictWebViewBuilder {
977    /// Bind a `WebViewDelegate` during creation.
978    ///
979    /// This is the only supported way to configure delegate callbacks.
980    pub fn delegate(mut self, delegate: Arc<dyn WebViewDelegate>) -> Self {
981        self.options = self.options.delegate(delegate);
982        self
983    }
984
985    /// Select the website-data lifetime independently of the security profile.
986    pub fn data_mode(mut self, data_mode: WebViewDataMode) -> Self {
987        self.options = self.options.data_mode(data_mode);
988        self
989    }
990
991    /// Mark the webview as surface-owned so platform shells leave its
992    /// presentation to the surface instead of the page container.
993    pub fn surface_owned(mut self, surface_owned: bool) -> Self {
994        self.options = self.options.surface_owned(surface_owned);
995        self
996    }
997
998    pub fn on_scheme<F, Fut>(mut self, scheme: &str, handler: F) -> Self
999    where
1000        F: Fn(http::Request<Vec<u8>>) -> Fut + Send + Sync + 'static,
1001        Fut: std::future::Future<Output = SchemeOutcome> + Send + 'static,
1002    {
1003        self.options = self.options.on_scheme(scheme, handler);
1004        self
1005    }
1006
1007    /// Register a scheme handler which receives native-instance and frame
1008    /// context alongside the HTTP request.
1009    pub fn on_contextual_scheme<F, Fut>(mut self, scheme: &str, handler: F) -> Self
1010    where
1011        F: Fn(ContextualSchemeRequest) -> Fut + Send + Sync + 'static,
1012        Fut: std::future::Future<Output = SchemeOutcome> + Send + 'static,
1013    {
1014        self.options = self.options.on_contextual_scheme(scheme, handler);
1015        self
1016    }
1017
1018    pub fn on_navigation<F>(mut self, handler: F) -> Self
1019    where
1020        F: Fn(&NavigationRequest) -> NavigationPolicy + Send + Sync + 'static,
1021    {
1022        self.options = self.options.on_navigation(handler);
1023        self
1024    }
1025
1026    pub fn on_new_window<F>(mut self, handler: F) -> Self
1027    where
1028        F: Fn(&str) -> NewWindowPolicy + Send + Sync + 'static,
1029    {
1030        self.options = self.options.on_new_window(handler);
1031        self
1032    }
1033
1034    pub fn on_file_chooser<F, Fut>(mut self, handler: F) -> Self
1035    where
1036        F: Fn(FileChooserRequest) -> Fut + Send + Sync + 'static,
1037        Fut: Future<Output = FileChooserResponse> + Send + 'static,
1038    {
1039        self.options = self.options.on_file_chooser(handler);
1040        self
1041    }
1042
1043    /// Create a strict-profile WebView session.
1044    ///
1045    /// Re-creating with the same `webtag` follows strict rules:
1046    /// - Different options => creation fails.
1047    /// - Same options but new callback registrations => creation fails.
1048    /// - Same options and no callbacks => existing instance is reused.
1049    pub fn create(self) -> WebViewSession {
1050        create_webview_session(self.webtag, self.options)
1051    }
1052}
1053
1054impl BrowserWebViewBuilder {
1055    /// Bind a `WebViewDelegate` during creation.
1056    ///
1057    /// This is the only supported way to configure delegate callbacks.
1058    pub fn delegate(mut self, delegate: Arc<dyn WebViewDelegate>) -> Self {
1059        self.options = self.options.delegate(delegate);
1060        self
1061    }
1062
1063    pub fn on_scheme<F, Fut>(mut self, scheme: &str, handler: F) -> Self
1064    where
1065        F: Fn(http::Request<Vec<u8>>) -> Fut + Send + Sync + 'static,
1066        Fut: std::future::Future<Output = SchemeOutcome> + Send + 'static,
1067    {
1068        self.options = self.options.on_scheme(scheme, handler);
1069        self
1070    }
1071
1072    /// Register a scheme handler which receives native-instance and frame
1073    /// context alongside the HTTP request.
1074    pub fn on_contextual_scheme<F, Fut>(mut self, scheme: &str, handler: F) -> Self
1075    where
1076        F: Fn(ContextualSchemeRequest) -> Fut + Send + Sync + 'static,
1077        Fut: std::future::Future<Output = SchemeOutcome> + Send + 'static,
1078    {
1079        self.options = self.options.on_contextual_scheme(scheme, handler);
1080        self
1081    }
1082
1083    pub fn on_navigation<F>(mut self, handler: F) -> Self
1084    where
1085        F: Fn(&NavigationRequest) -> NavigationPolicy + Send + Sync + 'static,
1086    {
1087        self.options = self.options.on_navigation(handler);
1088        self
1089    }
1090
1091    pub fn on_new_window<F>(mut self, handler: F) -> Self
1092    where
1093        F: Fn(&str) -> NewWindowPolicy + Send + Sync + 'static,
1094    {
1095        self.options = self.options.on_new_window(handler);
1096        self
1097    }
1098
1099    /// Register a download callback (browser profile only).
1100    ///
1101    /// The callback runs on the platform callback thread; keep it fast and offload
1102    /// expensive work to your app runtime.
1103    pub fn on_download<F>(mut self, handler: F) -> Self
1104    where
1105        F: Fn(DownloadRequest) + Send + Sync + 'static,
1106    {
1107        self.options = self.options.on_download(handler);
1108        self
1109    }
1110
1111    /// Select the website-data lifetime independently of the security profile.
1112    pub fn data_mode(mut self, data_mode: WebViewDataMode) -> Self {
1113        self.options = self.options.data_mode(data_mode);
1114        self
1115    }
1116
1117    pub fn on_file_chooser<F, Fut>(mut self, handler: F) -> Self
1118    where
1119        F: Fn(FileChooserRequest) -> Fut + Send + Sync + 'static,
1120        Fut: Future<Output = FileChooserResponse> + Send + 'static,
1121    {
1122        self.options = self.options.on_file_chooser(handler);
1123        self
1124    }
1125
1126    /// Create a browser-profile WebView session.
1127    ///
1128    /// Re-creating with the same `webtag` follows strict rules:
1129    /// - Different options => creation fails.
1130    /// - Same options but new callback registrations => creation fails.
1131    /// - Same options and no callbacks => existing instance is reused.
1132    pub fn create(self) -> WebViewSession {
1133        create_webview_session(self.webtag, self.options)
1134    }
1135}
1136
1137/// Pending callbacks extracted from internal option normalization.
1138/// Stored between session creation and `register_webview` installation.
1139pub(crate) struct PendingCallbacks {
1140    pub(crate) scheme_handlers: HashMap<String, AsyncSchemeHandler>,
1141    pub(crate) navigation_handler: Option<NavigationHandler>,
1142    pub(crate) new_window_handler: Option<NewWindowHandler>,
1143    pub(crate) download_handler: Option<DownloadHandler>,
1144    pub(crate) file_chooser_handler: Option<FileChooserHandler>,
1145    pub(crate) delegate: Option<Arc<dyn WebViewDelegate>>,
1146}
1147
1148impl PendingCallbacks {
1149    fn has_any(&self) -> bool {
1150        !self.scheme_handlers.is_empty()
1151            || self.navigation_handler.is_some()
1152            || self.new_window_handler.is_some()
1153            || self.download_handler.is_some()
1154            || self.file_chooser_handler.is_some()
1155            || self.delegate.is_some()
1156    }
1157}
1158
1159/// WebView type that includes inner implementation and delegate
1160pub struct WebView {
1161    pub(crate) inner: WebViewInner,
1162    native_view_id: NativeWebViewId,
1163    effective_options: EffectiveWebViewCreateOptions,
1164    // Hold a strong reference to the delegate; runtime destroy clears it to break cycles.
1165    delegate: RwLock<Option<Arc<dyn WebViewDelegate>>>,
1166    // Closure-based scheme handlers registered via builders.
1167    scheme_handlers: RwLock<HashMap<String, AsyncSchemeHandler>>,
1168    navigation_handler: RwLock<Option<NavigationHandler>>,
1169    new_window_handler: RwLock<Option<NewWindowHandler>>,
1170    download_handler: RwLock<Option<DownloadHandler>>,
1171    file_chooser_handler: RwLock<Option<FileChooserHandler>>,
1172    message_ingress: Arc<WebMessageIngress>,
1173}
1174
1175/// A one-shot reservation for a trusted native HTML load.
1176///
1177/// It is issued for one exact [`WebView`] and can neither be cloned nor moved
1178/// to another view. Consumers register [`Self::intent`] with their own
1179/// document state before calling [`Self::load`]. Dropping it, or a failed
1180/// load, revokes the intent before it can become an admission.
1181pub struct TrustedDataLoadReservation<'a> {
1182    webview: &'a WebView,
1183    webtag: WebTag,
1184    native_view_id: NativeWebViewId,
1185    intent: Option<TrustedLoadIntent>,
1186}
1187
1188enum TrustedDataLoadEvidence {
1189    #[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
1190    NativeKey(crate::events::normalizer::NativeKey),
1191    #[cfg(any(target_os = "windows", all(target_os = "linux", target_env = "ohos")))]
1192    PlatformAttested,
1193}
1194
1195impl TrustedDataLoadReservation<'_> {
1196    /// The opaque token to register before initiating the native load.
1197    pub fn intent(&self) -> TrustedLoadIntent {
1198        self.intent
1199            .expect("trusted data load reservation must retain its intent until consumed")
1200    }
1201
1202    /// Keep the issued token live without calling `load`. Used when the HTML is
1203    /// delivered through a scheme handler for the same navigation.
1204    pub fn disarm(mut self) -> TrustedLoadIntent {
1205        self.intent
1206            .take()
1207            .expect("trusted data load reservation must retain its intent until consumed")
1208    }
1209
1210    /// Consume this reservation and start its one native HTML load.
1211    pub fn load(mut self, request: LoadDataRequest<'_>) -> Result<TrustedLoadIntent, WebViewError> {
1212        let intent = self
1213            .intent
1214            .take()
1215            .expect("trusted data load reservation must be consumed once");
1216        let evidence = match self.webview.load_trusted_data_on_platform(intent, request) {
1217            Ok(evidence) => evidence,
1218            Err(error) => {
1219                crate::events::normalizer::revoke_trusted_load(
1220                    &self.webtag,
1221                    self.native_view_id,
1222                    intent,
1223                );
1224                return Err(error);
1225            }
1226        };
1227
1228        let attested = match evidence {
1229            #[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
1230            TrustedDataLoadEvidence::NativeKey(key) => {
1231                crate::events::normalizer::attest_trusted_load(
1232                    &self.webtag,
1233                    self.native_view_id,
1234                    intent,
1235                    key,
1236                )
1237            }
1238            #[cfg(any(target_os = "windows", all(target_os = "linux", target_env = "ohos")))]
1239            TrustedDataLoadEvidence::PlatformAttested => true,
1240        };
1241        if attested {
1242            Ok(intent)
1243        } else {
1244            // A destroy/recreate or concurrent later direct load won between
1245            // issuing and binding. The native load may still render, but it is
1246            // intentionally ineligible for trusted admission.
1247            crate::events::normalizer::revoke_trusted_load(
1248                &self.webtag,
1249                self.native_view_id,
1250                intent,
1251            );
1252            Err(WebViewError::WebView(
1253                "trusted data load lost its WebView lifecycle binding".to_string(),
1254            ))
1255        }
1256    }
1257}
1258
1259impl Drop for TrustedDataLoadReservation<'_> {
1260    fn drop(&mut self) {
1261        if let Some(intent) = self.intent.take() {
1262            crate::events::normalizer::revoke_trusted_load(
1263                &self.webtag,
1264                self.native_view_id,
1265                intent,
1266            );
1267        }
1268    }
1269}
1270
1271#[cfg_attr(
1272    not(any(
1273        target_os = "android",
1274        target_os = "ios",
1275        target_os = "macos",
1276        target_os = "windows"
1277    )),
1278    allow(dead_code)
1279)]
1280fn snapshot_web_message_context(
1281    native_view_id: NativeWebViewId,
1282    frame: WebMessageFrame,
1283    transport: WebMessageTransport,
1284    source: WebMessageSource,
1285) -> WebMessageContext {
1286    // This load is the message callback's document-binding linearization
1287    // point. A navigation start which wins it produces Unbound; a commit which
1288    // wins it produces that generation. The queued message retains this value.
1289    WebMessageContext::new(
1290        native_view_id,
1291        crate::events::normalizer::current_document_binding(native_view_id),
1292        frame,
1293        transport,
1294        source,
1295    )
1296}
1297
1298/// A one-shot port is handed to exactly one document, so a frame arriving on
1299/// it may be labelled top-level only while that generation is still current.
1300fn document_port_context(
1301    native_view: NativeWebViewId,
1302    current: crate::DocumentBinding,
1303    expected_generation: crate::DocumentGeneration,
1304    transport: WebMessageTransport,
1305    source: WebMessageSource,
1306) -> Option<WebMessageContext> {
1307    let port_transport = matches!(
1308        transport,
1309        WebMessageTransport::AndroidMessagePort | WebMessageTransport::HarmonyMessagePort
1310    );
1311    (port_transport && current == crate::DocumentBinding::Bound(expected_generation)).then(|| {
1312        WebMessageContext::new(
1313            native_view,
1314            crate::DocumentBinding::Bound(expected_generation),
1315            WebMessageFrame::TopLevel,
1316            transport,
1317            source,
1318        )
1319    })
1320}
1321
1322impl WebView {
1323    pub(crate) fn new(
1324        inner: WebViewInner,
1325        effective_options: EffectiveWebViewCreateOptions,
1326        native_view_id: NativeWebViewId,
1327    ) -> Self {
1328        Self {
1329            inner,
1330            native_view_id,
1331            effective_options,
1332            delegate: RwLock::new(None),
1333            scheme_handlers: RwLock::new(HashMap::new()),
1334            navigation_handler: RwLock::new(None),
1335            new_window_handler: RwLock::new(None),
1336            download_handler: RwLock::new(None),
1337            file_chooser_handler: RwLock::new(None),
1338            message_ingress: Arc::new(WebMessageIngress::default()),
1339        }
1340    }
1341
1342    /// Opaque identity for this concrete native WebView instance.
1343    ///
1344    /// It is readable for identity comparison across crates, but cannot be
1345    /// constructed, serialized, or converted to a raw platform handle by
1346    /// consumers.
1347    pub const fn native_view_id(&self) -> NativeWebViewId {
1348        self.native_view_id
1349    }
1350
1351    /// Account for a platform adapter rejecting an oversized raw message
1352    /// before it allocates the cross-platform `String` used by the queue.
1353    #[cfg(any(
1354        target_os = "android",
1355        target_os = "ios",
1356        target_os = "macos",
1357        target_os = "windows",
1358        all(target_os = "linux", target_env = "ohos")
1359    ))]
1360    pub(crate) fn reject_oversized_web_message(&self) {
1361        let WebMessageEnqueue::Rejected { reason, count } = self
1362            .message_ingress
1363            .reject(WebMessageRejectReason::MessageTooLarge)
1364        else {
1365            unreachable!("rejecting an ingress message must return a rejection")
1366        };
1367        if should_sample_rejection(count) {
1368            log::warn!(
1369                "Dropping WebView message reason={} count={} ({})",
1370                reason.as_str(),
1371                count,
1372                self.webtag()
1373            );
1374        }
1375    }
1376
1377    /// Reserve a trusted native HTML load before starting the native operation.
1378    ///
1379    /// Register [`TrustedDataLoadReservation::intent`] with document state
1380    /// before calling `load`; a native callback is permitted to arrive before
1381    /// `load` returns. The reservation's Drop implementation revokes an
1382    /// unused token. HTML and base URLs are resource-location inputs, never
1383    /// authority.
1384    pub fn prepare_trusted_data_load(
1385        &self,
1386    ) -> Result<TrustedDataLoadReservation<'_>, WebViewError> {
1387        let webtag = self.webtag();
1388        let native_view_id = self.native_view_id;
1389        let intent = crate::events::normalizer::issue_trusted_load(&webtag, native_view_id)
1390            .ok_or_else(|| {
1391                WebViewError::WebView(
1392                    "trusted data load requires a live matching WebView lifecycle".to_string(),
1393                )
1394            })?;
1395        Ok(TrustedDataLoadReservation {
1396            webview: self,
1397            webtag,
1398            native_view_id,
1399            intent: Some(intent),
1400        })
1401    }
1402
1403    #[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
1404    fn load_trusted_data_on_platform(
1405        &self,
1406        _intent: TrustedLoadIntent,
1407        request: LoadDataRequest<'_>,
1408    ) -> Result<TrustedDataLoadEvidence, WebViewError> {
1409        self.inner
1410            .load_trusted_data(request)
1411            .map(TrustedDataLoadEvidence::NativeKey)
1412    }
1413
1414    #[cfg(target_os = "windows")]
1415    fn load_trusted_data_on_platform(
1416        &self,
1417        intent: TrustedLoadIntent,
1418        request: LoadDataRequest<'_>,
1419    ) -> Result<TrustedDataLoadEvidence, WebViewError> {
1420        self.inner.load_trusted_data(intent, request)?;
1421        Ok(TrustedDataLoadEvidence::PlatformAttested)
1422    }
1423
1424    #[cfg(all(target_os = "linux", target_env = "ohos"))]
1425    fn load_trusted_data_on_platform(
1426        &self,
1427        intent: TrustedLoadIntent,
1428        request: LoadDataRequest<'_>,
1429    ) -> Result<TrustedDataLoadEvidence, WebViewError> {
1430        self.inner
1431            .load_trusted_data(intent, request)
1432            .map(|()| TrustedDataLoadEvidence::PlatformAttested)
1433    }
1434
1435    #[cfg(not(any(
1436        target_os = "ios",
1437        target_os = "macos",
1438        target_os = "windows",
1439        target_os = "android",
1440        all(target_os = "linux", target_env = "ohos")
1441    )))]
1442    fn load_trusted_data_on_platform(
1443        &self,
1444        _intent: TrustedLoadIntent,
1445        _request: LoadDataRequest<'_>,
1446    ) -> Result<TrustedDataLoadEvidence, WebViewError> {
1447        Err(WebViewError::Unsupported(
1448            "trusted direct HTML loads require a platform navigation key".to_string(),
1449        ))
1450    }
1451
1452    #[cfg_attr(
1453        not(any(
1454            target_os = "android",
1455            target_os = "ios",
1456            target_os = "macos",
1457            target_os = "windows"
1458        )),
1459        allow(dead_code)
1460    )]
1461    /// Enqueue a platform message whose frame proof is known by the adapter.
1462    ///
1463    /// The document binding is snapshotted from the normalizer while this
1464    /// concrete native WebView is current; adapters cannot claim a generation
1465    /// through this entry point.
1466    pub(crate) fn enqueue_web_message(
1467        self: &Arc<Self>,
1468        body: String,
1469        frame: WebMessageFrame,
1470        transport: WebMessageTransport,
1471        source: WebMessageSource,
1472    ) {
1473        let context = snapshot_web_message_context(self.native_view_id, frame, transport, source);
1474        match self
1475            .message_ingress
1476            .enqueue(IncomingWebMessage::new(body, context))
1477        {
1478            WebMessageEnqueue::Queued => return,
1479            WebMessageEnqueue::Rejected { reason, count } => {
1480                if should_sample_rejection(count) {
1481                    log::warn!(
1482                        "Dropping WebView message reason={} count={} ({})",
1483                        reason.as_str(),
1484                        count,
1485                        self.webtag()
1486                    );
1487                }
1488                return;
1489            }
1490            WebMessageEnqueue::Schedule => {}
1491        }
1492
1493        if WebMessageExecutor::global()
1494            .schedule(WebMessageJob {
1495                ingress: Arc::clone(&self.message_ingress),
1496                webview: Arc::downgrade(self),
1497            })
1498            .is_err()
1499        {
1500            // A fixed worker unexpectedly exiting must not leave this ingress
1501            // scheduled forever, nor let later callbacks restart its delivery.
1502            self.message_ingress.close();
1503            log::error!(
1504                "Dropping WebView message queue because the fixed executor stopped ({})",
1505                self.webtag()
1506            );
1507        }
1508    }
1509
1510    /// Enqueue a MessagePort frame only for the exact generation captured
1511    /// when that one-shot port was created. A port retained by an old page can
1512    /// never be rebound by sampling the successor document's current state.
1513    #[cfg_attr(
1514        not(any(target_os = "android", all(target_os = "linux", target_env = "ohos"))),
1515        allow(dead_code)
1516    )]
1517    pub(crate) fn enqueue_document_web_message(
1518        self: &Arc<Self>,
1519        body: String,
1520        expected_generation: crate::DocumentGeneration,
1521        transport: WebMessageTransport,
1522        source: WebMessageSource,
1523    ) {
1524        let Some(context) = document_port_context(
1525            self.native_view_id,
1526            crate::events::normalizer::current_document_binding(self.native_view_id),
1527            expected_generation,
1528            transport,
1529            source,
1530        ) else {
1531            log::debug!(
1532                "Dropping stale document MessagePort frame ({})",
1533                self.webtag()
1534            );
1535            return;
1536        };
1537        match self
1538            .message_ingress
1539            .enqueue(IncomingWebMessage::new(body, context))
1540        {
1541            WebMessageEnqueue::Queued => return,
1542            WebMessageEnqueue::Rejected { reason, count } => {
1543                if should_sample_rejection(count) {
1544                    log::warn!(
1545                        "Dropping document MessagePort frame reason={} count={} ({})",
1546                        reason.as_str(),
1547                        count,
1548                        self.webtag()
1549                    );
1550                }
1551                return;
1552            }
1553            WebMessageEnqueue::Schedule => {}
1554        }
1555        if WebMessageExecutor::global()
1556            .schedule(WebMessageJob {
1557                ingress: Arc::clone(&self.message_ingress),
1558                webview: Arc::downgrade(self),
1559            })
1560            .is_err()
1561        {
1562            self.message_ingress.close();
1563        }
1564    }
1565
1566    fn close_message_ingress(&self) {
1567        self.message_ingress.close();
1568    }
1569
1570    /// Read the document binding currently owned by this native WebView.
1571    ///
1572    /// This is intentionally read-only. Only accepted top-level navigation
1573    /// starts and reliable commits in the event normalizer can change it.
1574    pub fn current_document_binding(&self) -> crate::DocumentBinding {
1575        crate::events::normalizer::current_document_binding(self.native_view_id)
1576    }
1577
1578    /// Get the appid
1579    pub fn appid(&self) -> String {
1580        self.inner.webtag.extract_appid()
1581    }
1582
1583    /// Get the path
1584    pub fn path(&self) -> String {
1585        self.inner.webtag.extract_parts().1
1586    }
1587
1588    /// Get the webtag (computed from appid and path)
1589    pub fn webtag(&self) -> WebTag {
1590        self.inner.webtag.clone()
1591    }
1592
1593    pub(crate) fn effective_options(&self) -> &EffectiveWebViewCreateOptions {
1594        &self.effective_options
1595    }
1596
1597    /// Get delegate for this WebView
1598    pub(crate) fn get_delegate(&self) -> Option<Arc<dyn WebViewDelegate>> {
1599        self.delegate.read().ok().and_then(|guard| guard.clone())
1600    }
1601
1602    /// Remove delegate for this WebView
1603    pub(crate) fn remove_delegate(&self) {
1604        if let Ok(mut guard) = self.delegate.write() {
1605            *guard = None;
1606        }
1607    }
1608
1609    /// Install all pending callbacks into this WebView (called once during creation).
1610    pub(crate) fn install_callbacks(&self, callbacks: PendingCallbacks) {
1611        if let Some(delegate) = callbacks.delegate
1612            && let Ok(mut guard) = self.delegate.write()
1613        {
1614            *guard = Some(delegate);
1615        }
1616        if let Ok(mut guard) = self.scheme_handlers.write() {
1617            *guard = callbacks.scheme_handlers;
1618        }
1619        if let Some(handler) = callbacks.navigation_handler
1620            && let Ok(mut guard) = self.navigation_handler.write()
1621        {
1622            *guard = Some(handler);
1623        }
1624        if let Some(handler) = callbacks.new_window_handler
1625            && let Ok(mut guard) = self.new_window_handler.write()
1626        {
1627            *guard = Some(handler);
1628        }
1629        if let Some(handler) = callbacks.download_handler
1630            && let Ok(mut guard) = self.download_handler.write()
1631        {
1632            *guard = Some(handler);
1633        }
1634        if let Some(handler) = callbacks.file_chooser_handler
1635            && let Ok(mut guard) = self.file_chooser_handler.write()
1636        {
1637            *guard = Some(handler);
1638        }
1639    }
1640
1641    /// Check if a scheme handler is registered for the given scheme.
1642    pub fn has_scheme_handler(&self, scheme: &str) -> bool {
1643        self.scheme_handlers
1644            .read()
1645            .ok()
1646            .is_some_and(|guard| guard.contains_key(scheme))
1647    }
1648
1649    /// Synchronously invoke the registered scheme handler for `scheme`.
1650    /// Returns `None` if no handler is registered or the handler declines.
1651    ///
1652    /// Compatibility ingress for out-of-tree / older adapters. It preserves
1653    /// the exact owning native instance but has no frame proof, so it always
1654    /// invokes contextual handlers with [`SchemeRequestFrame::Unproven`].
1655    #[allow(dead_code)]
1656    pub(crate) fn handle_scheme_request(
1657        &self,
1658        scheme: &str,
1659        request: http::Request<Vec<u8>>,
1660    ) -> Option<WebResourceResponse> {
1661        self.handle_contextual_scheme_request(
1662            scheme,
1663            ContextualSchemeRequest::new(
1664                request,
1665                self.native_view_id(),
1666                SchemeRequestFrame::Unproven,
1667            ),
1668        )
1669    }
1670
1671    /// Invoke a registered scheme handler with adapter-attested callback
1672    /// context. Platform code must validate its callback identity before
1673    /// constructing the request.
1674    pub(crate) fn handle_contextual_scheme_request(
1675        &self,
1676        scheme: &str,
1677        request: ContextualSchemeRequest,
1678    ) -> Option<WebResourceResponse> {
1679        #[cfg(any(target_os = "ios", target_os = "macos"))]
1680        if let Some(response) = self.inner.handle_internal_bridge_request(request.request()) {
1681            return Some(response);
1682        }
1683
1684        let guard = self.scheme_handlers.read().ok()?;
1685        let handler = guard.get(scheme)?;
1686        let outcome = block_on_scheme_future(handler(request));
1687        match outcome {
1688            SchemeOutcome::Handled(response) => Some(response),
1689            SchemeOutcome::PassThrough => None,
1690        }
1691    }
1692
1693    /// Call the navigation handler. Returns `Allow` if no handler is registered.
1694    ///
1695    /// A URL matching an open [`crate::url_callback`] channel is delivered to
1696    /// that channel and cancelled before any per-webview handler runs.
1697    pub fn handle_navigation(&self, request: &NavigationRequest) -> NavigationPolicy {
1698        if crate::url_callback::dispatch(&request.url) {
1699            return NavigationPolicy::Cancel;
1700        }
1701        if let Ok(guard) = self.navigation_handler.read()
1702            && let Some(handler) = guard.as_ref()
1703        {
1704            return handler(request);
1705        }
1706        NavigationPolicy::Allow
1707    }
1708
1709    /// Check if a new-window handler is registered.
1710    pub fn has_new_window_handler(&self) -> bool {
1711        self.new_window_handler
1712            .read()
1713            .ok()
1714            .is_some_and(|guard| guard.is_some())
1715    }
1716
1717    /// Call the new-window handler. Returns `Cancel` if no handler is registered.
1718    ///
1719    /// A URL matching an open [`crate::url_callback`] channel is delivered to
1720    /// that channel and cancelled before any per-webview handler runs.
1721    pub fn handle_new_window(&self, url: &str) -> NewWindowPolicy {
1722        if crate::url_callback::dispatch(url) {
1723            return NewWindowPolicy::Cancel;
1724        }
1725        if let Ok(guard) = self.new_window_handler.read()
1726            && let Some(handler) = guard.as_ref()
1727        {
1728            return handler(url);
1729        }
1730        NewWindowPolicy::Cancel
1731    }
1732
1733    /// Dispatch a download request to the registered handler.
1734    pub(crate) fn handle_download(&self, request: DownloadRequest) {
1735        if let Ok(guard) = self.download_handler.read()
1736            && let Some(handler) = guard.as_ref()
1737        {
1738            handler(request);
1739        }
1740    }
1741
1742    // Consulted only by the Windows download-event path.
1743    #[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1744    pub(crate) fn has_download_handler(&self) -> bool {
1745        self.download_handler
1746            .read()
1747            .ok()
1748            .is_some_and(|guard| guard.is_some())
1749    }
1750
1751    #[cfg_attr(target_os = "windows", allow(dead_code))]
1752    pub(crate) fn handle_file_chooser<C>(&self, request: FileChooserRequest, completion: C) -> bool
1753    where
1754        C: FnOnce(FileChooserResponse) + Send + 'static,
1755    {
1756        let Some(future) = self.make_file_chooser_future(request) else {
1757            return false;
1758        };
1759        std::thread::spawn(move || {
1760            completion(block_on_scheme_future(future));
1761        });
1762        true
1763    }
1764
1765    #[cfg_attr(target_os = "windows", allow(dead_code))]
1766    fn make_file_chooser_future(&self, request: FileChooserRequest) -> Option<FileChooserFuture> {
1767        let Ok(guard) = self.file_chooser_handler.read() else {
1768            return None;
1769        };
1770        let handler = guard.as_ref()?;
1771        Some(handler(request))
1772    }
1773
1774    /// Toggle docked DevTools (macOS only, uses private _inspector API)
1775    #[cfg(target_os = "macos")]
1776    pub fn toggle_devtools(&self) {
1777        self.inner.toggle_devtools();
1778    }
1779
1780    /// Toggle detached DevTools (macOS only, uses private _inspector API)
1781    #[cfg(target_os = "macos")]
1782    pub fn toggle_devtools_detached(&self) {
1783        self.inner.toggle_devtools_detached();
1784    }
1785
1786    /// Get platform-specific pointer for interop (Apple platforms only)
1787    #[cfg(any(target_os = "ios", target_os = "macos"))]
1788    pub fn get_swift_webview_ptr(&self) -> usize {
1789        self.inner.get_swift_webview_ptr()
1790    }
1791
1792    /// Get Java WebView reference (Android only)
1793    #[cfg(target_os = "android")]
1794    pub fn get_java_webview(&self) -> &jni::objects::Global<jni::objects::JObject<'static>> {
1795        self.inner.get_java_webview()
1796    }
1797
1798    pub async fn evaluate_javascript(
1799        &self,
1800        js: &str,
1801    ) -> Result<serde_json::Value, crate::WebViewScriptError> {
1802        self.inner.eval_js(js).await
1803    }
1804
1805    /// Synthetic-event click for platforms that don't expose a native touch
1806    /// injection API (iOS WKWebView, ArkWeb on Harmony). Looks up the
1807    /// selector, scrolls it into view, and dispatches a synthetic
1808    /// `MouseEvent` (or sets `focus="true"` for `<lx-*>` custom elements
1809    /// that proxy focus to a native overlay).
1810    /// Run a page-input action script and decode its `{ok, error, interactable}`
1811    /// result.
1812    #[cfg(any(
1813        target_os = "ios",
1814        target_os = "android",
1815        all(feature = "webview-input", target_os = "macos"),
1816        all(target_os = "linux", target_env = "ohos")
1817    ))]
1818    async fn run_js_action(&self, script: &str) -> Result<(), WebViewInputError> {
1819        let result = self
1820            .inner
1821            .eval_js(script)
1822            .await
1823            .map_err(WebViewInputError::Script)?;
1824        if result.get("ok").and_then(|v| v.as_bool()) == Some(true) {
1825            return Ok(());
1826        }
1827        let err_msg = result
1828            .get("error")
1829            .and_then(|v| v.as_str())
1830            .unwrap_or("input action failed")
1831            .to_string();
1832        if result.get("interactable").and_then(|v| v.as_bool()) == Some(false) {
1833            Err(WebViewInputError::ElementNotInteractable(err_msg))
1834        } else {
1835            Err(WebViewInputError::ElementNotFound(err_msg))
1836        }
1837    }
1838
1839    /// Click an element by synthesizing DOM events. The shared input mechanism
1840    /// for platforms/hosts where native event dispatch cannot reach the page:
1841    /// iOS (no `UITouch` synthesis), OpenHarmony, and macOS when the WebView is
1842    /// detached (AppUI renders pages off-surface). `lx-` custom elements proxy
1843    /// focus to their native overlay instead of receiving mouse events.
1844    #[cfg(any(
1845        target_os = "ios",
1846        all(feature = "webview-input", target_os = "macos"),
1847        all(target_os = "linux", target_env = "ohos")
1848    ))]
1849    pub(crate) async fn click_via_js(
1850        &self,
1851        selector: &str,
1852        index: Option<usize>,
1853    ) -> Result<(), WebViewInputError> {
1854        let selector_json = serde_json::to_string(selector)
1855            .map_err(|err| WebViewInputError::Platform(format!("Invalid selector: {err}")))?;
1856        let idx = index.unwrap_or(0);
1857        let script = format!(
1858            "((sel, i) => {{ \
1859              const els = document.querySelectorAll(sel); \
1860              if (!els.length || i < 0 || i >= els.length) return {{ ok:false, error:'no match', count:els.length }}; \
1861              const el = els[i]; \
1862              try {{ el.scrollIntoView({{block:'center', inline:'center'}}); }} catch(_e) {{}} \
1863              const rect = el.getBoundingClientRect(); \
1864              const style = window.getComputedStyle(el); \
1865              const disabled = !!el.disabled || el.getAttribute('aria-disabled') === 'true'; \
1866              const visible = rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0 && \
1867                rect.top < window.innerHeight && rect.left < window.innerWidth && \
1868                style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity || '1') !== 0; \
1869              if (!visible) return {{ ok:false, error:'not visible', interactable:false, count:els.length }}; \
1870              if (disabled) return {{ ok:false, error:'not enabled', interactable:false, count:els.length }}; \
1871              const tag = (el.tagName || '').toLowerCase(); \
1872              if (tag.indexOf('lx-') === 0) {{ \
1873                el.setAttribute('focus', 'true'); \
1874                if (typeof el.syncNativeProps === 'function') {{ try {{ el.syncNativeProps(); }} catch(_e) {{}} }} \
1875                return {{ ok:true, count:els.length, native:true }}; \
1876              }} \
1877              if (typeof el.focus === 'function') {{ try {{ el.focus({{preventScroll:true}}); }} catch(_e) {{ try {{ el.focus(); }} catch(__){{}} }} }} \
1878              const opts = {{ bubbles:true, cancelable:true, view:window, clientX: rect.left + rect.width/2, clientY: rect.top + rect.height/2 }}; \
1879              try {{ if (window.PointerEvent) el.dispatchEvent(new PointerEvent('pointerdown', Object.assign({{pointerId:1, isPrimary:true, pointerType:'mouse'}}, opts))); }} catch(_e) {{}} \
1880              try {{ el.dispatchEvent(new MouseEvent('mousedown', opts)); }} catch(_e) {{}} \
1881              try {{ if (window.PointerEvent) el.dispatchEvent(new PointerEvent('pointerup', Object.assign({{pointerId:1, isPrimary:true, pointerType:'mouse'}}, opts))); }} catch(_e) {{}} \
1882              try {{ el.dispatchEvent(new MouseEvent('mouseup', opts)); }} catch(_e) {{}} \
1883              try {{ el.dispatchEvent(new MouseEvent('click', opts)); }} catch(_e) {{}} \
1884              return {{ ok:true, count:els.length }}; \
1885            }})({selector_json}, {idx})"
1886        );
1887        self.run_js_action(&script).await
1888    }
1889
1890    /// Type text into an editable element by synthesizing DOM events. Goes
1891    /// through the native value setter so framework-tracked inputs (React) fire
1892    /// their `onChange`. `lx-` custom elements set their value + sync native.
1893    #[cfg(any(
1894        target_os = "ios",
1895        target_os = "android",
1896        all(feature = "webview-input", target_os = "macos"),
1897        all(target_os = "linux", target_env = "ohos")
1898    ))]
1899    pub(crate) async fn type_via_js(
1900        &self,
1901        selector: &str,
1902        index: Option<usize>,
1903        text: &str,
1904        replace: bool,
1905    ) -> Result<(), WebViewInputError> {
1906        let selector_json = serde_json::to_string(selector)
1907            .map_err(|err| WebViewInputError::Platform(format!("Invalid selector: {err}")))?;
1908        let text_json = serde_json::to_string(text)
1909            .map_err(|err| WebViewInputError::Platform(format!("Invalid text: {err}")))?;
1910        let idx = index.unwrap_or(0);
1911        let script = format!(
1912            "((sel, i, text, replace) => {{ \
1913              const els = document.querySelectorAll(sel); \
1914              if (!els.length || i < 0 || i >= els.length) return {{ ok:false, error:'no match', count:els.length }}; \
1915              const el = els[i]; \
1916              try {{ el.scrollIntoView({{block:'center', inline:'center'}}); }} catch(_e) {{}} \
1917              if (typeof el.focus === 'function') {{ try {{ el.focus({{preventScroll:true}}); }} catch(_e) {{ try {{ el.focus(); }} catch(__){{}} }} }} \
1918              const tag = (el.tagName || '').toLowerCase(); \
1919              if (tag === 'input' || tag === 'textarea') {{ \
1920                const proto = tag === 'textarea' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; \
1921                const desc = Object.getOwnPropertyDescriptor(proto, 'value'); \
1922                const next = (replace ? '' : (el.value || '')) + text; \
1923                if (desc && desc.set) {{ desc.set.call(el, next); }} else {{ el.value = next; }} \
1924                el.dispatchEvent(new InputEvent('input', {{ bubbles:true, cancelable:true, data:text, inputType:'insertText' }})); \
1925                el.dispatchEvent(new Event('change', {{ bubbles:true }})); \
1926                return {{ ok:true, count:els.length }}; \
1927              }} \
1928              if (el.isContentEditable) {{ \
1929                el.textContent = (replace ? '' : (el.textContent || '')) + text; \
1930                el.dispatchEvent(new InputEvent('input', {{ bubbles:true, data:text, inputType:'insertText' }})); \
1931                return {{ ok:true, count:els.length }}; \
1932              }} \
1933              if (tag.indexOf('lx-') === 0) {{ \
1934                try {{ el.value = (replace ? '' : (el.value || '')) + text; }} catch(_e) {{}} \
1935                if (typeof el.syncNativeProps === 'function') {{ try {{ el.syncNativeProps(); }} catch(_e) {{}} }} \
1936                el.dispatchEvent(new Event('input', {{ bubbles:true }})); \
1937                return {{ ok:true, count:els.length, native:true }}; \
1938              }} \
1939              return {{ ok:false, error:'not editable', interactable:false, count:els.length }}; \
1940            }})({selector_json}, {idx}, {text_json}, {replace})"
1941        );
1942        self.run_js_action(&script).await
1943    }
1944
1945    /// Press a key by synthesizing keydown/keyup on the selected or focused element.
1946    #[cfg(any(
1947        target_os = "ios",
1948        target_os = "android",
1949        all(feature = "webview-input", target_os = "macos"),
1950        all(target_os = "linux", target_env = "ohos")
1951    ))]
1952    pub(crate) async fn press_via_js(
1953        &self,
1954        key: &str,
1955        selector: Option<&str>,
1956        index: Option<usize>,
1957    ) -> Result<(), WebViewInputError> {
1958        let key_json = serde_json::to_string(key)
1959            .map_err(|err| WebViewInputError::Platform(format!("Invalid key: {err}")))?;
1960        let selector_json = serde_json::to_string(&selector)
1961            .map_err(|err| WebViewInputError::Platform(format!("Invalid selector: {err}")))?;
1962        let idx = index.unwrap_or(0);
1963        let script = format!(
1964            "((key, sel, i) => {{ \
1965              const els = sel === null ? null : document.querySelectorAll(sel); \
1966              if (els && (!els.length || i < 0 || i >= els.length)) return {{ ok:false, error:'no match', count:els.length }}; \
1967              const el = els ? els[i] : (document.activeElement || document.body); \
1968              if (els) {{ \
1969                try {{ el.scrollIntoView({{block:'center', inline:'center'}}); }} catch(_e) {{}} \
1970                if (typeof el.focus === 'function') {{ try {{ el.focus({{preventScroll:true}}); }} catch(_e) {{ try {{ el.focus(); }} catch(__){{}} }} }} \
1971              }} \
1972              const map = {{ enter:'Enter', 'return':'Enter', tab:'Tab', esc:'Escape', escape:'Escape', backspace:'Backspace', 'delete':'Delete', forwarddelete:'Delete', space:' ', up:'ArrowUp', down:'ArrowDown', left:'ArrowLeft', right:'ArrowRight', arrowup:'ArrowUp', arrowdown:'ArrowDown', arrowleft:'ArrowLeft', arrowright:'ArrowRight', home:'Home', end:'End', pageup:'PageUp', pagedown:'PageDown' }}; \
1973              const norm = String(key).toLowerCase(); \
1974              const k = map[norm] || key; \
1975              const opts = {{ bubbles:true, cancelable:true, composed:true, key:k, view:window }}; \
1976              el.dispatchEvent(new KeyboardEvent('keydown', opts)); \
1977              el.dispatchEvent(new KeyboardEvent('keyup', opts)); \
1978              return {{ ok:true }}; \
1979            }})({key_json}, {selector_json}, {idx})"
1980        );
1981        self.run_js_action(&script).await
1982    }
1983
1984    /// Scroll by `(dx, dy)` in the DOM. Walks up from the element at the given
1985    /// viewport point (default: center) to the nearest scrollable ancestor, so
1986    /// it scrolls internal scroll containers, not just the document. When a
1987    /// webview reports `innerWidth/Height` as 0, the center point is unusable,
1988    /// so it falls back to the largest scrollable element, then the document
1989    /// scroller. Uses direct `scrollTop`/`scrollLeft` assignment, not
1990    /// `scrollBy`: on iOS WKWebView `scrollBy` animates sub-scrollers and
1991    /// overshoots to 2x the delta. NB: the built script must contain no `//`
1992    /// line comments — the `\`-continued format string collapses to one line.
1993    #[cfg(any(
1994        target_os = "ios",
1995        all(feature = "webview-input", target_os = "macos"),
1996        all(target_os = "linux", target_env = "ohos")
1997    ))]
1998    pub(crate) async fn scroll_via_js(
1999        &self,
2000        at: Option<(f64, f64)>,
2001        dx: f64,
2002        dy: f64,
2003    ) -> Result<(), WebViewInputError> {
2004        let (px, py) = at.unwrap_or((-1.0, -1.0));
2005        let script = format!(
2006            "((px, py, dx, dy) => {{ \
2007              const overflows = (v) => (/(auto|scroll|overlay)/).test(v); \
2008              const ancestor = (node) => {{ \
2009                while (node && node !== document.body && node !== document.documentElement) {{ \
2010                  const s = window.getComputedStyle(node); \
2011                  if ((overflows(s.overflowY) && node.scrollHeight > node.clientHeight) || \
2012                      (overflows(s.overflowX) && node.scrollWidth > node.clientWidth)) return node; \
2013                  node = node.parentElement; \
2014                }} \
2015                return null; \
2016              }}; \
2017              const largest = () => {{ \
2018                let best = null, range = 0; \
2019                const all = document.querySelectorAll('*'); \
2020                for (let k = 0; k < all.length; k++) {{ \
2021                  const n = all[k], s = window.getComputedStyle(n); \
2022                  const ry = overflows(s.overflowY) ? (n.scrollHeight - n.clientHeight) : 0; \
2023                  const rx = overflows(s.overflowX) ? (n.scrollWidth - n.clientWidth) : 0; \
2024                  const r = ry > rx ? ry : rx; \
2025                  if (r > range) {{ range = r; best = n; }} \
2026                }} \
2027                return best; \
2028              }}; \
2029              const vw = window.innerWidth || document.documentElement.clientWidth || 0; \
2030              const vh = window.innerHeight || document.documentElement.clientHeight || 0; \
2031              let target = null; \
2032              if (px >= 0 && py >= 0) target = ancestor(document.elementFromPoint(px, py) || document.body); \
2033              else if (vw > 0 && vh > 0) target = ancestor(document.elementFromPoint(vw >> 1, vh >> 1) || document.body); \
2034              if (!target) {{ \
2035                const se = document.scrollingElement || document.documentElement; \
2036                target = (se && se.scrollHeight > se.clientHeight) ? se : (largest() || se); \
2037              }} \
2038              target.scrollLeft += dx; target.scrollTop += dy; \
2039              return {{ ok:true }}; \
2040            }})({px}, {py}, {dx}, {dy})"
2041        );
2042        self.run_js_action(&script).await
2043    }
2044
2045    /// Scroll an element into view (`scrollIntoView`).
2046    #[cfg(any(
2047        target_os = "ios",
2048        all(feature = "webview-input", target_os = "macos"),
2049        all(target_os = "linux", target_env = "ohos")
2050    ))]
2051    pub(crate) async fn scroll_to_via_js(
2052        &self,
2053        selector: &str,
2054        index: Option<usize>,
2055    ) -> Result<(), WebViewInputError> {
2056        let selector_json = serde_json::to_string(selector)
2057            .map_err(|err| WebViewInputError::Platform(format!("Invalid selector: {err}")))?;
2058        let idx = index.unwrap_or(0);
2059        let script = format!(
2060            "((sel, i) => {{ \
2061              const els = document.querySelectorAll(sel); \
2062              if (!els.length || i < 0 || i >= els.length) return {{ ok:false, error:'no match', count:els.length }}; \
2063              try {{ els[i].scrollIntoView({{ block:'center', inline:'center' }}); }} catch(_e) {{ els[i].scrollIntoView(); }} \
2064              return {{ ok:true, count:els.length }}; \
2065            }})({selector_json}, {idx})"
2066        );
2067        self.run_js_action(&script).await
2068    }
2069
2070    pub async fn current_url(&self) -> Result<Option<String>, WebViewError> {
2071        self.inner.current_url().await
2072    }
2073
2074    pub fn reload(&self) -> Result<(), WebViewError> {
2075        self.inner.reload()
2076    }
2077
2078    pub fn go_back(&self) -> Result<(), WebViewError> {
2079        self.inner.go_back()
2080    }
2081
2082    pub fn go_forward(&self) -> Result<(), WebViewError> {
2083        self.inner.go_forward()
2084    }
2085
2086    pub async fn list_cookies(&self) -> Result<Vec<WebViewCookie>, WebViewError> {
2087        self.inner.list_cookies().await
2088    }
2089
2090    pub async fn set_cookie(&self, request: WebViewCookieSetRequest) -> Result<(), WebViewError> {
2091        self.inner.set_cookie(request).await
2092    }
2093
2094    pub async fn delete_cookie(
2095        &self,
2096        name: &str,
2097        domain: &str,
2098        path: &str,
2099    ) -> Result<(), WebViewError> {
2100        self.inner.delete_cookie(name, domain, path).await
2101    }
2102
2103    pub async fn clear_cookies(&self) -> Result<(), WebViewError> {
2104        self.inner.clear_cookies().await
2105    }
2106
2107    pub async fn start_network_capture(&self) -> Result<(), WebViewError> {
2108        self.inner.start_network_capture().await
2109    }
2110
2111    pub async fn stop_network_capture(&self) -> Result<(), WebViewError> {
2112        self.inner.stop_network_capture().await
2113    }
2114
2115    pub async fn network_entries(&self) -> Result<NetworkCaptureSnapshot, WebViewError> {
2116        self.inner.network_entries().await
2117    }
2118
2119    pub async fn clear_network_capture(&self) -> Result<(), WebViewError> {
2120        self.inner.clear_network_capture().await
2121    }
2122
2123    pub async fn take_screenshot(&self) -> Result<Vec<u8>, WebViewError> {
2124        self.inner.take_screenshot().await
2125    }
2126
2127    pub async fn click(
2128        &self,
2129        selector: &str,
2130        options: ClickOptions,
2131    ) -> Result<(), WebViewInputError> {
2132        <Self as WebViewInputController>::click(self, selector, options).await
2133    }
2134
2135    pub async fn type_text(
2136        &self,
2137        selector: &str,
2138        text: &str,
2139        options: TypeOptions,
2140    ) -> Result<(), WebViewInputError> {
2141        <Self as WebViewInputController>::type_text(self, selector, text, options).await
2142    }
2143
2144    pub async fn fill(
2145        &self,
2146        selector: &str,
2147        text: &str,
2148        options: FillOptions,
2149    ) -> Result<(), WebViewInputError> {
2150        <Self as WebViewInputController>::fill(self, selector, text, options).await
2151    }
2152
2153    pub async fn press(&self, key: &str, options: PressOptions) -> Result<(), WebViewInputError> {
2154        <Self as WebViewInputController>::press(self, key, options).await
2155    }
2156
2157    pub async fn scroll(
2158        &self,
2159        dx: f64,
2160        dy: f64,
2161        options: ScrollOptions,
2162    ) -> Result<(), WebViewInputError> {
2163        <Self as WebViewInputController>::scroll(self, dx, dy, options).await
2164    }
2165
2166    pub async fn scroll_to(
2167        &self,
2168        selector: &str,
2169        options: ScrollOptions,
2170    ) -> Result<(), WebViewInputError> {
2171        <Self as WebViewInputController>::scroll_to(self, selector, options).await
2172    }
2173}
2174
2175#[async_trait]
2176impl WebViewController for WebView {
2177    fn load_url(&self, url: &str) -> Result<(), WebViewError> {
2178        self.inner.load_url(url)
2179    }
2180
2181    fn load_data(&self, request: LoadDataRequest<'_>) -> Result<(), WebViewError> {
2182        self.inner.load_data(request)
2183    }
2184
2185    fn exec_js(&self, js: &str) -> Result<(), WebViewError> {
2186        self.inner.exec_js(js)
2187    }
2188
2189    async fn eval_js(&self, js: &str) -> Result<serde_json::Value, WebViewScriptError> {
2190        self.inner.eval_js(js).await
2191    }
2192
2193    async fn current_url(&self) -> Result<Option<String>, WebViewError> {
2194        self.inner.current_url().await
2195    }
2196
2197    fn post_message(&self, message: &str) -> Result<(), WebViewError> {
2198        self.inner.post_message(message)
2199    }
2200
2201    fn post_message_to_document(
2202        &self,
2203        expected_generation: crate::DocumentGeneration,
2204        gate: Arc<dyn crate::DocumentOutboundGate>,
2205        message: &str,
2206    ) -> Result<(), WebViewError> {
2207        self.inner
2208            .post_message_to_document(expected_generation, gate, message)
2209    }
2210
2211    fn clear_browsing_data(&self) -> Result<(), WebViewError> {
2212        self.inner.clear_browsing_data()
2213    }
2214
2215    fn set_user_agent_override(&self, user_agent: UserAgentOverride) -> Result<(), WebViewError> {
2216        user_agent.validate()?;
2217        self.inner.set_user_agent_override(user_agent)
2218    }
2219
2220    fn reload(&self) -> Result<(), WebViewError> {
2221        self.inner.reload()
2222    }
2223
2224    fn go_back(&self) -> Result<(), WebViewError> {
2225        self.inner.go_back()
2226    }
2227
2228    fn go_forward(&self) -> Result<(), WebViewError> {
2229        self.inner.go_forward()
2230    }
2231
2232    async fn list_cookies(&self) -> Result<Vec<WebViewCookie>, WebViewError> {
2233        self.inner.list_cookies().await
2234    }
2235
2236    async fn set_cookie(&self, request: WebViewCookieSetRequest) -> Result<(), WebViewError> {
2237        self.inner.set_cookie(request).await
2238    }
2239
2240    async fn delete_cookie(
2241        &self,
2242        name: &str,
2243        domain: &str,
2244        path: &str,
2245    ) -> Result<(), WebViewError> {
2246        self.inner.delete_cookie(name, domain, path).await
2247    }
2248
2249    async fn clear_cookies(&self) -> Result<(), WebViewError> {
2250        self.inner.clear_cookies().await
2251    }
2252
2253    async fn clear_site_data(
2254        &self,
2255        url: &str,
2256        options: ClearSiteDataOptions,
2257    ) -> Result<ClearSiteDataResult, WebViewError> {
2258        self.inner.clear_site_data(url, options).await
2259    }
2260
2261    // Callers reach this through the inherent method today, but the trait
2262    // impl must stay exhaustive: a missed forward silently resolves to the
2263    // trait's Err default for dyn/generic dispatch (how clear_site_data
2264    // shipped broken).
2265    async fn take_screenshot(&self) -> Result<Vec<u8>, WebViewError> {
2266        self.inner.take_screenshot().await
2267    }
2268
2269    async fn start_network_capture(&self) -> Result<(), WebViewError> {
2270        self.inner.start_network_capture().await
2271    }
2272
2273    async fn stop_network_capture(&self) -> Result<(), WebViewError> {
2274        self.inner.stop_network_capture().await
2275    }
2276
2277    async fn network_entries(&self) -> Result<NetworkCaptureSnapshot, WebViewError> {
2278        self.inner.network_entries().await
2279    }
2280
2281    async fn clear_network_capture(&self) -> Result<(), WebViewError> {
2282        self.inner.clear_network_capture().await
2283    }
2284}
2285
2286#[async_trait]
2287impl WebViewInputController for WebView {
2288    async fn click(
2289        &self,
2290        _selector: &str,
2291        _options: ClickOptions,
2292    ) -> Result<(), WebViewInputError> {
2293        // macOS uses DOM synthesis for selector clicks: AppKit does not expose
2294        // a reliable permission-free way to update WKWebView hit testing from
2295        // an in-process NSEvent. Text and key input still use native WebKit
2296        // editing paths below. iOS/OpenHarmony likewise have no native touch
2297        // synthesis.
2298        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2299        {
2300            return self.click_via_js(_selector, _options.index).await;
2301        }
2302        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2303        {
2304            return self.inner.click_inner(_selector, _options).await;
2305        }
2306        #[cfg(target_os = "android")]
2307        {
2308            return self.inner.click_inner(_selector, _options).await;
2309        }
2310        #[cfg(any(target_os = "ios", all(target_os = "linux", target_env = "ohos")))]
2311        {
2312            return self.click_via_js(_selector, _options.index).await;
2313        }
2314        #[allow(unreachable_code)]
2315        Err(WebViewInputError::Unsupported(
2316            "input control is not implemented for this platform",
2317        ))
2318    }
2319
2320    async fn type_text(
2321        &self,
2322        _selector: &str,
2323        _text: &str,
2324        _options: TypeOptions,
2325    ) -> Result<(), WebViewInputError> {
2326        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2327        {
2328            if self.inner.is_window_attached().await {
2329                return self.inner.type_text_inner(_selector, _text, _options).await;
2330            }
2331            return self
2332                .type_via_js(_selector, _options.index, _text, _options.replace)
2333                .await;
2334        }
2335        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2336        {
2337            return self.inner.type_text_inner(_selector, _text, _options).await;
2338        }
2339        #[cfg(any(
2340            target_os = "ios",
2341            target_os = "android",
2342            all(target_os = "linux", target_env = "ohos")
2343        ))]
2344        {
2345            return self
2346                .type_via_js(_selector, _options.index, _text, _options.replace)
2347                .await;
2348        }
2349        #[allow(unreachable_code)]
2350        Err(WebViewInputError::Unsupported(
2351            "input control is not implemented for this platform",
2352        ))
2353    }
2354
2355    async fn fill(
2356        &self,
2357        _selector: &str,
2358        _text: &str,
2359        _options: FillOptions,
2360    ) -> Result<(), WebViewInputError> {
2361        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2362        {
2363            // `fill` is a framework-aware replacement operation. WebKit's
2364            // native InsertText command can report success before a controlled
2365            // React/Vue input observes the edit, leaving dependent controls in
2366            // their old state. `type` retains the native keyboard path.
2367            return self
2368                .type_via_js(_selector, _options.index, _text, true)
2369                .await;
2370        }
2371        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2372        {
2373            return self
2374                .inner
2375                .type_text_inner(
2376                    _selector,
2377                    _text,
2378                    TypeOptions {
2379                        index: _options.index,
2380                        replace: true,
2381                    },
2382                )
2383                .await;
2384        }
2385        #[cfg(any(
2386            target_os = "ios",
2387            target_os = "android",
2388            all(target_os = "linux", target_env = "ohos")
2389        ))]
2390        {
2391            return self
2392                .type_via_js(_selector, _options.index, _text, true)
2393                .await;
2394        }
2395        #[allow(unreachable_code)]
2396        Err(WebViewInputError::Unsupported(
2397            "input control is not implemented for this platform",
2398        ))
2399    }
2400
2401    async fn press(&self, _key: &str, _options: PressOptions) -> Result<(), WebViewInputError> {
2402        if _options.index.is_some() && _options.selector.is_none() {
2403            return Err(WebViewInputError::Platform(
2404                "press index requires a selector".to_string(),
2405            ));
2406        }
2407        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2408        {
2409            if self.inner.is_window_attached().await {
2410                return self.inner.press_inner(_key, _options).await;
2411            }
2412            return self
2413                .press_via_js(_key, _options.selector.as_deref(), _options.index)
2414                .await;
2415        }
2416        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2417        {
2418            return self.inner.press_inner(_key, _options).await;
2419        }
2420        #[cfg(any(
2421            target_os = "ios",
2422            target_os = "android",
2423            all(target_os = "linux", target_env = "ohos")
2424        ))]
2425        {
2426            return self
2427                .press_via_js(_key, _options.selector.as_deref(), _options.index)
2428                .await;
2429        }
2430        #[allow(unreachable_code)]
2431        Err(WebViewInputError::Unsupported(
2432            "input control is not implemented for this platform",
2433        ))
2434    }
2435
2436    async fn scroll(
2437        &self,
2438        _dx: f64,
2439        _dy: f64,
2440        _options: ScrollOptions,
2441    ) -> Result<(), WebViewInputError> {
2442        // AppUI renders lxapp pages as native surfaces with the WKWebView
2443        // detached, so native scroll wheel events can't reach the DOM — use JS.
2444        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2445        {
2446            if self.inner.is_window_attached().await {
2447                return self.inner.scroll_inner(_dx, _dy, _options).await;
2448            }
2449            return self.scroll_via_js(None, _dx, _dy).await;
2450        }
2451        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2452        {
2453            return self.inner.scroll_inner(_dx, _dy, _options).await;
2454        }
2455        // Android scrolls page content in the native View layer (the DOM
2456        // document has no scroll extent), so drive WebView.scrollBy natively.
2457        #[cfg(target_os = "android")]
2458        {
2459            return self.inner.scroll_inner(_dx, _dy, _options).await;
2460        }
2461        // iOS has no native scroll synthesis; Harmony webview is always detached.
2462        #[cfg(any(target_os = "ios", all(target_os = "linux", target_env = "ohos")))]
2463        {
2464            return self.scroll_via_js(None, _dx, _dy).await;
2465        }
2466        #[allow(unreachable_code)]
2467        Err(WebViewInputError::Unsupported(
2468            "input control is not implemented for this platform",
2469        ))
2470    }
2471
2472    async fn scroll_to(
2473        &self,
2474        _selector: &str,
2475        _options: ScrollOptions,
2476    ) -> Result<(), WebViewInputError> {
2477        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2478        {
2479            if self.inner.is_window_attached().await {
2480                return self.inner.scroll_to_inner(_selector, _options).await;
2481            }
2482            return self.scroll_to_via_js(_selector, None).await;
2483        }
2484        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2485        {
2486            return self.inner.scroll_to_inner(_selector, _options).await;
2487        }
2488        #[cfg(target_os = "android")]
2489        {
2490            return self.inner.scroll_to_inner(_selector, _options).await;
2491        }
2492        #[cfg(any(target_os = "ios", all(target_os = "linux", target_env = "ohos")))]
2493        {
2494            return self.scroll_to_via_js(_selector, None).await;
2495        }
2496        #[allow(unreachable_code)]
2497        Err(WebViewInputError::Unsupported(
2498            "input control is not implemented for this platform",
2499        ))
2500    }
2501}
2502
2503/// Type alias for WebView instances storage to reduce complexity
2504type WebViewInstancesMap = Arc<Mutex<HashMap<String, Arc<WebView>>>>;
2505
2506#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2507#[serde(rename_all = "snake_case")]
2508pub enum WebViewCreateStage {
2509    Requested,
2510    NativeCreated,
2511    ControllerAttached,
2512    Ready,
2513    Destroyed,
2514}
2515
2516#[derive(Debug, Clone, PartialEq, Eq)]
2517pub enum WebViewEvent {
2518    Stage(WebViewCreateStage),
2519    Failed {
2520        stage: WebViewCreateStage,
2521        error: WebViewError,
2522    },
2523}
2524
2525type WebViewReadyState = Option<Result<Arc<WebView>, WebViewError>>;
2526
2527#[derive(Clone)]
2528pub struct WebViewEventSubscription {
2529    rx: watch::Receiver<WebViewEvent>,
2530}
2531
2532impl WebViewEventSubscription {
2533    pub fn current(&self) -> WebViewEvent {
2534        self.rx.borrow().clone()
2535    }
2536
2537    pub async fn changed(&mut self) -> Result<WebViewEvent, WebViewError> {
2538        self.rx.changed().await.map_err(|_| {
2539            WebViewError::WebView("webview event channel unexpectedly closed".to_string())
2540        })?;
2541        Ok(self.current())
2542    }
2543}
2544
2545#[derive(Clone)]
2546pub struct WebViewSession {
2547    webtag: WebTag,
2548    event_rx: watch::Receiver<WebViewEvent>,
2549    ready_rx: watch::Receiver<WebViewReadyState>,
2550    signals: Arc<WebViewSessionSignals>,
2551}
2552
2553impl WebViewSession {
2554    pub fn webtag(&self) -> &WebTag {
2555        &self.webtag
2556    }
2557
2558    pub fn subscribe_events(&self) -> WebViewEventSubscription {
2559        WebViewEventSubscription {
2560            rx: self.event_rx.clone(),
2561        }
2562    }
2563
2564    pub fn current_event(&self) -> WebViewEvent {
2565        self.event_rx.borrow().clone()
2566    }
2567
2568    pub async fn wait_ready(&self) -> Result<Arc<WebView>, WebViewError> {
2569        let mut rx = self.ready_rx.clone();
2570        loop {
2571            if let Some(result) = self.signals.terminal_result() {
2572                return result;
2573            }
2574            if let Some(result) = rx.borrow().clone() {
2575                return result;
2576            }
2577            if rx.changed().await.is_err() {
2578                if let Some(result) = self.signals.terminal_result() {
2579                    return result;
2580                }
2581                return Err(WebViewError::WebView(
2582                    "webview ready channel unexpectedly closed".to_string(),
2583                ));
2584            }
2585        }
2586    }
2587}
2588
2589struct WebViewSessionSignals {
2590    event_tx: watch::Sender<WebViewEvent>,
2591    ready_tx: watch::Sender<WebViewReadyState>,
2592    state: Mutex<WebViewSessionState>,
2593}
2594
2595#[derive(Default)]
2596struct WebViewSessionState {
2597    terminal_result: Option<Result<Arc<WebView>, WebViewError>>,
2598    destroyed: bool,
2599}
2600
2601impl WebViewSessionSignals {
2602    fn new() -> Arc<Self> {
2603        let (event_tx, _event_rx) =
2604            watch::channel(WebViewEvent::Stage(WebViewCreateStage::Requested));
2605        let (ready_tx, _ready_rx) = watch::channel(None);
2606        Arc::new(Self {
2607            event_tx,
2608            ready_tx,
2609            state: Mutex::new(WebViewSessionState::default()),
2610        })
2611    }
2612
2613    fn subscribe(self: &Arc<Self>, webtag: WebTag) -> WebViewSession {
2614        WebViewSession {
2615            webtag,
2616            event_rx: self.event_tx.subscribe(),
2617            ready_rx: self.ready_tx.subscribe(),
2618            signals: Arc::clone(self),
2619        }
2620    }
2621
2622    fn terminal_result(&self) -> Option<Result<Arc<WebView>, WebViewError>> {
2623        let state = lock_or_recover(&self.state, "webview_session_state.terminal_result");
2624        state.terminal_result.clone()
2625    }
2626
2627    // Only consulted by the Apple create path's registry-race guard.
2628    #[cfg_attr(not(any(target_os = "macos", target_os = "ios")), allow(dead_code))]
2629    fn is_destroyed(&self) -> bool {
2630        let state = lock_or_recover(&self.state, "webview_session_state.is_destroyed");
2631        state.destroyed
2632    }
2633
2634    fn publish_result(
2635        &self,
2636        result: Result<Arc<WebView>, WebViewError>,
2637        stage_on_error: WebViewCreateStage,
2638    ) {
2639        let mut state = lock_or_recover(&self.state, "webview_session_state.publish_result");
2640        if state.destroyed || state.terminal_result.is_some() {
2641            return;
2642        }
2643        state.terminal_result = Some(result.clone());
2644        drop(state);
2645
2646        match result {
2647            Ok(webview) => {
2648                self.event_tx
2649                    .send_replace(WebViewEvent::Stage(WebViewCreateStage::NativeCreated));
2650                self.event_tx
2651                    .send_replace(WebViewEvent::Stage(WebViewCreateStage::ControllerAttached));
2652                self.ready_tx.send_replace(Some(Ok(webview)));
2653                self.event_tx
2654                    .send_replace(WebViewEvent::Stage(WebViewCreateStage::Ready));
2655            }
2656            Err(error) => {
2657                self.ready_tx.send_replace(Some(Err(error.clone())));
2658                self.event_tx.send_replace(WebViewEvent::Failed {
2659                    stage: stage_on_error,
2660                    error,
2661                });
2662            }
2663        }
2664    }
2665
2666    fn publish_destroyed(&self) {
2667        let mut state = lock_or_recover(&self.state, "webview_session_state.publish_destroyed");
2668        if state.destroyed {
2669            return;
2670        }
2671        state.destroyed = true;
2672        if state.terminal_result.is_none() {
2673            state.terminal_result = Some(Err(WebViewError::WebView(
2674                "webview destroyed before ready".to_string(),
2675            )));
2676        }
2677        let terminal_result = state.terminal_result.clone();
2678        drop(state);
2679
2680        self.event_tx
2681            .send_replace(WebViewEvent::Stage(WebViewCreateStage::Destroyed));
2682        if let Some(result) = terminal_result {
2683            self.ready_tx.send_replace(Some(result));
2684        }
2685    }
2686}
2687
2688pub(crate) struct WebViewCreateSender {
2689    webtag: WebTag,
2690    signals: Arc<WebViewSessionSignals>,
2691    native_view_id: NativeWebViewId,
2692}
2693
2694impl WebViewCreateSender {
2695    fn new(webtag: WebTag, signals: Arc<WebViewSessionSignals>) -> Self {
2696        Self {
2697            webtag,
2698            signals,
2699            native_view_id: next_native_webview_id(),
2700        }
2701    }
2702
2703    /// The concrete native-instance identity reserved before platform callback
2704    /// registration. Platform closures must capture this value and validate it
2705    /// against a lookup before delivering a message for a reusable WebTag.
2706    pub(crate) const fn native_view_id(&self) -> NativeWebViewId {
2707        self.native_view_id
2708    }
2709
2710    pub(crate) fn succeed(self, webview: Arc<WebView>) {
2711        self.signals
2712            .publish_result(Ok(webview), WebViewCreateStage::Requested);
2713    }
2714
2715    pub(crate) fn fail(self, stage: WebViewCreateStage, error: WebViewError) {
2716        if remove_session_signals_if_matches(&self.webtag, &self.signals) {
2717            crate::events::normalizer::destroy(&self.webtag);
2718        }
2719        self.signals.publish_result(Err(error), stage);
2720    }
2721
2722    /// Complete only this create generation after a newer same-tag session
2723    /// replaced it. The current generation's registry and callbacks belong to
2724    /// a different signals identity and must remain untouched.
2725    #[cfg_attr(not(target_os = "android"), allow(dead_code))]
2726    pub(crate) fn cancel_superseded(self) {
2727        if remove_session_signals_if_matches(&self.webtag, &self.signals) {
2728            crate::events::normalizer::destroy(&self.webtag);
2729        }
2730        self.signals.publish_destroyed();
2731    }
2732
2733    /// True if the session was destroyed (e.g. the tab was closed/discarded)
2734    /// while the native WebView was still being built. The platform create
2735    /// path checks this before registering, to avoid leaving a zombie in the
2736    /// global registry. Apple and Windows consult it around native registration.
2737    #[cfg_attr(
2738        not(any(target_os = "macos", target_os = "ios", target_os = "windows")),
2739        allow(dead_code)
2740    )]
2741    pub(crate) fn is_destroyed(&self) -> bool {
2742        self.signals.is_destroyed()
2743    }
2744}
2745
2746/// Global WebView instances storage
2747static WEBVIEW_INSTANCES: OnceLock<WebViewInstancesMap> = OnceLock::new();
2748
2749/// Pending callbacks: keyed by webtag string -> callbacks struct.
2750/// Stored here between builder-based session creation and `register_webview`.
2751struct PendingCallbacksEntry {
2752    #[cfg(target_os = "android")]
2753    signals: Arc<WebViewSessionSignals>,
2754    callbacks: PendingCallbacks,
2755}
2756
2757static PENDING_CALLBACKS: OnceLock<Mutex<HashMap<String, PendingCallbacksEntry>>> = OnceLock::new();
2758static WEBVIEW_SESSIONS: OnceLock<Mutex<HashMap<String, Arc<WebViewSessionSignals>>>> =
2759    OnceLock::new();
2760#[cfg(target_os = "windows")]
2761static WEBVIEW_CREATE_LOCKS: OnceLock<Mutex<HashMap<String, std::sync::Weak<Mutex<()>>>>> =
2762    OnceLock::new();
2763static DESIRED_PROXY_FOR_NEW_WEBVIEWS: OnceLock<RwLock<Option<ProxyConfig>>> = OnceLock::new();
2764static PROXY_APPLY_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2765
2766fn apply_http_proxy_platform(
2767    config: Option<&ProxyConfig>,
2768) -> Result<ProxyApplyReport, WebViewError> {
2769    #[cfg(target_os = "android")]
2770    {
2771        crate::android::apply_http_proxy(config)
2772    }
2773
2774    #[cfg(any(target_os = "ios", target_os = "macos"))]
2775    {
2776        crate::apple::apply_http_proxy(config)
2777    }
2778
2779    #[cfg(all(target_os = "linux", target_env = "ohos"))]
2780    {
2781        crate::harmony::apply_http_proxy(config)
2782    }
2783
2784    #[cfg(not(any(
2785        target_os = "android",
2786        target_os = "ios",
2787        target_os = "macos",
2788        all(target_os = "linux", target_env = "ohos")
2789    )))]
2790    {
2791        let _ = config;
2792        Ok(ProxyApplyReport::unsupported(
2793            "proxy is not supported on this platform",
2794        ))
2795    }
2796}
2797
2798/// Configure the proxy that should be used for newly created WebViews in this process.
2799///
2800/// This only updates the desired configuration kept in process memory. It does
2801/// not live-apply the proxy to currently active WebViews.
2802pub fn configure_proxy_for_new_webviews(config: Option<ProxyConfig>) -> Result<(), WebViewError> {
2803    let apply_lock = PROXY_APPLY_LOCK.get_or_init(|| Mutex::new(()));
2804    let _guard = lock_or_recover(apply_lock, "webview_proxy_apply_lock");
2805
2806    let normalized_config = match config {
2807        Some(cfg) => Some(cfg.validate()?),
2808        None => None,
2809    };
2810
2811    let state = DESIRED_PROXY_FOR_NEW_WEBVIEWS.get_or_init(|| RwLock::new(None));
2812    match state.write() {
2813        Ok(mut guard) => {
2814            *guard = normalized_config;
2815        }
2816        Err(poisoned) => {
2817            log::error!("RwLock poisoned at webview_desired_proxy.write, recovering");
2818            *poisoned.into_inner() = normalized_config;
2819        }
2820    }
2821    Ok(())
2822}
2823
2824/// Apply or clear process-level HTTP proxy for the current platform runtime now.
2825///
2826/// - `Some(config)`: set proxy
2827/// - `None`: clear proxy
2828pub fn apply_proxy_to_current_runtime(
2829    config: Option<ProxyConfig>,
2830) -> Result<ProxyApplyReport, WebViewError> {
2831    let apply_lock = PROXY_APPLY_LOCK.get_or_init(|| Mutex::new(()));
2832    let _guard = lock_or_recover(apply_lock, "webview_proxy_apply_lock");
2833
2834    let normalized_config = match config {
2835        Some(cfg) => Some(cfg.validate()?),
2836        None => None,
2837    };
2838
2839    let report = apply_http_proxy_platform(normalized_config.as_ref())?;
2840
2841    if matches!(
2842        report.status,
2843        ProxyApplyStatus::Applied | ProxyApplyStatus::Cleared
2844    ) {
2845        let state = DESIRED_PROXY_FOR_NEW_WEBVIEWS.get_or_init(|| RwLock::new(None));
2846        match state.write() {
2847            Ok(mut guard) => {
2848                *guard = normalized_config;
2849            }
2850            Err(poisoned) => {
2851                log::error!("RwLock poisoned at webview_desired_proxy.write, recovering");
2852                *poisoned.into_inner() = normalized_config;
2853            }
2854        }
2855    }
2856
2857    Ok(report)
2858}
2859
2860/// Get the configured proxy that will be used for newly created WebViews.
2861pub fn configured_proxy_for_new_webviews() -> Option<ProxyConfig> {
2862    let state = DESIRED_PROXY_FOR_NEW_WEBVIEWS.get()?;
2863    match state.read() {
2864        Ok(guard) => guard.clone(),
2865        Err(poisoned) => {
2866            log::error!("RwLock poisoned at webview_desired_proxy.read, recovering");
2867            poisoned.into_inner().clone()
2868        }
2869    }
2870}
2871
2872fn clear_pending_callbacks(webtag: &WebTag) {
2873    if let Some(pending) = PENDING_CALLBACKS.get()
2874        && let Ok(mut map) = pending.lock()
2875    {
2876        map.remove(webtag.key());
2877    }
2878}
2879
2880fn replace_session_signals(webtag: &WebTag, signals: Arc<WebViewSessionSignals>) {
2881    let sessions = WEBVIEW_SESSIONS.get_or_init(|| Mutex::new(HashMap::new()));
2882    let mut guard = lock_or_recover(sessions, "webview_sessions.replace");
2883    guard.insert(webtag.key().to_string(), signals);
2884}
2885
2886fn remove_session_signals(webtag: &WebTag) -> Option<Arc<WebViewSessionSignals>> {
2887    let sessions = WEBVIEW_SESSIONS.get()?;
2888    let mut guard = lock_or_recover(sessions, "webview_sessions.remove");
2889    guard.remove(webtag.key())
2890}
2891
2892fn remove_session_signals_if_matches(
2893    webtag: &WebTag,
2894    expected: &Arc<WebViewSessionSignals>,
2895) -> bool {
2896    let Some(sessions) = WEBVIEW_SESSIONS.get() else {
2897        return false;
2898    };
2899    let mut guard = lock_or_recover(sessions, "webview_sessions.remove_if_matches");
2900    if guard
2901        .get(webtag.key())
2902        .is_some_and(|current| Arc::ptr_eq(current, expected))
2903    {
2904        guard.remove(webtag.key());
2905        true
2906    } else {
2907        false
2908    }
2909}
2910
2911/// WebView identifier combining appid, path, and optional session id.
2912/// Example: `appid:path#123`.
2913#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2914pub struct WebTag(String);
2915
2916impl std::fmt::Display for WebTag {
2917    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2918        write!(f, "{}", self.0)
2919    }
2920}
2921
2922impl WebTag {
2923    pub fn new(appid: &str, path: &str, session_id: Option<u64>) -> Self {
2924        let mut tag = format!("{}:{}", appid, path);
2925        if let Some(session) = session_id {
2926            tag.push('#');
2927            tag.push_str(&session.to_string());
2928        }
2929        Self(tag)
2930    }
2931
2932    pub fn as_str(&self) -> &str {
2933        &self.0
2934    }
2935
2936    /// Storage key for this tag.
2937    /// This preserves the optional `#session` suffix so instances are isolated
2938    /// per runtime session.
2939    pub fn key(&self) -> &str {
2940        &self.0
2941    }
2942
2943    /// Extract appid from the webtag
2944    pub fn extract_appid(&self) -> String {
2945        self.0.split(':').next().unwrap_or("").to_string()
2946    }
2947
2948    /// Extract appid and path from WebTag
2949    /// This will always succeed since WebTag is constructed with a valid format
2950    pub fn extract_parts(&self) -> (String, String) {
2951        if let Some((appid, path_with_session)) = self.0.split_once(':') {
2952            let path = path_with_session
2953                .split('#')
2954                .next()
2955                .unwrap_or(path_with_session);
2956            (appid.to_string(), path.to_string())
2957        } else {
2958            log::error!("Invalid webtag format: {}", self.0);
2959            ("".to_string(), self.0.clone())
2960        }
2961    }
2962
2963    /// Extract session id (if present) from the webtag
2964    pub fn session_id(&self) -> Option<u64> {
2965        self.0
2966            .split('#')
2967            .next_back()
2968            .and_then(|raw| raw.parse::<u64>().ok())
2969    }
2970
2971    /// Grouping key combining appid and session id (`appid#session`), with the
2972    /// session defaulting to `0` when the tag carries no `#session` suffix.
2973    /// Tags without an `appid:` prefix are returned unchanged.
2974    #[cfg_attr(
2975        any(not(target_os = "windows"), target_os = "windows"),
2976        allow(dead_code)
2977    )]
2978    pub(crate) fn group_key(&self) -> String {
2979        let Some((appid, path_with_session)) = self.0.split_once(':') else {
2980            return self.0.clone();
2981        };
2982        let session = path_with_session
2983            .rsplit_once('#')
2984            .and_then(|(_, suffix)| suffix.parse::<u64>().ok())
2985            .map(|session| session.to_string())
2986            .unwrap_or_else(|| "0".to_string());
2987        format!("{appid}#{session}")
2988    }
2989
2990    fn key_path(&self) -> String {
2991        let Some((_, path_with_suffix)) = self.0.split_once(':') else {
2992            return self.0.clone();
2993        };
2994        if self.session_id().is_some()
2995            && let Some((path, _)) = path_with_suffix.rsplit_once('#')
2996        {
2997            return path.to_string();
2998        }
2999        path_with_suffix.to_string()
3000    }
3001}
3002
3003impl From<&str> for WebTag {
3004    fn from(webtag_str: &str) -> Self {
3005        Self(webtag_str.to_string())
3006    }
3007}
3008
3009fn request_create_webview(
3010    webtag: &WebTag,
3011    sender: WebViewCreateSender,
3012    options: WebViewCreateOptions,
3013) {
3014    let (appid, _) = webtag.extract_parts();
3015    let (effective_options, pending_callbacks) = match options.normalize() {
3016        Ok(value) => value,
3017        Err(error) => {
3018            sender.fail(WebViewCreateStage::Requested, error);
3019            return;
3020        }
3021    };
3022
3023    log::info!(
3024        "Creating WebView for key={} profile={:?} data_mode={:?} schemes={:?}",
3025        webtag.key(),
3026        effective_options.profile,
3027        effective_options.data_mode,
3028        effective_options.registered_schemes,
3029    );
3030
3031    // Get or initialize the global instances map
3032    let instances = WEBVIEW_INSTANCES.get_or_init(|| Arc::new(Mutex::new(HashMap::new())));
3033
3034    // Existing instance policy:
3035    // - Different options: fail fast (do not silently reuse incompatible instance).
3036    // - Same options + callback registrations: fail fast because callbacks are immutable after first create.
3037    // - Same options + no callbacks: return existing instance.
3038    if let Ok(webviews) = instances.lock()
3039        && let Some(existing_webview) = webviews.get(webtag.key())
3040    {
3041        if existing_webview.effective_options() != &effective_options {
3042            sender.fail(
3043                WebViewCreateStage::Requested,
3044                WebViewError::InvalidCreateOptions(format!(
3045                    "webview already exists with different options: key={} existing={:?} requested={:?}",
3046                    webtag.key(),
3047                    existing_webview.effective_options(),
3048                    effective_options
3049                )),
3050            );
3051            return;
3052        }
3053
3054        if pending_callbacks.has_any() {
3055            sender.fail(
3056                WebViewCreateStage::Requested,
3057                WebViewError::InvalidCreateOptions(format!(
3058                    "webview already exists and callback registrations are immutable: key={} options={:?}",
3059                    webtag.key(),
3060                    existing_webview.effective_options()
3061                )),
3062            );
3063            log::warn!(
3064                "Rejected recreate with callbacks for existing webview key={} options={:?}",
3065                webtag.key(),
3066                existing_webview.effective_options()
3067            );
3068            return;
3069        }
3070
3071        log::info!("WebView already exists, reusing: {}", webtag.key());
3072        sender.succeed(existing_webview.clone());
3073        return;
3074    }
3075
3076    // Drop stale pending callbacks from previously failed create attempts.
3077    clear_pending_callbacks(webtag);
3078
3079    // Stash pending callbacks for install during register_webview()
3080    if pending_callbacks.has_any() {
3081        let pending = PENDING_CALLBACKS.get_or_init(|| Mutex::new(HashMap::new()));
3082        if let Ok(mut map) = pending.lock() {
3083            map.insert(
3084                webtag.key().to_string(),
3085                PendingCallbacksEntry {
3086                    #[cfg(target_os = "android")]
3087                    signals: Arc::clone(&sender.signals),
3088                    callbacks: pending_callbacks,
3089                },
3090            );
3091        }
3092    }
3093
3094    // Delegate WebView creation to the platform-specific implementation
3095    WebViewInner::create(
3096        &appid,
3097        &webtag.key_path(),
3098        webtag.session_id(),
3099        effective_options,
3100        sender,
3101    );
3102}
3103
3104fn create_webview_session(webtag: WebTag, options: WebViewCreateOptions) -> WebViewSession {
3105    // Windows creation blocks until its WebView2 UI thread registers the
3106    // native instance. Serialize the whole same-tag transaction, including
3107    // session replacement and pending callbacks, so a discard/reactivate race
3108    // cannot cross-wire two generations of callbacks.
3109    #[cfg(target_os = "windows")]
3110    let create_lock = windows_webview_create_lock(webtag.key());
3111    #[cfg(target_os = "windows")]
3112    let _create_guard = lock_or_recover(&create_lock, "windows_webview_create_lock");
3113
3114    let signals = WebViewSessionSignals::new();
3115    let session = signals.subscribe(webtag.clone());
3116    let sender = WebViewCreateSender::new(webtag.clone(), signals.clone());
3117    replace_session_signals(&webtag, signals);
3118    crate::events::normalizer::begin(&webtag, sender.native_view_id());
3119    request_create_webview(&webtag, sender, options);
3120    session
3121}
3122
3123#[cfg(target_os = "windows")]
3124fn windows_webview_create_lock(webtag_key: &str) -> Arc<Mutex<()>> {
3125    let locks = WEBVIEW_CREATE_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
3126    let mut locks = lock_or_recover(locks, "windows_webview_create_locks");
3127    locks.retain(|_, lock| lock.strong_count() > 0);
3128    if let Some(lock) = locks.get(webtag_key).and_then(std::sync::Weak::upgrade) {
3129        return lock;
3130    }
3131    let lock = Arc::new(Mutex::new(()));
3132    locks.insert(webtag_key.to_string(), Arc::downgrade(&lock));
3133    lock
3134}
3135
3136#[cfg_attr(target_os = "android", allow(dead_code))]
3137pub(crate) fn register_webview(webview: Arc<WebView>) {
3138    let webtag = webview.webtag();
3139    crate::events::normalizer::bind_native_view(&webtag, webview.native_view_id());
3140
3141    // Install any pending callbacks
3142    if let Some(pending) = PENDING_CALLBACKS.get()
3143        && let Ok(mut map) = pending.lock()
3144        && let Some(entry) = map.remove(webtag.key())
3145    {
3146        let callbacks = entry.callbacks;
3147        log::info!(
3148            "Installing callbacks for {} (schemes={}, nav={}, new_window={}, download={}, file_chooser={}, delegate={})",
3149            webtag.key(),
3150            callbacks.scheme_handlers.len(),
3151            callbacks.navigation_handler.is_some(),
3152            callbacks.new_window_handler.is_some(),
3153            callbacks.download_handler.is_some(),
3154            callbacks.file_chooser_handler.is_some(),
3155            callbacks.delegate.is_some()
3156        );
3157        webview.install_callbacks(callbacks);
3158    }
3159
3160    if let Some(instances) = WEBVIEW_INSTANCES.get()
3161        && let Ok(mut webviews) = instances.lock()
3162    {
3163        webviews.insert(webtag.key().to_string(), webview.clone());
3164        log::info!("WebView created and stored: {}", webtag.key());
3165    }
3166}
3167
3168#[cfg(target_os = "android")]
3169pub(crate) fn register_android_webview_if_current(
3170    webview: Arc<WebView>,
3171    sender: &WebViewCreateSender,
3172) -> bool {
3173    let webtag = webview.webtag();
3174    let sessions = WEBVIEW_SESSIONS.get_or_init(|| Mutex::new(HashMap::new()));
3175    let session_guard = lock_or_recover(sessions, "webview_sessions.register_android");
3176    if !session_guard
3177        .get(webtag.key())
3178        .is_some_and(|current| Arc::ptr_eq(current, &sender.signals))
3179    {
3180        return false;
3181    }
3182
3183    if let Some(pending) = PENDING_CALLBACKS.get()
3184        && let Ok(mut map) = pending.lock()
3185        && map
3186            .get(webtag.key())
3187            .is_some_and(|entry| Arc::ptr_eq(&entry.signals, &sender.signals))
3188        && let Some(entry) = map.remove(webtag.key())
3189    {
3190        webview.install_callbacks(entry.callbacks);
3191    }
3192
3193    let instances = WEBVIEW_INSTANCES.get_or_init(|| Arc::new(Mutex::new(HashMap::new())));
3194    let mut webviews = lock_or_recover(instances, "webview_instances.register_android");
3195    crate::events::normalizer::bind_native_view(&webtag, webview.native_view_id());
3196    webviews.insert(webtag.key().to_string(), webview);
3197    true
3198}
3199
3200/// Find WebView by WebTag.
3201pub(crate) fn find_webview(webtag: &WebTag) -> Option<Arc<WebView>> {
3202    if let Some(instances) = WEBVIEW_INSTANCES.get() {
3203        if let Ok(webviews) = instances.lock() {
3204            webviews.get(webtag.key()).cloned()
3205        } else {
3206            None
3207        }
3208    } else {
3209        None
3210    }
3211}
3212
3213/// Resolve a logical WebTag only while it still names the native instance
3214/// which registered the callback. This prevents a late callback from a
3215/// destroyed WebView from being delivered to a replacement that reused its
3216/// tag.
3217// This is consumed by conditionally compiled platform callback adapters.
3218#[allow(dead_code)]
3219pub(crate) fn find_webview_by_native_view_id(
3220    webtag: &WebTag,
3221    native_view_id: NativeWebViewId,
3222) -> Option<Arc<WebView>> {
3223    find_webview(webtag).filter(|webview| webview.native_view_id() == native_view_id)
3224}
3225
3226#[cfg(target_os = "windows")]
3227pub(crate) fn first_browser_webview() -> Option<Arc<WebView>> {
3228    WEBVIEW_INSTANCES
3229        .get()
3230        .and_then(|instances| instances.lock().ok())
3231        .and_then(|webviews| {
3232            webviews
3233                .values()
3234                .find(|webview| {
3235                    webview.effective_options.profile == SecurityProfile::BrowserRelaxed
3236                })
3237                .cloned()
3238        })
3239}
3240
3241pub(crate) fn list_webviews() -> Vec<WebTag> {
3242    if let Some(instances) = WEBVIEW_INSTANCES.get()
3243        && let Ok(webviews) = instances.lock()
3244    {
3245        let mut tags: Vec<WebTag> = webviews.values().map(|webview| webview.webtag()).collect();
3246        tags.sort_by(|a, b| a.as_str().cmp(b.as_str()));
3247        return tags;
3248    }
3249    Vec::new()
3250}
3251
3252/// Resolve a delegate only while a callback's concrete native WebView still
3253/// owns the logical tag. A retired normalizer must never deliver its queued
3254/// output to a replacement delegate.
3255pub(crate) fn find_webview_delegate_by_native_view_id(
3256    webtag: &WebTag,
3257    native_view_id: NativeWebViewId,
3258) -> Option<Arc<dyn WebViewDelegate>> {
3259    find_webview_by_native_view_id(webtag, native_view_id)
3260        .and_then(|webview| webview.get_delegate())
3261}
3262
3263fn remove_arc_if_matches<T>(
3264    entries: &mut HashMap<String, Arc<T>>,
3265    key: &str,
3266    expected: &Arc<T>,
3267) -> Option<Arc<T>> {
3268    entries
3269        .get(key)
3270        .is_some_and(|current| Arc::ptr_eq(current, expected))
3271        .then(|| entries.remove(key))
3272        .flatten()
3273}
3274
3275/// Remove one ready WebView only while it is still the instance registered for
3276/// its tag. Tag-scoped session, callback, and navigation state may already
3277/// belong to a newer create cycle and is deliberately left untouched.
3278pub(crate) fn destroy_webview_if_matches(webtag: &WebTag, expected: &Arc<WebView>) -> bool {
3279    // Close before touching the registry: a queued message must not cross the
3280    // remove-to-close window and become in-flight. Closing an already detached
3281    // expected instance is harmless and still rejects its late native callbacks.
3282    expected.close_message_ingress();
3283    let removed = if let Some(instances) = WEBVIEW_INSTANCES.get()
3284        && let Ok(mut webviews) = instances.lock()
3285    {
3286        remove_arc_if_matches(&mut webviews, webtag.key(), expected)
3287    } else {
3288        None
3289    };
3290    if let Some(webview) = removed {
3291        #[cfg(target_os = "windows")]
3292        {
3293            let _ = webview.inner.set_content_visible(false);
3294            webview.inner.request_shutdown();
3295        }
3296        webview.remove_delegate();
3297        true
3298    } else {
3299        false
3300    }
3301}
3302
3303/// Destroy whichever WebView is currently registered for `webtag`.
3304///
3305/// This is intentionally tag-scoped host lifecycle behavior. Callers that
3306/// retain a concrete instance must use [`destroy_webview_if_matches`] instead.
3307pub(crate) fn destroy_current_webview(webtag: &WebTag) {
3308    // Close ingress before lifecycle notifications and native teardown. A
3309    // delivery already admitted under its own mutex may finish; queued and
3310    // future callbacks are rejected immediately.
3311    if let Some(webview) = find_webview(webtag) {
3312        webview.close_message_ingress();
3313    }
3314    // Drain active navigations as Cancelled(WebViewDestroyed) while the
3315    // delegate can still observe them, then drop the normalizer.
3316    crate::events::normalizer::destroy(webtag);
3317    // Mark the session destroyed FIRST. If a native create is still in flight
3318    // (built on the main thread but not yet registered), it observes this via
3319    // `WebViewCreateSender::is_destroyed()` after registering and tears the
3320    // instance back down — so a destroy that races ahead of registration can't
3321    // leave a zombie in the global registry.
3322    if let Some(signals) = remove_session_signals(webtag) {
3323        signals.publish_destroyed();
3324    }
3325    let removed = if let Some(instances) = WEBVIEW_INSTANCES.get()
3326        && let Ok(mut webviews) = instances.lock()
3327    {
3328        webviews.remove(webtag.key())
3329    } else {
3330        None
3331    };
3332    if let Some(webview) = removed {
3333        webview.close_message_ingress();
3334        // Windows composition teardown is asynchronous. Hide the controller
3335        // synchronously while it is still callable so a closed browser tab or
3336        // surface cannot leave its last composed frame over the replacement.
3337        #[cfg(target_os = "windows")]
3338        {
3339            let _ = webview.inner.set_content_visible(false);
3340            // Other owners can keep the retired Arc alive after it leaves the
3341            // registry. Stop its native thread now so a same-tag replacement
3342            // can safely begin instead of waiting for the final Arc to drop.
3343            webview.inner.request_shutdown();
3344        }
3345        webview.remove_delegate();
3346    }
3347    clear_pending_callbacks(webtag);
3348}
3349
3350#[cfg(test)]
3351mod tests {
3352    use super::{
3353        MAX_PENDING_WEB_MESSAGE_BYTES, MAX_PENDING_WEB_MESSAGES, MAX_WEB_MESSAGE_BYTES,
3354        PlatformConsoleBackend, PlatformConsoleDelivery, SecurityProfile, WEBVIEW_SESSIONS,
3355        WebMessageEnqueue, WebMessageIngress, WebMessageRejectReason, WebTag, WebViewCreateOptions,
3356        WebViewCreateSender, WebViewSessionSignals, block_on_scheme_future, document_port_context,
3357        next_native_webview_id, platform_console_delivery, remove_arc_if_matches,
3358        remove_session_signals_if_matches, replace_session_signals, should_sample_rejection,
3359        snapshot_web_message_context, web_message_bytes_within_limit,
3360        web_message_utf16_units_within_limit,
3361    };
3362    use crate::{
3363        ContextualSchemeRequest, DocumentBinding, IncomingWebMessage, NativeWebViewId,
3364        SchemeOutcome, SchemeRequestFrame, WebMessageContext, WebMessageFrame, WebMessageSource,
3365        WebMessageTransport,
3366    };
3367    use std::collections::HashMap;
3368    use std::sync::{Arc, Mutex, mpsc};
3369    use std::thread;
3370
3371    fn message(body: &str, native_view: crate::NativeWebViewId) -> IncomingWebMessage {
3372        IncomingWebMessage::new(
3373            body.to_string(),
3374            WebMessageContext::new(
3375                native_view,
3376                DocumentBinding::Unbound,
3377                WebMessageFrame::Unproven,
3378                WebMessageTransport::Other,
3379                WebMessageSource::unavailable(),
3380            ),
3381        )
3382    }
3383
3384    fn assert_browser_console_is_bound(backend: PlatformConsoleBackend) {
3385        assert_eq!(
3386            platform_console_delivery(SecurityProfile::BrowserRelaxed, backend),
3387            PlatformConsoleDelivery::RequiredV3Envelope
3388        );
3389        assert_eq!(
3390            platform_console_delivery(SecurityProfile::StrictDefault, backend),
3391            PlatformConsoleDelivery::DirectDelegate
3392        );
3393    }
3394
3395    #[test]
3396    fn apple_browser_console_requires_v3_envelope() {
3397        assert_browser_console_is_bound(PlatformConsoleBackend::Apple);
3398    }
3399
3400    #[test]
3401    fn android_browser_console_requires_v3_envelope() {
3402        assert_browser_console_is_bound(PlatformConsoleBackend::Android);
3403    }
3404
3405    #[test]
3406    fn harmony_browser_console_requires_v3_envelope() {
3407        assert_browser_console_is_bound(PlatformConsoleBackend::Harmony);
3408    }
3409
3410    #[test]
3411    fn windows_browser_console_requires_v3_envelope() {
3412        assert_browser_console_is_bound(PlatformConsoleBackend::Windows);
3413    }
3414
3415    #[test]
3416    fn native_webview_ids_are_not_reused_across_logical_tag_reuse() {
3417        let retired = next_native_webview_id();
3418        let replacement = next_native_webview_id();
3419        assert_ne!(retired, replacement);
3420
3421        let late_message = message("late", retired);
3422        assert_ne!(late_message.context().native_view(), replacement);
3423        assert_eq!(late_message.context().document(), DocumentBinding::Unbound);
3424    }
3425
3426    #[test]
3427    fn document_port_top_level_proof_requires_current_generation() {
3428        let native_view = next_native_webview_id();
3429        let current = crate::DocumentGeneration::new(42);
3430        let stale = crate::DocumentGeneration::new(41);
3431
3432        let context = document_port_context(
3433            native_view,
3434            DocumentBinding::Bound(current),
3435            current,
3436            WebMessageTransport::AndroidMessagePort,
3437            WebMessageSource::unavailable(),
3438        )
3439        .expect("the exact document port proves the top-level document");
3440        assert_eq!(context.frame(), WebMessageFrame::TopLevel);
3441        assert_eq!(context.document(), DocumentBinding::Bound(current));
3442
3443        assert!(
3444            document_port_context(
3445                native_view,
3446                DocumentBinding::Bound(current),
3447                stale,
3448                WebMessageTransport::AndroidMessagePort,
3449                WebMessageSource::unavailable(),
3450            )
3451            .is_none()
3452        );
3453        assert!(
3454            document_port_context(
3455                native_view,
3456                DocumentBinding::Bound(current),
3457                current,
3458                WebMessageTransport::AndroidJavascriptInterface,
3459                WebMessageSource::unavailable(),
3460            )
3461            .is_none()
3462        );
3463
3464        // Harmony hands out the same kind of one-shot, per-document port.
3465        assert_eq!(
3466            document_port_context(
3467                native_view,
3468                DocumentBinding::Bound(current),
3469                current,
3470                WebMessageTransport::HarmonyMessagePort,
3471                WebMessageSource::unavailable(),
3472            )
3473            .expect("a current Harmony port proves the top-level document")
3474            .frame(),
3475            WebMessageFrame::TopLevel
3476        );
3477        assert!(
3478            document_port_context(
3479                native_view,
3480                DocumentBinding::Bound(current),
3481                stale,
3482                WebMessageTransport::HarmonyMessagePort,
3483                WebMessageSource::unavailable(),
3484            )
3485            .is_none()
3486        );
3487    }
3488
3489    #[test]
3490    fn legacy_scheme_handler_runs_from_contextual_ingress() {
3491        let options = WebViewCreateOptions::strict().on_scheme("lx", |request| async move {
3492            assert_eq!(request.uri(), "lx://app/index.html");
3493            SchemeOutcome::PassThrough
3494        });
3495        let (_, callbacks) = options.normalize().unwrap();
3496        let handler = callbacks.scheme_handlers.get("lx").unwrap();
3497        let outcome = block_on_scheme_future(handler(ContextualSchemeRequest::new(
3498            http::Request::builder()
3499                .uri("lx://app/index.html")
3500                .body(Vec::new())
3501                .unwrap(),
3502            NativeWebViewId::new(101),
3503            SchemeRequestFrame::TopLevelDocument,
3504        )));
3505        assert!(matches!(outcome, SchemeOutcome::PassThrough));
3506    }
3507
3508    #[test]
3509    fn ingress_snapshots_document_binding_at_enqueue_time() {
3510        let webtag = WebTag::new("test-app", "binding-snapshot", Some(1));
3511        let native_view = next_native_webview_id();
3512        crate::events::normalizer::begin(&webtag, native_view);
3513        crate::events::normalizer::submit(
3514            &webtag,
3515            native_view,
3516            crate::events::normalizer::NativeSignal::NavigationStarted {
3517                key: Some(71),
3518                url: "https://first/".into(),
3519            },
3520        );
3521        crate::events::normalizer::submit(
3522            &webtag,
3523            native_view,
3524            crate::events::normalizer::NativeSignal::DocumentCommitted { key: Some(71) },
3525        );
3526
3527        let ingress = WebMessageIngress::default();
3528        let bound = IncomingWebMessage::new(
3529            "bound".to_string(),
3530            snapshot_web_message_context(
3531                native_view,
3532                WebMessageFrame::Unproven,
3533                WebMessageTransport::Other,
3534                WebMessageSource::unavailable(),
3535            ),
3536        );
3537        assert_eq!(
3538            bound.context().document(),
3539            DocumentBinding::Bound(crate::DocumentGeneration::new(1))
3540        );
3541        assert_eq!(ingress.enqueue(bound), WebMessageEnqueue::Schedule);
3542
3543        crate::events::normalizer::submit(
3544            &webtag,
3545            native_view,
3546            crate::events::normalizer::NativeSignal::NavigationStarted {
3547                key: Some(72),
3548                url: "https://second/".into(),
3549            },
3550        );
3551        let revoked = IncomingWebMessage::new(
3552            "revoked".to_string(),
3553            snapshot_web_message_context(
3554                native_view,
3555                WebMessageFrame::Unproven,
3556                WebMessageTransport::Other,
3557                WebMessageSource::unavailable(),
3558            ),
3559        );
3560        assert_eq!(revoked.context().document(), DocumentBinding::Unbound);
3561        assert_eq!(ingress.enqueue(revoked), WebMessageEnqueue::Queued);
3562
3563        let mut bindings = Vec::new();
3564        while let Some(message) = ingress.begin_delivery() {
3565            bindings.push(message.context().document());
3566            ingress.finish_delivery();
3567        }
3568        assert_eq!(
3569            bindings,
3570            vec![
3571                DocumentBinding::Bound(crate::DocumentGeneration::new(1)),
3572                DocumentBinding::Unbound,
3573            ]
3574        );
3575        crate::events::normalizer::destroy(&webtag);
3576    }
3577
3578    #[test]
3579    fn message_ingress_preserves_fifo_across_reentrant_enqueue() {
3580        let ingress = WebMessageIngress::default();
3581        let native_view = next_native_webview_id();
3582        let delivered = Mutex::new(Vec::new());
3583
3584        assert_eq!(
3585            ingress.enqueue(message("first", native_view)),
3586            WebMessageEnqueue::Schedule
3587        );
3588        while let Some(incoming) = ingress.begin_delivery() {
3589            let body = incoming.body().to_string();
3590            delivered.lock().unwrap().push(body.clone());
3591            if body == "first" {
3592                assert_eq!(
3593                    ingress.enqueue(message("second", native_view)),
3594                    WebMessageEnqueue::Queued
3595                );
3596                assert_eq!(
3597                    ingress.enqueue(message("third", native_view)),
3598                    WebMessageEnqueue::Queued
3599                );
3600            }
3601            ingress.finish_delivery();
3602        }
3603
3604        assert_eq!(*delivered.lock().unwrap(), vec!["first", "second", "third"]);
3605    }
3606
3607    #[test]
3608    fn message_ingress_bounds_untrusted_backlog_without_reordering_accepted_messages() {
3609        let ingress = WebMessageIngress::default();
3610        let native_view = next_native_webview_id();
3611        assert_eq!(
3612            ingress.enqueue(message("0", native_view)),
3613            WebMessageEnqueue::Schedule
3614        );
3615        for index in 1..MAX_PENDING_WEB_MESSAGES {
3616            assert_eq!(
3617                ingress.enqueue(message(&index.to_string(), native_view)),
3618                WebMessageEnqueue::Queued
3619            );
3620        }
3621        assert_eq!(
3622            ingress.enqueue(message("overflow", native_view)),
3623            WebMessageEnqueue::Rejected {
3624                reason: WebMessageRejectReason::QueueCountLimit,
3625                count: 1,
3626            }
3627        );
3628
3629        let mut accepted = Vec::new();
3630        while let Some(incoming) = ingress.begin_delivery() {
3631            accepted.push(incoming.body().to_owned());
3632            ingress.finish_delivery();
3633        }
3634        assert_eq!(accepted.len(), MAX_PENDING_WEB_MESSAGES);
3635        assert_eq!(accepted.first().map(String::as_str), Some("0"));
3636        let last = (MAX_PENDING_WEB_MESSAGES - 1).to_string();
3637        assert_eq!(accepted.last().map(String::as_str), Some(last.as_str()));
3638    }
3639
3640    #[test]
3641    fn message_ingress_rejects_an_oversized_message_before_queueing() {
3642        let ingress = WebMessageIngress::default();
3643        let native_view = next_native_webview_id();
3644        let oversized = "x".repeat(MAX_WEB_MESSAGE_BYTES + 1);
3645
3646        assert_eq!(
3647            ingress.enqueue(message(&oversized, native_view)),
3648            WebMessageEnqueue::Rejected {
3649                reason: WebMessageRejectReason::MessageTooLarge,
3650                count: 1,
3651            }
3652        );
3653        assert_eq!(ingress.queued_bytes(), 0);
3654        assert!(ingress.begin_delivery().is_none());
3655    }
3656
3657    #[test]
3658    fn raw_web_message_byte_cap_includes_the_exact_boundary() {
3659        assert!(web_message_bytes_within_limit(MAX_WEB_MESSAGE_BYTES - 1));
3660        assert!(web_message_bytes_within_limit(MAX_WEB_MESSAGE_BYTES));
3661        assert!(!web_message_bytes_within_limit(MAX_WEB_MESSAGE_BYTES + 1));
3662        assert!(web_message_utf16_units_within_limit(MAX_WEB_MESSAGE_BYTES));
3663        assert!(!web_message_utf16_units_within_limit(
3664            MAX_WEB_MESSAGE_BYTES + 1
3665        ));
3666        assert!(!web_message_bytes_within_limit(usize::MAX));
3667    }
3668
3669    #[test]
3670    fn message_ingress_byte_budget_is_released_on_delivery_and_close() {
3671        let ingress = WebMessageIngress::default();
3672        let native_view = next_native_webview_id();
3673        let chunk = "x".repeat(MAX_WEB_MESSAGE_BYTES);
3674        let accepted = MAX_PENDING_WEB_MESSAGE_BYTES / MAX_WEB_MESSAGE_BYTES;
3675
3676        for index in 0..accepted {
3677            assert_eq!(
3678                ingress.enqueue(message(&chunk, native_view)),
3679                if index == 0 {
3680                    WebMessageEnqueue::Schedule
3681                } else {
3682                    WebMessageEnqueue::Queued
3683                }
3684            );
3685        }
3686        assert_eq!(ingress.queued_bytes(), MAX_PENDING_WEB_MESSAGE_BYTES);
3687        assert_eq!(
3688            ingress.enqueue(message("overflow", native_view)),
3689            WebMessageEnqueue::Rejected {
3690                reason: WebMessageRejectReason::QueueByteLimit,
3691                count: 1,
3692            }
3693        );
3694
3695        let first = ingress.begin_delivery().expect("first queued message");
3696        assert_eq!(first.body().len(), MAX_WEB_MESSAGE_BYTES);
3697        assert_eq!(
3698            ingress.queued_bytes(),
3699            MAX_PENDING_WEB_MESSAGE_BYTES - MAX_WEB_MESSAGE_BYTES
3700        );
3701        ingress.finish_delivery();
3702        assert_eq!(
3703            ingress.enqueue(message(&chunk, native_view)),
3704            WebMessageEnqueue::Queued
3705        );
3706        ingress.close();
3707        assert_eq!(ingress.queued_bytes(), 0);
3708        assert!(ingress.begin_delivery().is_none());
3709    }
3710
3711    #[test]
3712    fn ingress_rejection_counters_are_reason_coded_and_logs_are_sampled() {
3713        let ingress = WebMessageIngress::default();
3714        let native_view = next_native_webview_id();
3715        let oversized = "x".repeat(MAX_WEB_MESSAGE_BYTES + 1);
3716        assert_eq!(
3717            ingress.reject(WebMessageRejectReason::MessageTooLarge),
3718            WebMessageEnqueue::Rejected {
3719                reason: WebMessageRejectReason::MessageTooLarge,
3720                count: 1,
3721            }
3722        );
3723        for expected_count in 2..=4 {
3724            assert_eq!(
3725                ingress.enqueue(message(&oversized, native_view)),
3726                WebMessageEnqueue::Rejected {
3727                    reason: WebMessageRejectReason::MessageTooLarge,
3728                    count: expected_count,
3729                }
3730            );
3731        }
3732        assert_eq!(
3733            ingress.rejection_count(WebMessageRejectReason::MessageTooLarge),
3734            4
3735        );
3736        assert!(should_sample_rejection(1));
3737        assert!(should_sample_rejection(2));
3738        assert!(!should_sample_rejection(3));
3739        assert!(should_sample_rejection(4));
3740    }
3741
3742    #[test]
3743    fn message_ingress_preserves_fifo_across_producer_threads() {
3744        let ingress = Arc::new(WebMessageIngress::default());
3745        let native_view = next_native_webview_id();
3746        let (a_to_b_tx, a_to_b_rx) = mpsc::channel();
3747        let (b_to_a_tx, b_to_a_rx) = mpsc::channel();
3748
3749        let a_ingress = Arc::clone(&ingress);
3750        let producer_a = thread::spawn(move || {
3751            assert_eq!(
3752                a_ingress.enqueue(message("0", native_view)),
3753                WebMessageEnqueue::Schedule
3754            );
3755            a_to_b_tx.send(()).unwrap();
3756            for value in (2..64).step_by(2) {
3757                b_to_a_rx.recv().unwrap();
3758                assert_eq!(
3759                    a_ingress.enqueue(message(&value.to_string(), native_view)),
3760                    WebMessageEnqueue::Queued
3761                );
3762                a_to_b_tx.send(()).unwrap();
3763            }
3764        });
3765
3766        let b_ingress = Arc::clone(&ingress);
3767        let producer_b = thread::spawn(move || {
3768            for value in (1..64).step_by(2) {
3769                a_to_b_rx.recv().unwrap();
3770                assert_eq!(
3771                    b_ingress.enqueue(message(&value.to_string(), native_view)),
3772                    WebMessageEnqueue::Queued
3773                );
3774                if value != 63 {
3775                    b_to_a_tx.send(()).unwrap();
3776                }
3777            }
3778        });
3779
3780        producer_a.join().unwrap();
3781        producer_b.join().unwrap();
3782
3783        let mut accepted = Vec::new();
3784        while let Some(incoming) = ingress.begin_delivery() {
3785            accepted.push(incoming.body().to_owned());
3786            ingress.finish_delivery();
3787        }
3788        assert_eq!(
3789            accepted,
3790            (0..64).map(|value| value.to_string()).collect::<Vec<_>>()
3791        );
3792    }
3793
3794    #[test]
3795    fn destroying_ingress_discards_queued_messages_but_allows_admitted_delivery() {
3796        let ingress = Arc::new(WebMessageIngress::default());
3797        let native_view = next_native_webview_id();
3798        assert_eq!(
3799            ingress.enqueue(message("in-flight", native_view)),
3800            WebMessageEnqueue::Schedule
3801        );
3802        assert_eq!(
3803            ingress.enqueue(message("queued", native_view)),
3804            WebMessageEnqueue::Queued
3805        );
3806
3807        let delivered = Mutex::new(Vec::new());
3808        let ingress_for_delivery = Arc::clone(&ingress);
3809        ingress.drain(|incoming| {
3810            delivered.lock().unwrap().push(incoming.body().to_owned());
3811            ingress_for_delivery.close();
3812            assert_eq!(
3813                ingress_for_delivery.enqueue(message("after-close", native_view)),
3814                WebMessageEnqueue::Rejected {
3815                    reason: WebMessageRejectReason::Closed,
3816                    count: 1,
3817                }
3818            );
3819        });
3820
3821        assert_eq!(*delivered.lock().unwrap(), vec!["in-flight"]);
3822        assert!(ingress.begin_delivery().is_none());
3823    }
3824
3825    #[test]
3826    fn closing_before_registry_removal_prevents_queued_message_admission() {
3827        let ingress = WebMessageIngress::default();
3828        let native_view = next_native_webview_id();
3829        assert_eq!(
3830            ingress.enqueue(message("queued", native_view)),
3831            WebMessageEnqueue::Schedule
3832        );
3833
3834        // This mirrors `destroy_webview_if_matches`: close is the destroy
3835        // linearization point and happens before the registry mutation.
3836        ingress.close();
3837
3838        assert!(ingress.begin_delivery().is_none());
3839        assert_eq!(
3840            ingress.enqueue(message("late", native_view)),
3841            WebMessageEnqueue::Rejected {
3842                reason: WebMessageRejectReason::Closed,
3843                count: 1,
3844            }
3845        );
3846    }
3847
3848    #[test]
3849    fn delegate_panic_does_not_stall_following_messages() {
3850        let ingress = WebMessageIngress::default();
3851        let native_view = next_native_webview_id();
3852        assert_eq!(
3853            ingress.enqueue(message("panic", native_view)),
3854            WebMessageEnqueue::Schedule
3855        );
3856        assert_eq!(
3857            ingress.enqueue(message("after", native_view)),
3858            WebMessageEnqueue::Queued
3859        );
3860
3861        let delivered = Mutex::new(Vec::new());
3862        ingress.drain(|incoming| {
3863            if incoming.body() == "panic" {
3864                panic!("test delegate panic");
3865            }
3866            delivered.lock().unwrap().push(incoming.body().to_owned());
3867        });
3868
3869        assert_eq!(*delivered.lock().unwrap(), vec!["after"]);
3870        assert_eq!(
3871            ingress.enqueue(message("recovered", native_view)),
3872            WebMessageEnqueue::Schedule
3873        );
3874    }
3875
3876    #[test]
3877    fn conditional_instance_removal_uses_arc_identity() {
3878        let current = Arc::new(7_u8);
3879        let same_value_different_instance = Arc::new(7_u8);
3880        let mut entries = HashMap::from([("tab".to_string(), current.clone())]);
3881
3882        assert!(
3883            remove_arc_if_matches(&mut entries, "tab", &same_value_different_instance).is_none()
3884        );
3885        assert!(Arc::ptr_eq(entries.get("tab").unwrap(), &current));
3886
3887        let removed = remove_arc_if_matches(&mut entries, "tab", &current).unwrap();
3888        assert!(Arc::ptr_eq(&removed, &current));
3889        assert!(!entries.contains_key("tab"));
3890    }
3891
3892    #[test]
3893    fn superseded_sender_completes_without_removing_current_generation() {
3894        let webtag = WebTag::from("test:pages/superseded#9173");
3895        let superseded = WebViewSessionSignals::new();
3896        let current = WebViewSessionSignals::new();
3897        replace_session_signals(&webtag, current.clone());
3898
3899        WebViewCreateSender::new(webtag.clone(), superseded.clone()).cancel_superseded();
3900
3901        assert!(
3902            superseded
3903                .terminal_result()
3904                .is_some_and(|result| result.is_err())
3905        );
3906        assert!(current.terminal_result().is_none());
3907        let sessions = WEBVIEW_SESSIONS.get().unwrap().lock().unwrap();
3908        assert!(
3909            sessions
3910                .get(webtag.key())
3911                .is_some_and(|signals| Arc::ptr_eq(signals, &current))
3912        );
3913        drop(sessions);
3914        assert!(remove_session_signals_if_matches(&webtag, &current));
3915    }
3916}