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 hit = document.elementFromPoint(rect.left + rect.width/2, rect.top + rect.height/2); \
1872              if (!hit || !(hit === el || el.contains(hit))) return {{ ok:false, error:'element is obscured', interactable:false }}; \
1873              const tag = (el.tagName || '').toLowerCase(); \
1874              if (tag.indexOf('lx-') === 0) {{ \
1875                el.setAttribute('focus', 'true'); \
1876                if (typeof el.syncNativeProps === 'function') {{ try {{ el.syncNativeProps(); }} catch(_e) {{}} }} \
1877                return {{ ok:true, count:els.length, native:true }}; \
1878              }} \
1879              if (typeof el.focus === 'function') {{ try {{ el.focus({{preventScroll:true}}); }} catch(_e) {{ try {{ el.focus(); }} catch(__){{}} }} }} \
1880              const opts = {{ bubbles:true, cancelable:true, view:window, clientX: rect.left + rect.width/2, clientY: rect.top + rect.height/2 }}; \
1881              try {{ if (window.PointerEvent) el.dispatchEvent(new PointerEvent('pointerdown', Object.assign({{pointerId:1, isPrimary:true, pointerType:'mouse'}}, opts))); }} catch(_e) {{}} \
1882              try {{ el.dispatchEvent(new MouseEvent('mousedown', opts)); }} catch(_e) {{}} \
1883              try {{ if (window.PointerEvent) el.dispatchEvent(new PointerEvent('pointerup', Object.assign({{pointerId:1, isPrimary:true, pointerType:'mouse'}}, opts))); }} catch(_e) {{}} \
1884              try {{ el.dispatchEvent(new MouseEvent('mouseup', opts)); }} catch(_e) {{}} \
1885              try {{ el.dispatchEvent(new MouseEvent('click', opts)); }} catch(_e) {{}} \
1886              return {{ ok:true, count:els.length }}; \
1887            }})({selector_json}, {idx})"
1888        );
1889        self.run_js_action(&script).await
1890    }
1891
1892    /// Type text into an editable element by synthesizing DOM events. Goes
1893    /// through the native value setter so framework-tracked inputs (React) fire
1894    /// their `onChange`. `lx-` custom elements set their value + sync native.
1895    #[cfg(any(
1896        target_os = "ios",
1897        target_os = "android",
1898        all(feature = "webview-input", target_os = "macos"),
1899        all(target_os = "linux", target_env = "ohos")
1900    ))]
1901    pub(crate) async fn type_via_js(
1902        &self,
1903        selector: &str,
1904        index: Option<usize>,
1905        text: &str,
1906        replace: bool,
1907    ) -> Result<(), WebViewInputError> {
1908        let selector_json = serde_json::to_string(selector)
1909            .map_err(|err| WebViewInputError::Platform(format!("Invalid selector: {err}")))?;
1910        let text_json = serde_json::to_string(text)
1911            .map_err(|err| WebViewInputError::Platform(format!("Invalid text: {err}")))?;
1912        let idx = index.unwrap_or(0);
1913        let script = format!(
1914            "((sel, i, text, replace) => {{ \
1915              const els = document.querySelectorAll(sel); \
1916              if (!els.length || i < 0 || i >= els.length) return {{ ok:false, error:'no match', count:els.length }}; \
1917              const el = els[i]; \
1918              try {{ el.scrollIntoView({{block:'center', inline:'center'}}); }} catch(_e) {{}} \
1919              if (typeof el.focus === 'function') {{ try {{ el.focus({{preventScroll:true}}); }} catch(_e) {{ try {{ el.focus(); }} catch(__){{}} }} }} \
1920              const tag = (el.tagName || '').toLowerCase(); \
1921              if (tag === 'input' || tag === 'textarea') {{ \
1922                const proto = tag === 'textarea' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype; \
1923                const desc = Object.getOwnPropertyDescriptor(proto, 'value'); \
1924                const next = (replace ? '' : (el.value || '')) + text; \
1925                if (desc && desc.set) {{ desc.set.call(el, next); }} else {{ el.value = next; }} \
1926                el.dispatchEvent(new InputEvent('input', {{ bubbles:true, cancelable:true, data:text, inputType:'insertText' }})); \
1927                el.dispatchEvent(new Event('change', {{ bubbles:true }})); \
1928                return {{ ok:true, count:els.length }}; \
1929              }} \
1930              if (el.isContentEditable) {{ \
1931                el.textContent = (replace ? '' : (el.textContent || '')) + text; \
1932                el.dispatchEvent(new InputEvent('input', {{ bubbles:true, data:text, inputType:'insertText' }})); \
1933                return {{ ok:true, count:els.length }}; \
1934              }} \
1935              if (tag.indexOf('lx-') === 0) {{ \
1936                try {{ el.value = (replace ? '' : (el.value || '')) + text; }} catch(_e) {{}} \
1937                if (typeof el.syncNativeProps === 'function') {{ try {{ el.syncNativeProps(); }} catch(_e) {{}} }} \
1938                el.dispatchEvent(new Event('input', {{ bubbles:true }})); \
1939                return {{ ok:true, count:els.length, native:true }}; \
1940              }} \
1941              return {{ ok:false, error:'not editable', interactable:false, count:els.length }}; \
1942            }})({selector_json}, {idx}, {text_json}, {replace})"
1943        );
1944        self.run_js_action(&script).await
1945    }
1946
1947    /// Press a key by synthesizing keydown/keyup on the selected or focused element.
1948    #[cfg(any(
1949        target_os = "ios",
1950        target_os = "android",
1951        all(feature = "webview-input", target_os = "macos"),
1952        all(target_os = "linux", target_env = "ohos")
1953    ))]
1954    pub(crate) async fn press_via_js(
1955        &self,
1956        key: &str,
1957        selector: Option<&str>,
1958        index: Option<usize>,
1959    ) -> Result<(), WebViewInputError> {
1960        let key_json = serde_json::to_string(key)
1961            .map_err(|err| WebViewInputError::Platform(format!("Invalid key: {err}")))?;
1962        let selector_json = serde_json::to_string(&selector)
1963            .map_err(|err| WebViewInputError::Platform(format!("Invalid selector: {err}")))?;
1964        let idx = index.unwrap_or(0);
1965        let script = format!(
1966            "((key, sel, i) => {{ \
1967              const els = sel === null ? null : document.querySelectorAll(sel); \
1968              if (els && (!els.length || i < 0 || i >= els.length)) return {{ ok:false, error:'no match', count:els.length }}; \
1969              const el = els ? els[i] : (document.activeElement || document.body); \
1970              if (els) {{ \
1971                try {{ el.scrollIntoView({{block:'center', inline:'center'}}); }} catch(_e) {{}} \
1972                if (typeof el.focus === 'function') {{ try {{ el.focus({{preventScroll:true}}); }} catch(_e) {{ try {{ el.focus(); }} catch(__){{}} }} }} \
1973              }} \
1974              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' }}; \
1975              const norm = String(key).toLowerCase(); \
1976              const k = map[norm] || key; \
1977              const opts = {{ bubbles:true, cancelable:true, composed:true, key:k, view:window }}; \
1978              el.dispatchEvent(new KeyboardEvent('keydown', opts)); \
1979              el.dispatchEvent(new KeyboardEvent('keyup', opts)); \
1980              return {{ ok:true }}; \
1981            }})({key_json}, {selector_json}, {idx})"
1982        );
1983        self.run_js_action(&script).await
1984    }
1985
1986    /// Scroll by `(dx, dy)` in the DOM. Walks up from the element at the given
1987    /// viewport point (default: center) to the nearest scrollable ancestor, so
1988    /// it scrolls internal scroll containers, not just the document. When a
1989    /// webview reports `innerWidth/Height` as 0, the center point is unusable,
1990    /// so it falls back to the largest scrollable element, then the document
1991    /// scroller. Uses direct `scrollTop`/`scrollLeft` assignment, not
1992    /// `scrollBy`: on iOS WKWebView `scrollBy` animates sub-scrollers and
1993    /// overshoots to 2x the delta. NB: the built script must contain no `//`
1994    /// line comments — the `\`-continued format string collapses to one line.
1995    #[cfg(any(
1996        target_os = "ios",
1997        all(feature = "webview-input", target_os = "macos"),
1998        all(target_os = "linux", target_env = "ohos")
1999    ))]
2000    pub(crate) async fn scroll_via_js(
2001        &self,
2002        at: Option<(f64, f64)>,
2003        dx: f64,
2004        dy: f64,
2005    ) -> Result<(), WebViewInputError> {
2006        let (px, py) = at.unwrap_or((-1.0, -1.0));
2007        let script = format!(
2008            "((px, py, dx, dy) => {{ \
2009              const overflows = (v) => (/(auto|scroll|overlay)/).test(v); \
2010              const ancestor = (node) => {{ \
2011                while (node && node !== document.body && node !== document.documentElement) {{ \
2012                  const s = window.getComputedStyle(node); \
2013                  if ((overflows(s.overflowY) && node.scrollHeight > node.clientHeight) || \
2014                      (overflows(s.overflowX) && node.scrollWidth > node.clientWidth)) return node; \
2015                  node = node.parentElement; \
2016                }} \
2017                return null; \
2018              }}; \
2019              const largest = () => {{ \
2020                let best = null, range = 0; \
2021                const all = document.querySelectorAll('*'); \
2022                for (let k = 0; k < all.length; k++) {{ \
2023                  const n = all[k], s = window.getComputedStyle(n); \
2024                  const ry = overflows(s.overflowY) ? (n.scrollHeight - n.clientHeight) : 0; \
2025                  const rx = overflows(s.overflowX) ? (n.scrollWidth - n.clientWidth) : 0; \
2026                  const r = ry > rx ? ry : rx; \
2027                  if (r > range) {{ range = r; best = n; }} \
2028                }} \
2029                return best; \
2030              }}; \
2031              const vw = window.innerWidth || document.documentElement.clientWidth || 0; \
2032              const vh = window.innerHeight || document.documentElement.clientHeight || 0; \
2033              let target = null; \
2034              if (px >= 0 && py >= 0) target = ancestor(document.elementFromPoint(px, py) || document.body); \
2035              else if (vw > 0 && vh > 0) target = ancestor(document.elementFromPoint(vw >> 1, vh >> 1) || document.body); \
2036              if (!target) {{ \
2037                const se = document.scrollingElement || document.documentElement; \
2038                target = (se && se.scrollHeight > se.clientHeight) ? se : (largest() || se); \
2039              }} \
2040              target.scrollLeft += dx; target.scrollTop += dy; \
2041              return {{ ok:true }}; \
2042            }})({px}, {py}, {dx}, {dy})"
2043        );
2044        self.run_js_action(&script).await
2045    }
2046
2047    /// Scroll an element into view (`scrollIntoView`).
2048    #[cfg(any(
2049        target_os = "ios",
2050        all(feature = "webview-input", target_os = "macos"),
2051        all(target_os = "linux", target_env = "ohos")
2052    ))]
2053    pub(crate) async fn scroll_to_via_js(
2054        &self,
2055        selector: &str,
2056        index: Option<usize>,
2057    ) -> Result<(), WebViewInputError> {
2058        let selector_json = serde_json::to_string(selector)
2059            .map_err(|err| WebViewInputError::Platform(format!("Invalid selector: {err}")))?;
2060        let idx = index.unwrap_or(0);
2061        let script = format!(
2062            "((sel, i) => {{ \
2063              const els = document.querySelectorAll(sel); \
2064              if (!els.length || i < 0 || i >= els.length) return {{ ok:false, error:'no match', count:els.length }}; \
2065              try {{ els[i].scrollIntoView({{ block:'center', inline:'center' }}); }} catch(_e) {{ els[i].scrollIntoView(); }} \
2066              return {{ ok:true, count:els.length }}; \
2067            }})({selector_json}, {idx})"
2068        );
2069        self.run_js_action(&script).await
2070    }
2071
2072    pub async fn current_url(&self) -> Result<Option<String>, WebViewError> {
2073        self.inner.current_url().await
2074    }
2075
2076    pub fn reload(&self) -> Result<(), WebViewError> {
2077        self.inner.reload()
2078    }
2079
2080    pub fn go_back(&self) -> Result<(), WebViewError> {
2081        self.inner.go_back()
2082    }
2083
2084    pub fn go_forward(&self) -> Result<(), WebViewError> {
2085        self.inner.go_forward()
2086    }
2087
2088    pub async fn list_cookies(&self) -> Result<Vec<WebViewCookie>, WebViewError> {
2089        self.inner.list_cookies().await
2090    }
2091
2092    pub async fn set_cookie(&self, request: WebViewCookieSetRequest) -> Result<(), WebViewError> {
2093        self.inner.set_cookie(request).await
2094    }
2095
2096    pub async fn delete_cookie(
2097        &self,
2098        name: &str,
2099        domain: &str,
2100        path: &str,
2101    ) -> Result<(), WebViewError> {
2102        self.inner.delete_cookie(name, domain, path).await
2103    }
2104
2105    pub async fn clear_cookies(&self) -> Result<(), WebViewError> {
2106        self.inner.clear_cookies().await
2107    }
2108
2109    pub async fn start_network_capture(&self) -> Result<(), WebViewError> {
2110        self.inner.start_network_capture().await
2111    }
2112
2113    pub async fn stop_network_capture(&self) -> Result<(), WebViewError> {
2114        self.inner.stop_network_capture().await
2115    }
2116
2117    pub async fn network_entries(&self) -> Result<NetworkCaptureSnapshot, WebViewError> {
2118        self.inner.network_entries().await
2119    }
2120
2121    pub async fn clear_network_capture(&self) -> Result<(), WebViewError> {
2122        self.inner.clear_network_capture().await
2123    }
2124
2125    pub async fn take_screenshot(&self) -> Result<Vec<u8>, WebViewError> {
2126        self.inner.take_screenshot().await
2127    }
2128
2129    pub async fn click(
2130        &self,
2131        selector: &str,
2132        options: ClickOptions,
2133    ) -> Result<(), WebViewInputError> {
2134        <Self as WebViewInputController>::click(self, selector, options).await
2135    }
2136
2137    pub async fn type_text(
2138        &self,
2139        selector: &str,
2140        text: &str,
2141        options: TypeOptions,
2142    ) -> Result<(), WebViewInputError> {
2143        <Self as WebViewInputController>::type_text(self, selector, text, options).await
2144    }
2145
2146    pub async fn fill(
2147        &self,
2148        selector: &str,
2149        text: &str,
2150        options: FillOptions,
2151    ) -> Result<(), WebViewInputError> {
2152        <Self as WebViewInputController>::fill(self, selector, text, options).await
2153    }
2154
2155    pub async fn press(&self, key: &str, options: PressOptions) -> Result<(), WebViewInputError> {
2156        <Self as WebViewInputController>::press(self, key, options).await
2157    }
2158
2159    pub async fn scroll(
2160        &self,
2161        dx: f64,
2162        dy: f64,
2163        options: ScrollOptions,
2164    ) -> Result<(), WebViewInputError> {
2165        <Self as WebViewInputController>::scroll(self, dx, dy, options).await
2166    }
2167
2168    pub async fn scroll_to(
2169        &self,
2170        selector: &str,
2171        options: ScrollOptions,
2172    ) -> Result<(), WebViewInputError> {
2173        <Self as WebViewInputController>::scroll_to(self, selector, options).await
2174    }
2175}
2176
2177#[async_trait]
2178impl WebViewController for WebView {
2179    fn load_url(&self, url: &str) -> Result<(), WebViewError> {
2180        self.inner.load_url(url)
2181    }
2182
2183    fn load_data(&self, request: LoadDataRequest<'_>) -> Result<(), WebViewError> {
2184        self.inner.load_data(request)
2185    }
2186
2187    fn exec_js(&self, js: &str) -> Result<(), WebViewError> {
2188        self.inner.exec_js(js)
2189    }
2190
2191    async fn eval_js(&self, js: &str) -> Result<serde_json::Value, WebViewScriptError> {
2192        self.inner.eval_js(js).await
2193    }
2194
2195    async fn current_url(&self) -> Result<Option<String>, WebViewError> {
2196        self.inner.current_url().await
2197    }
2198
2199    fn post_message(&self, message: &str) -> Result<(), WebViewError> {
2200        self.inner.post_message(message)
2201    }
2202
2203    fn post_message_to_document(
2204        &self,
2205        expected_generation: crate::DocumentGeneration,
2206        gate: Arc<dyn crate::DocumentOutboundGate>,
2207        message: &str,
2208    ) -> Result<(), WebViewError> {
2209        self.inner
2210            .post_message_to_document(expected_generation, gate, message)
2211    }
2212
2213    fn clear_browsing_data(&self) -> Result<(), WebViewError> {
2214        self.inner.clear_browsing_data()
2215    }
2216
2217    fn set_user_agent_override(&self, user_agent: UserAgentOverride) -> Result<(), WebViewError> {
2218        user_agent.validate()?;
2219        self.inner.set_user_agent_override(user_agent)
2220    }
2221
2222    fn reload(&self) -> Result<(), WebViewError> {
2223        self.inner.reload()
2224    }
2225
2226    fn go_back(&self) -> Result<(), WebViewError> {
2227        self.inner.go_back()
2228    }
2229
2230    fn go_forward(&self) -> Result<(), WebViewError> {
2231        self.inner.go_forward()
2232    }
2233
2234    async fn list_cookies(&self) -> Result<Vec<WebViewCookie>, WebViewError> {
2235        self.inner.list_cookies().await
2236    }
2237
2238    async fn set_cookie(&self, request: WebViewCookieSetRequest) -> Result<(), WebViewError> {
2239        self.inner.set_cookie(request).await
2240    }
2241
2242    async fn delete_cookie(
2243        &self,
2244        name: &str,
2245        domain: &str,
2246        path: &str,
2247    ) -> Result<(), WebViewError> {
2248        self.inner.delete_cookie(name, domain, path).await
2249    }
2250
2251    async fn clear_cookies(&self) -> Result<(), WebViewError> {
2252        self.inner.clear_cookies().await
2253    }
2254
2255    async fn clear_site_data(
2256        &self,
2257        url: &str,
2258        options: ClearSiteDataOptions,
2259    ) -> Result<ClearSiteDataResult, WebViewError> {
2260        self.inner.clear_site_data(url, options).await
2261    }
2262
2263    // Callers reach this through the inherent method today, but the trait
2264    // impl must stay exhaustive: a missed forward silently resolves to the
2265    // trait's Err default for dyn/generic dispatch (how clear_site_data
2266    // shipped broken).
2267    async fn take_screenshot(&self) -> Result<Vec<u8>, WebViewError> {
2268        self.inner.take_screenshot().await
2269    }
2270
2271    async fn start_network_capture(&self) -> Result<(), WebViewError> {
2272        self.inner.start_network_capture().await
2273    }
2274
2275    async fn stop_network_capture(&self) -> Result<(), WebViewError> {
2276        self.inner.stop_network_capture().await
2277    }
2278
2279    async fn network_entries(&self) -> Result<NetworkCaptureSnapshot, WebViewError> {
2280        self.inner.network_entries().await
2281    }
2282
2283    async fn clear_network_capture(&self) -> Result<(), WebViewError> {
2284        self.inner.clear_network_capture().await
2285    }
2286}
2287
2288#[async_trait]
2289impl WebViewInputController for WebView {
2290    async fn click(
2291        &self,
2292        _selector: &str,
2293        _options: ClickOptions,
2294    ) -> Result<(), WebViewInputError> {
2295        // macOS uses DOM synthesis for selector clicks: AppKit does not expose
2296        // a reliable permission-free way to update WKWebView hit testing from
2297        // an in-process NSEvent. Text and key input still use native WebKit
2298        // editing paths below. iOS/OpenHarmony likewise have no native touch
2299        // synthesis.
2300        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2301        {
2302            return self.click_via_js(_selector, _options.index).await;
2303        }
2304        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2305        {
2306            return self.inner.click_inner(_selector, _options).await;
2307        }
2308        #[cfg(target_os = "android")]
2309        {
2310            return self.inner.click_inner(_selector, _options).await;
2311        }
2312        #[cfg(any(target_os = "ios", all(target_os = "linux", target_env = "ohos")))]
2313        {
2314            return self.click_via_js(_selector, _options.index).await;
2315        }
2316        #[allow(unreachable_code)]
2317        Err(WebViewInputError::Unsupported(
2318            "input control is not implemented for this platform",
2319        ))
2320    }
2321
2322    async fn type_text(
2323        &self,
2324        _selector: &str,
2325        _text: &str,
2326        _options: TypeOptions,
2327    ) -> Result<(), WebViewInputError> {
2328        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2329        {
2330            if self.inner.is_window_attached().await {
2331                return self.inner.type_text_inner(_selector, _text, _options).await;
2332            }
2333            return self
2334                .type_via_js(_selector, _options.index, _text, _options.replace)
2335                .await;
2336        }
2337        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2338        {
2339            return self.inner.type_text_inner(_selector, _text, _options).await;
2340        }
2341        #[cfg(any(
2342            target_os = "ios",
2343            target_os = "android",
2344            all(target_os = "linux", target_env = "ohos")
2345        ))]
2346        {
2347            return self
2348                .type_via_js(_selector, _options.index, _text, _options.replace)
2349                .await;
2350        }
2351        #[allow(unreachable_code)]
2352        Err(WebViewInputError::Unsupported(
2353            "input control is not implemented for this platform",
2354        ))
2355    }
2356
2357    async fn fill(
2358        &self,
2359        _selector: &str,
2360        _text: &str,
2361        _options: FillOptions,
2362    ) -> Result<(), WebViewInputError> {
2363        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2364        {
2365            // `fill` is a framework-aware replacement operation. WebKit's
2366            // native InsertText command can report success before a controlled
2367            // React/Vue input observes the edit, leaving dependent controls in
2368            // their old state. `type` retains the native keyboard path.
2369            return self
2370                .type_via_js(_selector, _options.index, _text, true)
2371                .await;
2372        }
2373        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2374        {
2375            return self
2376                .inner
2377                .type_text_inner(
2378                    _selector,
2379                    _text,
2380                    TypeOptions {
2381                        index: _options.index,
2382                        replace: true,
2383                    },
2384                )
2385                .await;
2386        }
2387        #[cfg(any(
2388            target_os = "ios",
2389            target_os = "android",
2390            all(target_os = "linux", target_env = "ohos")
2391        ))]
2392        {
2393            return self
2394                .type_via_js(_selector, _options.index, _text, true)
2395                .await;
2396        }
2397        #[allow(unreachable_code)]
2398        Err(WebViewInputError::Unsupported(
2399            "input control is not implemented for this platform",
2400        ))
2401    }
2402
2403    async fn press(&self, _key: &str, _options: PressOptions) -> Result<(), WebViewInputError> {
2404        if _options.index.is_some() && _options.selector.is_none() {
2405            return Err(WebViewInputError::Platform(
2406                "press index requires a selector".to_string(),
2407            ));
2408        }
2409        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2410        {
2411            if self.inner.is_window_attached().await {
2412                return self.inner.press_inner(_key, _options).await;
2413            }
2414            return self
2415                .press_via_js(_key, _options.selector.as_deref(), _options.index)
2416                .await;
2417        }
2418        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2419        {
2420            return self.inner.press_inner(_key, _options).await;
2421        }
2422        #[cfg(any(
2423            target_os = "ios",
2424            target_os = "android",
2425            all(target_os = "linux", target_env = "ohos")
2426        ))]
2427        {
2428            return self
2429                .press_via_js(_key, _options.selector.as_deref(), _options.index)
2430                .await;
2431        }
2432        #[allow(unreachable_code)]
2433        Err(WebViewInputError::Unsupported(
2434            "input control is not implemented for this platform",
2435        ))
2436    }
2437
2438    async fn scroll(
2439        &self,
2440        _dx: f64,
2441        _dy: f64,
2442        _options: ScrollOptions,
2443    ) -> Result<(), WebViewInputError> {
2444        // AppUI renders lxapp pages as native surfaces with the WKWebView
2445        // detached, so native scroll wheel events can't reach the DOM — use JS.
2446        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2447        {
2448            if self.inner.is_window_attached().await {
2449                return self.inner.scroll_inner(_dx, _dy, _options).await;
2450            }
2451            return self.scroll_via_js(None, _dx, _dy).await;
2452        }
2453        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2454        {
2455            return self.inner.scroll_inner(_dx, _dy, _options).await;
2456        }
2457        // Android scrolls page content in the native View layer (the DOM
2458        // document has no scroll extent), so drive WebView.scrollBy natively.
2459        #[cfg(target_os = "android")]
2460        {
2461            return self.inner.scroll_inner(_dx, _dy, _options).await;
2462        }
2463        // iOS has no native scroll synthesis; Harmony webview is always detached.
2464        #[cfg(any(target_os = "ios", all(target_os = "linux", target_env = "ohos")))]
2465        {
2466            return self.scroll_via_js(None, _dx, _dy).await;
2467        }
2468        #[allow(unreachable_code)]
2469        Err(WebViewInputError::Unsupported(
2470            "input control is not implemented for this platform",
2471        ))
2472    }
2473
2474    async fn scroll_to(
2475        &self,
2476        _selector: &str,
2477        _options: ScrollOptions,
2478    ) -> Result<(), WebViewInputError> {
2479        #[cfg(all(feature = "webview-input", target_os = "macos"))]
2480        {
2481            if self.inner.is_window_attached().await {
2482                return self.inner.scroll_to_inner(_selector, _options).await;
2483            }
2484            return self.scroll_to_via_js(_selector, None).await;
2485        }
2486        #[cfg(all(feature = "webview-input", target_os = "windows"))]
2487        {
2488            return self.inner.scroll_to_inner(_selector, _options).await;
2489        }
2490        #[cfg(target_os = "android")]
2491        {
2492            return self.inner.scroll_to_inner(_selector, _options).await;
2493        }
2494        #[cfg(any(target_os = "ios", all(target_os = "linux", target_env = "ohos")))]
2495        {
2496            return self.scroll_to_via_js(_selector, None).await;
2497        }
2498        #[allow(unreachable_code)]
2499        Err(WebViewInputError::Unsupported(
2500            "input control is not implemented for this platform",
2501        ))
2502    }
2503}
2504
2505/// Type alias for WebView instances storage to reduce complexity
2506type WebViewInstancesMap = Arc<Mutex<HashMap<String, Arc<WebView>>>>;
2507
2508#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2509#[serde(rename_all = "snake_case")]
2510pub enum WebViewCreateStage {
2511    Requested,
2512    NativeCreated,
2513    ControllerAttached,
2514    Ready,
2515    Destroyed,
2516}
2517
2518#[derive(Debug, Clone, PartialEq, Eq)]
2519pub enum WebViewEvent {
2520    Stage(WebViewCreateStage),
2521    Failed {
2522        stage: WebViewCreateStage,
2523        error: WebViewError,
2524    },
2525}
2526
2527type WebViewReadyState = Option<Result<Arc<WebView>, WebViewError>>;
2528
2529#[derive(Clone)]
2530pub struct WebViewEventSubscription {
2531    rx: watch::Receiver<WebViewEvent>,
2532}
2533
2534impl WebViewEventSubscription {
2535    pub fn current(&self) -> WebViewEvent {
2536        self.rx.borrow().clone()
2537    }
2538
2539    pub async fn changed(&mut self) -> Result<WebViewEvent, WebViewError> {
2540        self.rx.changed().await.map_err(|_| {
2541            WebViewError::WebView("webview event channel unexpectedly closed".to_string())
2542        })?;
2543        Ok(self.current())
2544    }
2545}
2546
2547#[derive(Clone)]
2548pub struct WebViewSession {
2549    webtag: WebTag,
2550    event_rx: watch::Receiver<WebViewEvent>,
2551    ready_rx: watch::Receiver<WebViewReadyState>,
2552    signals: Arc<WebViewSessionSignals>,
2553}
2554
2555impl WebViewSession {
2556    pub fn webtag(&self) -> &WebTag {
2557        &self.webtag
2558    }
2559
2560    pub fn subscribe_events(&self) -> WebViewEventSubscription {
2561        WebViewEventSubscription {
2562            rx: self.event_rx.clone(),
2563        }
2564    }
2565
2566    pub fn current_event(&self) -> WebViewEvent {
2567        self.event_rx.borrow().clone()
2568    }
2569
2570    pub async fn wait_ready(&self) -> Result<Arc<WebView>, WebViewError> {
2571        let mut rx = self.ready_rx.clone();
2572        loop {
2573            if let Some(result) = self.signals.terminal_result() {
2574                return result;
2575            }
2576            if let Some(result) = rx.borrow().clone() {
2577                return result;
2578            }
2579            if rx.changed().await.is_err() {
2580                if let Some(result) = self.signals.terminal_result() {
2581                    return result;
2582                }
2583                return Err(WebViewError::WebView(
2584                    "webview ready channel unexpectedly closed".to_string(),
2585                ));
2586            }
2587        }
2588    }
2589}
2590
2591struct WebViewSessionSignals {
2592    event_tx: watch::Sender<WebViewEvent>,
2593    ready_tx: watch::Sender<WebViewReadyState>,
2594    state: Mutex<WebViewSessionState>,
2595}
2596
2597#[derive(Default)]
2598struct WebViewSessionState {
2599    terminal_result: Option<Result<Arc<WebView>, WebViewError>>,
2600    destroyed: bool,
2601}
2602
2603impl WebViewSessionSignals {
2604    fn new() -> Arc<Self> {
2605        let (event_tx, _event_rx) =
2606            watch::channel(WebViewEvent::Stage(WebViewCreateStage::Requested));
2607        let (ready_tx, _ready_rx) = watch::channel(None);
2608        Arc::new(Self {
2609            event_tx,
2610            ready_tx,
2611            state: Mutex::new(WebViewSessionState::default()),
2612        })
2613    }
2614
2615    fn subscribe(self: &Arc<Self>, webtag: WebTag) -> WebViewSession {
2616        WebViewSession {
2617            webtag,
2618            event_rx: self.event_tx.subscribe(),
2619            ready_rx: self.ready_tx.subscribe(),
2620            signals: Arc::clone(self),
2621        }
2622    }
2623
2624    fn terminal_result(&self) -> Option<Result<Arc<WebView>, WebViewError>> {
2625        let state = lock_or_recover(&self.state, "webview_session_state.terminal_result");
2626        state.terminal_result.clone()
2627    }
2628
2629    // Only consulted by the Apple create path's registry-race guard.
2630    #[cfg_attr(not(any(target_os = "macos", target_os = "ios")), allow(dead_code))]
2631    fn is_destroyed(&self) -> bool {
2632        let state = lock_or_recover(&self.state, "webview_session_state.is_destroyed");
2633        state.destroyed
2634    }
2635
2636    fn publish_result(
2637        &self,
2638        result: Result<Arc<WebView>, WebViewError>,
2639        stage_on_error: WebViewCreateStage,
2640    ) {
2641        let mut state = lock_or_recover(&self.state, "webview_session_state.publish_result");
2642        if state.destroyed || state.terminal_result.is_some() {
2643            return;
2644        }
2645        state.terminal_result = Some(result.clone());
2646        drop(state);
2647
2648        match result {
2649            Ok(webview) => {
2650                self.event_tx
2651                    .send_replace(WebViewEvent::Stage(WebViewCreateStage::NativeCreated));
2652                self.event_tx
2653                    .send_replace(WebViewEvent::Stage(WebViewCreateStage::ControllerAttached));
2654                self.ready_tx.send_replace(Some(Ok(webview)));
2655                self.event_tx
2656                    .send_replace(WebViewEvent::Stage(WebViewCreateStage::Ready));
2657            }
2658            Err(error) => {
2659                self.ready_tx.send_replace(Some(Err(error.clone())));
2660                self.event_tx.send_replace(WebViewEvent::Failed {
2661                    stage: stage_on_error,
2662                    error,
2663                });
2664            }
2665        }
2666    }
2667
2668    fn publish_destroyed(&self) {
2669        let mut state = lock_or_recover(&self.state, "webview_session_state.publish_destroyed");
2670        if state.destroyed {
2671            return;
2672        }
2673        state.destroyed = true;
2674        if state.terminal_result.is_none() {
2675            state.terminal_result = Some(Err(WebViewError::WebView(
2676                "webview destroyed before ready".to_string(),
2677            )));
2678        }
2679        let terminal_result = state.terminal_result.clone();
2680        drop(state);
2681
2682        self.event_tx
2683            .send_replace(WebViewEvent::Stage(WebViewCreateStage::Destroyed));
2684        if let Some(result) = terminal_result {
2685            self.ready_tx.send_replace(Some(result));
2686        }
2687    }
2688}
2689
2690pub(crate) struct WebViewCreateSender {
2691    webtag: WebTag,
2692    signals: Arc<WebViewSessionSignals>,
2693    native_view_id: NativeWebViewId,
2694}
2695
2696impl WebViewCreateSender {
2697    fn new(webtag: WebTag, signals: Arc<WebViewSessionSignals>) -> Self {
2698        Self {
2699            webtag,
2700            signals,
2701            native_view_id: next_native_webview_id(),
2702        }
2703    }
2704
2705    /// The concrete native-instance identity reserved before platform callback
2706    /// registration. Platform closures must capture this value and validate it
2707    /// against a lookup before delivering a message for a reusable WebTag.
2708    pub(crate) const fn native_view_id(&self) -> NativeWebViewId {
2709        self.native_view_id
2710    }
2711
2712    pub(crate) fn succeed(self, webview: Arc<WebView>) {
2713        self.signals
2714            .publish_result(Ok(webview), WebViewCreateStage::Requested);
2715    }
2716
2717    pub(crate) fn fail(self, stage: WebViewCreateStage, error: WebViewError) {
2718        if remove_session_signals_if_matches(&self.webtag, &self.signals) {
2719            crate::events::normalizer::destroy(&self.webtag);
2720        }
2721        self.signals.publish_result(Err(error), stage);
2722    }
2723
2724    /// Complete only this create generation after a newer same-tag session
2725    /// replaced it. The current generation's registry and callbacks belong to
2726    /// a different signals identity and must remain untouched.
2727    #[cfg_attr(not(target_os = "android"), allow(dead_code))]
2728    pub(crate) fn cancel_superseded(self) {
2729        if remove_session_signals_if_matches(&self.webtag, &self.signals) {
2730            crate::events::normalizer::destroy(&self.webtag);
2731        }
2732        self.signals.publish_destroyed();
2733    }
2734
2735    /// True if the session was destroyed (e.g. the tab was closed/discarded)
2736    /// while the native WebView was still being built. The platform create
2737    /// path checks this before registering, to avoid leaving a zombie in the
2738    /// global registry. Apple and Windows consult it around native registration.
2739    #[cfg_attr(
2740        not(any(target_os = "macos", target_os = "ios", target_os = "windows")),
2741        allow(dead_code)
2742    )]
2743    pub(crate) fn is_destroyed(&self) -> bool {
2744        self.signals.is_destroyed()
2745    }
2746}
2747
2748/// Global WebView instances storage
2749static WEBVIEW_INSTANCES: OnceLock<WebViewInstancesMap> = OnceLock::new();
2750
2751/// Pending callbacks: keyed by webtag string -> callbacks struct.
2752/// Stored here between builder-based session creation and `register_webview`.
2753struct PendingCallbacksEntry {
2754    #[cfg(target_os = "android")]
2755    signals: Arc<WebViewSessionSignals>,
2756    callbacks: PendingCallbacks,
2757}
2758
2759static PENDING_CALLBACKS: OnceLock<Mutex<HashMap<String, PendingCallbacksEntry>>> = OnceLock::new();
2760static WEBVIEW_SESSIONS: OnceLock<Mutex<HashMap<String, Arc<WebViewSessionSignals>>>> =
2761    OnceLock::new();
2762#[cfg(target_os = "windows")]
2763static WEBVIEW_CREATE_LOCKS: OnceLock<Mutex<HashMap<String, std::sync::Weak<Mutex<()>>>>> =
2764    OnceLock::new();
2765static DESIRED_PROXY_FOR_NEW_WEBVIEWS: OnceLock<RwLock<Option<ProxyConfig>>> = OnceLock::new();
2766static PROXY_APPLY_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2767
2768fn apply_http_proxy_platform(
2769    config: Option<&ProxyConfig>,
2770) -> Result<ProxyApplyReport, WebViewError> {
2771    #[cfg(target_os = "android")]
2772    {
2773        crate::android::apply_http_proxy(config)
2774    }
2775
2776    #[cfg(any(target_os = "ios", target_os = "macos"))]
2777    {
2778        crate::apple::apply_http_proxy(config)
2779    }
2780
2781    #[cfg(all(target_os = "linux", target_env = "ohos"))]
2782    {
2783        crate::harmony::apply_http_proxy(config)
2784    }
2785
2786    #[cfg(not(any(
2787        target_os = "android",
2788        target_os = "ios",
2789        target_os = "macos",
2790        all(target_os = "linux", target_env = "ohos")
2791    )))]
2792    {
2793        let _ = config;
2794        Ok(ProxyApplyReport::unsupported(
2795            "proxy is not supported on this platform",
2796        ))
2797    }
2798}
2799
2800/// Configure the proxy that should be used for newly created WebViews in this process.
2801///
2802/// This only updates the desired configuration kept in process memory. It does
2803/// not live-apply the proxy to currently active WebViews.
2804pub fn configure_proxy_for_new_webviews(config: Option<ProxyConfig>) -> Result<(), WebViewError> {
2805    let apply_lock = PROXY_APPLY_LOCK.get_or_init(|| Mutex::new(()));
2806    let _guard = lock_or_recover(apply_lock, "webview_proxy_apply_lock");
2807
2808    let normalized_config = match config {
2809        Some(cfg) => Some(cfg.validate()?),
2810        None => None,
2811    };
2812
2813    let state = DESIRED_PROXY_FOR_NEW_WEBVIEWS.get_or_init(|| RwLock::new(None));
2814    match state.write() {
2815        Ok(mut guard) => {
2816            *guard = normalized_config;
2817        }
2818        Err(poisoned) => {
2819            log::error!("RwLock poisoned at webview_desired_proxy.write, recovering");
2820            *poisoned.into_inner() = normalized_config;
2821        }
2822    }
2823    Ok(())
2824}
2825
2826/// Apply or clear process-level HTTP proxy for the current platform runtime now.
2827///
2828/// - `Some(config)`: set proxy
2829/// - `None`: clear proxy
2830pub fn apply_proxy_to_current_runtime(
2831    config: Option<ProxyConfig>,
2832) -> Result<ProxyApplyReport, WebViewError> {
2833    let apply_lock = PROXY_APPLY_LOCK.get_or_init(|| Mutex::new(()));
2834    let _guard = lock_or_recover(apply_lock, "webview_proxy_apply_lock");
2835
2836    let normalized_config = match config {
2837        Some(cfg) => Some(cfg.validate()?),
2838        None => None,
2839    };
2840
2841    let report = apply_http_proxy_platform(normalized_config.as_ref())?;
2842
2843    if matches!(
2844        report.status,
2845        ProxyApplyStatus::Applied | ProxyApplyStatus::Cleared
2846    ) {
2847        let state = DESIRED_PROXY_FOR_NEW_WEBVIEWS.get_or_init(|| RwLock::new(None));
2848        match state.write() {
2849            Ok(mut guard) => {
2850                *guard = normalized_config;
2851            }
2852            Err(poisoned) => {
2853                log::error!("RwLock poisoned at webview_desired_proxy.write, recovering");
2854                *poisoned.into_inner() = normalized_config;
2855            }
2856        }
2857    }
2858
2859    Ok(report)
2860}
2861
2862/// Get the configured proxy that will be used for newly created WebViews.
2863pub fn configured_proxy_for_new_webviews() -> Option<ProxyConfig> {
2864    let state = DESIRED_PROXY_FOR_NEW_WEBVIEWS.get()?;
2865    match state.read() {
2866        Ok(guard) => guard.clone(),
2867        Err(poisoned) => {
2868            log::error!("RwLock poisoned at webview_desired_proxy.read, recovering");
2869            poisoned.into_inner().clone()
2870        }
2871    }
2872}
2873
2874fn clear_pending_callbacks(webtag: &WebTag) {
2875    if let Some(pending) = PENDING_CALLBACKS.get()
2876        && let Ok(mut map) = pending.lock()
2877    {
2878        map.remove(webtag.key());
2879    }
2880}
2881
2882fn replace_session_signals(webtag: &WebTag, signals: Arc<WebViewSessionSignals>) {
2883    let sessions = WEBVIEW_SESSIONS.get_or_init(|| Mutex::new(HashMap::new()));
2884    let mut guard = lock_or_recover(sessions, "webview_sessions.replace");
2885    guard.insert(webtag.key().to_string(), signals);
2886}
2887
2888fn remove_session_signals(webtag: &WebTag) -> Option<Arc<WebViewSessionSignals>> {
2889    let sessions = WEBVIEW_SESSIONS.get()?;
2890    let mut guard = lock_or_recover(sessions, "webview_sessions.remove");
2891    guard.remove(webtag.key())
2892}
2893
2894fn remove_session_signals_if_matches(
2895    webtag: &WebTag,
2896    expected: &Arc<WebViewSessionSignals>,
2897) -> bool {
2898    let Some(sessions) = WEBVIEW_SESSIONS.get() else {
2899        return false;
2900    };
2901    let mut guard = lock_or_recover(sessions, "webview_sessions.remove_if_matches");
2902    if guard
2903        .get(webtag.key())
2904        .is_some_and(|current| Arc::ptr_eq(current, expected))
2905    {
2906        guard.remove(webtag.key());
2907        true
2908    } else {
2909        false
2910    }
2911}
2912
2913/// Drop the tag's session only while it still resolved to `expected`. Its
2914/// terminal result owns the view, so a kept session pins the native WebView
2915/// until the next same-tag create replaces it.
2916fn remove_session_signals_owning(
2917    webtag: &WebTag,
2918    expected: &Arc<WebView>,
2919) -> Option<Arc<WebViewSessionSignals>> {
2920    let sessions = WEBVIEW_SESSIONS.get()?;
2921    let mut guard = lock_or_recover(sessions, "webview_sessions.remove_owning");
2922    let owns = guard.get(webtag.key()).is_some_and(|signals| {
2923        matches!(signals.terminal_result(), Some(Ok(current)) if Arc::ptr_eq(&current, expected))
2924    });
2925    owns.then(|| guard.remove(webtag.key())).flatten()
2926}
2927
2928/// WebView identifier combining appid, path, and optional session id.
2929/// Example: `appid:path#123`.
2930#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2931pub struct WebTag(String);
2932
2933impl std::fmt::Display for WebTag {
2934    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2935        write!(f, "{}", self.0)
2936    }
2937}
2938
2939impl WebTag {
2940    pub fn new(appid: &str, path: &str, session_id: Option<u64>) -> Self {
2941        let mut tag = format!("{}:{}", appid, path);
2942        if let Some(session) = session_id {
2943            tag.push('#');
2944            tag.push_str(&session.to_string());
2945        }
2946        Self(tag)
2947    }
2948
2949    pub fn as_str(&self) -> &str {
2950        &self.0
2951    }
2952
2953    /// Storage key for this tag.
2954    /// This preserves the optional `#session` suffix so instances are isolated
2955    /// per runtime session.
2956    pub fn key(&self) -> &str {
2957        &self.0
2958    }
2959
2960    /// Extract appid from the webtag
2961    pub fn extract_appid(&self) -> String {
2962        self.0.split(':').next().unwrap_or("").to_string()
2963    }
2964
2965    /// Extract appid and path from WebTag
2966    /// This will always succeed since WebTag is constructed with a valid format
2967    pub fn extract_parts(&self) -> (String, String) {
2968        if let Some((appid, path_with_session)) = self.0.split_once(':') {
2969            let path = path_with_session
2970                .split('#')
2971                .next()
2972                .unwrap_or(path_with_session);
2973            (appid.to_string(), path.to_string())
2974        } else {
2975            log::error!("Invalid webtag format: {}", self.0);
2976            ("".to_string(), self.0.clone())
2977        }
2978    }
2979
2980    /// Extract session id (if present) from the webtag
2981    pub fn session_id(&self) -> Option<u64> {
2982        self.0
2983            .split('#')
2984            .next_back()
2985            .and_then(|raw| raw.parse::<u64>().ok())
2986    }
2987
2988    /// Grouping key combining appid and session id (`appid#session`), with the
2989    /// session defaulting to `0` when the tag carries no `#session` suffix.
2990    /// Tags without an `appid:` prefix are returned unchanged.
2991    #[cfg_attr(
2992        any(not(target_os = "windows"), target_os = "windows"),
2993        allow(dead_code)
2994    )]
2995    pub(crate) fn group_key(&self) -> String {
2996        let Some((appid, path_with_session)) = self.0.split_once(':') else {
2997            return self.0.clone();
2998        };
2999        let session = path_with_session
3000            .rsplit_once('#')
3001            .and_then(|(_, suffix)| suffix.parse::<u64>().ok())
3002            .map(|session| session.to_string())
3003            .unwrap_or_else(|| "0".to_string());
3004        format!("{appid}#{session}")
3005    }
3006
3007    fn key_path(&self) -> String {
3008        let Some((_, path_with_suffix)) = self.0.split_once(':') else {
3009            return self.0.clone();
3010        };
3011        if self.session_id().is_some()
3012            && let Some((path, _)) = path_with_suffix.rsplit_once('#')
3013        {
3014            return path.to_string();
3015        }
3016        path_with_suffix.to_string()
3017    }
3018}
3019
3020impl From<&str> for WebTag {
3021    fn from(webtag_str: &str) -> Self {
3022        Self(webtag_str.to_string())
3023    }
3024}
3025
3026fn request_create_webview(
3027    webtag: &WebTag,
3028    sender: WebViewCreateSender,
3029    options: WebViewCreateOptions,
3030) {
3031    let (appid, _) = webtag.extract_parts();
3032    let (effective_options, pending_callbacks) = match options.normalize() {
3033        Ok(value) => value,
3034        Err(error) => {
3035            sender.fail(WebViewCreateStage::Requested, error);
3036            return;
3037        }
3038    };
3039
3040    log::info!(
3041        "Creating WebView for key={} profile={:?} data_mode={:?} schemes={:?}",
3042        webtag.key(),
3043        effective_options.profile,
3044        effective_options.data_mode,
3045        effective_options.registered_schemes,
3046    );
3047
3048    // Get or initialize the global instances map
3049    let instances = WEBVIEW_INSTANCES.get_or_init(|| Arc::new(Mutex::new(HashMap::new())));
3050
3051    // Existing instance policy:
3052    // - Different options: fail fast (do not silently reuse incompatible instance).
3053    // - Same options + callback registrations: fail fast because callbacks are immutable after first create.
3054    // - Same options + no callbacks: return existing instance.
3055    if let Ok(webviews) = instances.lock()
3056        && let Some(existing_webview) = webviews.get(webtag.key())
3057    {
3058        if existing_webview.effective_options() != &effective_options {
3059            sender.fail(
3060                WebViewCreateStage::Requested,
3061                WebViewError::InvalidCreateOptions(format!(
3062                    "webview already exists with different options: key={} existing={:?} requested={:?}",
3063                    webtag.key(),
3064                    existing_webview.effective_options(),
3065                    effective_options
3066                )),
3067            );
3068            return;
3069        }
3070
3071        if pending_callbacks.has_any() {
3072            sender.fail(
3073                WebViewCreateStage::Requested,
3074                WebViewError::InvalidCreateOptions(format!(
3075                    "webview already exists and callback registrations are immutable: key={} options={:?}",
3076                    webtag.key(),
3077                    existing_webview.effective_options()
3078                )),
3079            );
3080            log::warn!(
3081                "Rejected recreate with callbacks for existing webview key={} options={:?}",
3082                webtag.key(),
3083                existing_webview.effective_options()
3084            );
3085            return;
3086        }
3087
3088        log::info!("WebView already exists, reusing: {}", webtag.key());
3089        sender.succeed(existing_webview.clone());
3090        return;
3091    }
3092
3093    // Drop stale pending callbacks from previously failed create attempts.
3094    clear_pending_callbacks(webtag);
3095
3096    // Stash pending callbacks for install during register_webview()
3097    if pending_callbacks.has_any() {
3098        let pending = PENDING_CALLBACKS.get_or_init(|| Mutex::new(HashMap::new()));
3099        if let Ok(mut map) = pending.lock() {
3100            map.insert(
3101                webtag.key().to_string(),
3102                PendingCallbacksEntry {
3103                    #[cfg(target_os = "android")]
3104                    signals: Arc::clone(&sender.signals),
3105                    callbacks: pending_callbacks,
3106                },
3107            );
3108        }
3109    }
3110
3111    // Delegate WebView creation to the platform-specific implementation
3112    WebViewInner::create(
3113        &appid,
3114        &webtag.key_path(),
3115        webtag.session_id(),
3116        effective_options,
3117        sender,
3118    );
3119}
3120
3121fn create_webview_session(webtag: WebTag, options: WebViewCreateOptions) -> WebViewSession {
3122    // Windows creation blocks until its WebView2 UI thread registers the
3123    // native instance. Serialize the whole same-tag transaction, including
3124    // session replacement and pending callbacks, so a discard/reactivate race
3125    // cannot cross-wire two generations of callbacks.
3126    #[cfg(target_os = "windows")]
3127    let create_lock = windows_webview_create_lock(webtag.key());
3128    #[cfg(target_os = "windows")]
3129    let _create_guard = lock_or_recover(&create_lock, "windows_webview_create_lock");
3130
3131    let signals = WebViewSessionSignals::new();
3132    let session = signals.subscribe(webtag.clone());
3133    let sender = WebViewCreateSender::new(webtag.clone(), signals.clone());
3134    replace_session_signals(&webtag, signals);
3135    crate::events::normalizer::begin(&webtag, sender.native_view_id());
3136    request_create_webview(&webtag, sender, options);
3137    session
3138}
3139
3140#[cfg(target_os = "windows")]
3141fn windows_webview_create_lock(webtag_key: &str) -> Arc<Mutex<()>> {
3142    let locks = WEBVIEW_CREATE_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
3143    let mut locks = lock_or_recover(locks, "windows_webview_create_locks");
3144    locks.retain(|_, lock| lock.strong_count() > 0);
3145    if let Some(lock) = locks.get(webtag_key).and_then(std::sync::Weak::upgrade) {
3146        return lock;
3147    }
3148    let lock = Arc::new(Mutex::new(()));
3149    locks.insert(webtag_key.to_string(), Arc::downgrade(&lock));
3150    lock
3151}
3152
3153#[cfg_attr(target_os = "android", allow(dead_code))]
3154pub(crate) fn register_webview(webview: Arc<WebView>) {
3155    let webtag = webview.webtag();
3156    crate::events::normalizer::bind_native_view(&webtag, webview.native_view_id());
3157
3158    // Install any pending callbacks
3159    if let Some(pending) = PENDING_CALLBACKS.get()
3160        && let Ok(mut map) = pending.lock()
3161        && let Some(entry) = map.remove(webtag.key())
3162    {
3163        let callbacks = entry.callbacks;
3164        log::info!(
3165            "Installing callbacks for {} (schemes={}, nav={}, new_window={}, download={}, file_chooser={}, delegate={})",
3166            webtag.key(),
3167            callbacks.scheme_handlers.len(),
3168            callbacks.navigation_handler.is_some(),
3169            callbacks.new_window_handler.is_some(),
3170            callbacks.download_handler.is_some(),
3171            callbacks.file_chooser_handler.is_some(),
3172            callbacks.delegate.is_some()
3173        );
3174        webview.install_callbacks(callbacks);
3175    }
3176
3177    if let Some(instances) = WEBVIEW_INSTANCES.get()
3178        && let Ok(mut webviews) = instances.lock()
3179    {
3180        webviews.insert(webtag.key().to_string(), webview.clone());
3181        log::info!("WebView created and stored: {}", webtag.key());
3182    }
3183}
3184
3185#[cfg(target_os = "android")]
3186pub(crate) fn register_android_webview_if_current(
3187    webview: Arc<WebView>,
3188    sender: &WebViewCreateSender,
3189) -> bool {
3190    let webtag = webview.webtag();
3191    let sessions = WEBVIEW_SESSIONS.get_or_init(|| Mutex::new(HashMap::new()));
3192    let session_guard = lock_or_recover(sessions, "webview_sessions.register_android");
3193    if !session_guard
3194        .get(webtag.key())
3195        .is_some_and(|current| Arc::ptr_eq(current, &sender.signals))
3196    {
3197        return false;
3198    }
3199
3200    if let Some(pending) = PENDING_CALLBACKS.get()
3201        && let Ok(mut map) = pending.lock()
3202        && map
3203            .get(webtag.key())
3204            .is_some_and(|entry| Arc::ptr_eq(&entry.signals, &sender.signals))
3205        && let Some(entry) = map.remove(webtag.key())
3206    {
3207        webview.install_callbacks(entry.callbacks);
3208    }
3209
3210    let instances = WEBVIEW_INSTANCES.get_or_init(|| Arc::new(Mutex::new(HashMap::new())));
3211    let mut webviews = lock_or_recover(instances, "webview_instances.register_android");
3212    crate::events::normalizer::bind_native_view(&webtag, webview.native_view_id());
3213    webviews.insert(webtag.key().to_string(), webview);
3214    true
3215}
3216
3217/// Find WebView by WebTag.
3218pub(crate) fn find_webview(webtag: &WebTag) -> Option<Arc<WebView>> {
3219    if let Some(instances) = WEBVIEW_INSTANCES.get() {
3220        if let Ok(webviews) = instances.lock() {
3221            webviews.get(webtag.key()).cloned()
3222        } else {
3223            None
3224        }
3225    } else {
3226        None
3227    }
3228}
3229
3230/// Resolve a logical WebTag only while it still names the native instance
3231/// which registered the callback. This prevents a late callback from a
3232/// destroyed WebView from being delivered to a replacement that reused its
3233/// tag.
3234// This is consumed by conditionally compiled platform callback adapters.
3235#[allow(dead_code)]
3236pub(crate) fn find_webview_by_native_view_id(
3237    webtag: &WebTag,
3238    native_view_id: NativeWebViewId,
3239) -> Option<Arc<WebView>> {
3240    find_webview(webtag).filter(|webview| webview.native_view_id() == native_view_id)
3241}
3242
3243#[cfg(target_os = "windows")]
3244pub(crate) fn first_browser_webview() -> Option<Arc<WebView>> {
3245    WEBVIEW_INSTANCES
3246        .get()
3247        .and_then(|instances| instances.lock().ok())
3248        .and_then(|webviews| {
3249            webviews
3250                .values()
3251                .find(|webview| {
3252                    webview.effective_options.profile == SecurityProfile::BrowserRelaxed
3253                })
3254                .cloned()
3255        })
3256}
3257
3258pub(crate) fn list_webviews() -> Vec<WebTag> {
3259    if let Some(instances) = WEBVIEW_INSTANCES.get()
3260        && let Ok(webviews) = instances.lock()
3261    {
3262        let mut tags: Vec<WebTag> = webviews.values().map(|webview| webview.webtag()).collect();
3263        tags.sort_by(|a, b| a.as_str().cmp(b.as_str()));
3264        return tags;
3265    }
3266    Vec::new()
3267}
3268
3269/// Resolve a delegate only while a callback's concrete native WebView still
3270/// owns the logical tag. A retired normalizer must never deliver its queued
3271/// output to a replacement delegate.
3272pub(crate) fn find_webview_delegate_by_native_view_id(
3273    webtag: &WebTag,
3274    native_view_id: NativeWebViewId,
3275) -> Option<Arc<dyn WebViewDelegate>> {
3276    find_webview_by_native_view_id(webtag, native_view_id)
3277        .and_then(|webview| webview.get_delegate())
3278}
3279
3280fn remove_arc_if_matches<T>(
3281    entries: &mut HashMap<String, Arc<T>>,
3282    key: &str,
3283    expected: &Arc<T>,
3284) -> Option<Arc<T>> {
3285    entries
3286        .get(key)
3287        .is_some_and(|current| Arc::ptr_eq(current, expected))
3288        .then(|| entries.remove(key))
3289        .flatten()
3290}
3291
3292/// Remove one ready WebView only while it is still the instance registered for
3293/// its tag. Tag-scoped callback and navigation state may already belong to a
3294/// newer create cycle and is left untouched; the session goes only when it is
3295/// provably this instance's.
3296pub(crate) fn destroy_webview_if_matches(webtag: &WebTag, expected: &Arc<WebView>) -> bool {
3297    // Close before touching the registry: a queued message must not cross the
3298    // remove-to-close window and become in-flight. Closing an already detached
3299    // expected instance is harmless and still rejects its late native callbacks.
3300    expected.close_message_ingress();
3301    let removed = if let Some(instances) = WEBVIEW_INSTANCES.get()
3302        && let Ok(mut webviews) = instances.lock()
3303    {
3304        remove_arc_if_matches(&mut webviews, webtag.key(), expected)
3305    } else {
3306        None
3307    };
3308    if let Some(webview) = removed {
3309        if let Some(signals) = remove_session_signals_owning(webtag, &webview) {
3310            signals.publish_destroyed();
3311        }
3312        #[cfg(target_os = "windows")]
3313        {
3314            let _ = webview.inner.set_content_visible(false);
3315            webview.inner.request_shutdown();
3316        }
3317        webview.remove_delegate();
3318        true
3319    } else {
3320        false
3321    }
3322}
3323
3324/// Destroy whichever WebView is currently registered for `webtag`.
3325///
3326/// This is intentionally tag-scoped host lifecycle behavior. Callers that
3327/// retain a concrete instance must use [`destroy_webview_if_matches`] instead.
3328pub(crate) fn destroy_current_webview(webtag: &WebTag) {
3329    // Close ingress before lifecycle notifications and native teardown. A
3330    // delivery already admitted under its own mutex may finish; queued and
3331    // future callbacks are rejected immediately.
3332    if let Some(webview) = find_webview(webtag) {
3333        webview.close_message_ingress();
3334    }
3335    // Drain active navigations as Cancelled(WebViewDestroyed) while the
3336    // delegate can still observe them, then drop the normalizer.
3337    crate::events::normalizer::destroy(webtag);
3338    // Mark the session destroyed FIRST. If a native create is still in flight
3339    // (built on the main thread but not yet registered), it observes this via
3340    // `WebViewCreateSender::is_destroyed()` after registering and tears the
3341    // instance back down — so a destroy that races ahead of registration can't
3342    // leave a zombie in the global registry.
3343    if let Some(signals) = remove_session_signals(webtag) {
3344        signals.publish_destroyed();
3345    }
3346    let removed = if let Some(instances) = WEBVIEW_INSTANCES.get()
3347        && let Ok(mut webviews) = instances.lock()
3348    {
3349        webviews.remove(webtag.key())
3350    } else {
3351        None
3352    };
3353    if let Some(webview) = removed {
3354        webview.close_message_ingress();
3355        // Windows composition teardown is asynchronous. Hide the controller
3356        // synchronously while it is still callable so a closed browser tab or
3357        // surface cannot leave its last composed frame over the replacement.
3358        #[cfg(target_os = "windows")]
3359        {
3360            let _ = webview.inner.set_content_visible(false);
3361            // Other owners can keep the retired Arc alive after it leaves the
3362            // registry. Stop its native thread now so a same-tag replacement
3363            // can safely begin instead of waiting for the final Arc to drop.
3364            webview.inner.request_shutdown();
3365        }
3366        webview.remove_delegate();
3367    }
3368    clear_pending_callbacks(webtag);
3369}
3370
3371#[cfg(test)]
3372mod tests {
3373    use super::{
3374        MAX_PENDING_WEB_MESSAGE_BYTES, MAX_PENDING_WEB_MESSAGES, MAX_WEB_MESSAGE_BYTES,
3375        PlatformConsoleBackend, PlatformConsoleDelivery, SecurityProfile, WEBVIEW_SESSIONS,
3376        WebMessageEnqueue, WebMessageIngress, WebMessageRejectReason, WebTag, WebViewCreateOptions,
3377        WebViewCreateSender, WebViewSessionSignals, block_on_scheme_future, document_port_context,
3378        next_native_webview_id, platform_console_delivery, remove_arc_if_matches,
3379        remove_session_signals_if_matches, replace_session_signals, should_sample_rejection,
3380        snapshot_web_message_context, web_message_bytes_within_limit,
3381        web_message_utf16_units_within_limit,
3382    };
3383    use crate::{
3384        ContextualSchemeRequest, DocumentBinding, IncomingWebMessage, NativeWebViewId,
3385        SchemeOutcome, SchemeRequestFrame, WebMessageContext, WebMessageFrame, WebMessageSource,
3386        WebMessageTransport,
3387    };
3388    use std::collections::HashMap;
3389    use std::sync::{Arc, Mutex, mpsc};
3390    use std::thread;
3391
3392    fn message(body: &str, native_view: crate::NativeWebViewId) -> IncomingWebMessage {
3393        IncomingWebMessage::new(
3394            body.to_string(),
3395            WebMessageContext::new(
3396                native_view,
3397                DocumentBinding::Unbound,
3398                WebMessageFrame::Unproven,
3399                WebMessageTransport::Other,
3400                WebMessageSource::unavailable(),
3401            ),
3402        )
3403    }
3404
3405    fn assert_browser_console_is_bound(backend: PlatformConsoleBackend) {
3406        assert_eq!(
3407            platform_console_delivery(SecurityProfile::BrowserRelaxed, backend),
3408            PlatformConsoleDelivery::RequiredV3Envelope
3409        );
3410        assert_eq!(
3411            platform_console_delivery(SecurityProfile::StrictDefault, backend),
3412            PlatformConsoleDelivery::DirectDelegate
3413        );
3414    }
3415
3416    #[test]
3417    fn apple_browser_console_requires_v3_envelope() {
3418        assert_browser_console_is_bound(PlatformConsoleBackend::Apple);
3419    }
3420
3421    #[test]
3422    fn android_browser_console_requires_v3_envelope() {
3423        assert_browser_console_is_bound(PlatformConsoleBackend::Android);
3424    }
3425
3426    #[test]
3427    fn harmony_browser_console_requires_v3_envelope() {
3428        assert_browser_console_is_bound(PlatformConsoleBackend::Harmony);
3429    }
3430
3431    #[test]
3432    fn windows_browser_console_requires_v3_envelope() {
3433        assert_browser_console_is_bound(PlatformConsoleBackend::Windows);
3434    }
3435
3436    #[test]
3437    fn native_webview_ids_are_not_reused_across_logical_tag_reuse() {
3438        let retired = next_native_webview_id();
3439        let replacement = next_native_webview_id();
3440        assert_ne!(retired, replacement);
3441
3442        let late_message = message("late", retired);
3443        assert_ne!(late_message.context().native_view(), replacement);
3444        assert_eq!(late_message.context().document(), DocumentBinding::Unbound);
3445    }
3446
3447    #[test]
3448    fn document_port_top_level_proof_requires_current_generation() {
3449        let native_view = next_native_webview_id();
3450        let current = crate::DocumentGeneration::new(42);
3451        let stale = crate::DocumentGeneration::new(41);
3452
3453        let context = document_port_context(
3454            native_view,
3455            DocumentBinding::Bound(current),
3456            current,
3457            WebMessageTransport::AndroidMessagePort,
3458            WebMessageSource::unavailable(),
3459        )
3460        .expect("the exact document port proves the top-level document");
3461        assert_eq!(context.frame(), WebMessageFrame::TopLevel);
3462        assert_eq!(context.document(), DocumentBinding::Bound(current));
3463
3464        assert!(
3465            document_port_context(
3466                native_view,
3467                DocumentBinding::Bound(current),
3468                stale,
3469                WebMessageTransport::AndroidMessagePort,
3470                WebMessageSource::unavailable(),
3471            )
3472            .is_none()
3473        );
3474        assert!(
3475            document_port_context(
3476                native_view,
3477                DocumentBinding::Bound(current),
3478                current,
3479                WebMessageTransport::AndroidJavascriptInterface,
3480                WebMessageSource::unavailable(),
3481            )
3482            .is_none()
3483        );
3484
3485        // Harmony hands out the same kind of one-shot, per-document port.
3486        assert_eq!(
3487            document_port_context(
3488                native_view,
3489                DocumentBinding::Bound(current),
3490                current,
3491                WebMessageTransport::HarmonyMessagePort,
3492                WebMessageSource::unavailable(),
3493            )
3494            .expect("a current Harmony port proves the top-level document")
3495            .frame(),
3496            WebMessageFrame::TopLevel
3497        );
3498        assert!(
3499            document_port_context(
3500                native_view,
3501                DocumentBinding::Bound(current),
3502                stale,
3503                WebMessageTransport::HarmonyMessagePort,
3504                WebMessageSource::unavailable(),
3505            )
3506            .is_none()
3507        );
3508    }
3509
3510    #[test]
3511    fn legacy_scheme_handler_runs_from_contextual_ingress() {
3512        let options = WebViewCreateOptions::strict().on_scheme("lx", |request| async move {
3513            assert_eq!(request.uri(), "lx://app/index.html");
3514            SchemeOutcome::PassThrough
3515        });
3516        let (_, callbacks) = options.normalize().unwrap();
3517        let handler = callbacks.scheme_handlers.get("lx").unwrap();
3518        let outcome = block_on_scheme_future(handler(ContextualSchemeRequest::new(
3519            http::Request::builder()
3520                .uri("lx://app/index.html")
3521                .body(Vec::new())
3522                .unwrap(),
3523            NativeWebViewId::new(101),
3524            SchemeRequestFrame::TopLevelDocument,
3525        )));
3526        assert!(matches!(outcome, SchemeOutcome::PassThrough));
3527    }
3528
3529    #[test]
3530    fn ingress_snapshots_document_binding_at_enqueue_time() {
3531        let webtag = WebTag::new("test-app", "binding-snapshot", Some(1));
3532        let native_view = next_native_webview_id();
3533        crate::events::normalizer::begin(&webtag, native_view);
3534        crate::events::normalizer::submit(
3535            &webtag,
3536            native_view,
3537            crate::events::normalizer::NativeSignal::NavigationStarted {
3538                key: Some(71),
3539                url: "https://first/".into(),
3540            },
3541        );
3542        crate::events::normalizer::submit(
3543            &webtag,
3544            native_view,
3545            crate::events::normalizer::NativeSignal::DocumentCommitted { key: Some(71) },
3546        );
3547
3548        let ingress = WebMessageIngress::default();
3549        let bound = IncomingWebMessage::new(
3550            "bound".to_string(),
3551            snapshot_web_message_context(
3552                native_view,
3553                WebMessageFrame::Unproven,
3554                WebMessageTransport::Other,
3555                WebMessageSource::unavailable(),
3556            ),
3557        );
3558        assert_eq!(
3559            bound.context().document(),
3560            DocumentBinding::Bound(crate::DocumentGeneration::new(1))
3561        );
3562        assert_eq!(ingress.enqueue(bound), WebMessageEnqueue::Schedule);
3563
3564        crate::events::normalizer::submit(
3565            &webtag,
3566            native_view,
3567            crate::events::normalizer::NativeSignal::NavigationStarted {
3568                key: Some(72),
3569                url: "https://second/".into(),
3570            },
3571        );
3572        let revoked = IncomingWebMessage::new(
3573            "revoked".to_string(),
3574            snapshot_web_message_context(
3575                native_view,
3576                WebMessageFrame::Unproven,
3577                WebMessageTransport::Other,
3578                WebMessageSource::unavailable(),
3579            ),
3580        );
3581        assert_eq!(revoked.context().document(), DocumentBinding::Unbound);
3582        assert_eq!(ingress.enqueue(revoked), WebMessageEnqueue::Queued);
3583
3584        let mut bindings = Vec::new();
3585        while let Some(message) = ingress.begin_delivery() {
3586            bindings.push(message.context().document());
3587            ingress.finish_delivery();
3588        }
3589        assert_eq!(
3590            bindings,
3591            vec![
3592                DocumentBinding::Bound(crate::DocumentGeneration::new(1)),
3593                DocumentBinding::Unbound,
3594            ]
3595        );
3596        crate::events::normalizer::destroy(&webtag);
3597    }
3598
3599    #[test]
3600    fn message_ingress_preserves_fifo_across_reentrant_enqueue() {
3601        let ingress = WebMessageIngress::default();
3602        let native_view = next_native_webview_id();
3603        let delivered = Mutex::new(Vec::new());
3604
3605        assert_eq!(
3606            ingress.enqueue(message("first", native_view)),
3607            WebMessageEnqueue::Schedule
3608        );
3609        while let Some(incoming) = ingress.begin_delivery() {
3610            let body = incoming.body().to_string();
3611            delivered.lock().unwrap().push(body.clone());
3612            if body == "first" {
3613                assert_eq!(
3614                    ingress.enqueue(message("second", native_view)),
3615                    WebMessageEnqueue::Queued
3616                );
3617                assert_eq!(
3618                    ingress.enqueue(message("third", native_view)),
3619                    WebMessageEnqueue::Queued
3620                );
3621            }
3622            ingress.finish_delivery();
3623        }
3624
3625        assert_eq!(*delivered.lock().unwrap(), vec!["first", "second", "third"]);
3626    }
3627
3628    #[test]
3629    fn message_ingress_bounds_untrusted_backlog_without_reordering_accepted_messages() {
3630        let ingress = WebMessageIngress::default();
3631        let native_view = next_native_webview_id();
3632        assert_eq!(
3633            ingress.enqueue(message("0", native_view)),
3634            WebMessageEnqueue::Schedule
3635        );
3636        for index in 1..MAX_PENDING_WEB_MESSAGES {
3637            assert_eq!(
3638                ingress.enqueue(message(&index.to_string(), native_view)),
3639                WebMessageEnqueue::Queued
3640            );
3641        }
3642        assert_eq!(
3643            ingress.enqueue(message("overflow", native_view)),
3644            WebMessageEnqueue::Rejected {
3645                reason: WebMessageRejectReason::QueueCountLimit,
3646                count: 1,
3647            }
3648        );
3649
3650        let mut accepted = Vec::new();
3651        while let Some(incoming) = ingress.begin_delivery() {
3652            accepted.push(incoming.body().to_owned());
3653            ingress.finish_delivery();
3654        }
3655        assert_eq!(accepted.len(), MAX_PENDING_WEB_MESSAGES);
3656        assert_eq!(accepted.first().map(String::as_str), Some("0"));
3657        let last = (MAX_PENDING_WEB_MESSAGES - 1).to_string();
3658        assert_eq!(accepted.last().map(String::as_str), Some(last.as_str()));
3659    }
3660
3661    #[test]
3662    fn message_ingress_rejects_an_oversized_message_before_queueing() {
3663        let ingress = WebMessageIngress::default();
3664        let native_view = next_native_webview_id();
3665        let oversized = "x".repeat(MAX_WEB_MESSAGE_BYTES + 1);
3666
3667        assert_eq!(
3668            ingress.enqueue(message(&oversized, native_view)),
3669            WebMessageEnqueue::Rejected {
3670                reason: WebMessageRejectReason::MessageTooLarge,
3671                count: 1,
3672            }
3673        );
3674        assert_eq!(ingress.queued_bytes(), 0);
3675        assert!(ingress.begin_delivery().is_none());
3676    }
3677
3678    #[test]
3679    fn raw_web_message_byte_cap_includes_the_exact_boundary() {
3680        assert!(web_message_bytes_within_limit(MAX_WEB_MESSAGE_BYTES - 1));
3681        assert!(web_message_bytes_within_limit(MAX_WEB_MESSAGE_BYTES));
3682        assert!(!web_message_bytes_within_limit(MAX_WEB_MESSAGE_BYTES + 1));
3683        assert!(web_message_utf16_units_within_limit(MAX_WEB_MESSAGE_BYTES));
3684        assert!(!web_message_utf16_units_within_limit(
3685            MAX_WEB_MESSAGE_BYTES + 1
3686        ));
3687        assert!(!web_message_bytes_within_limit(usize::MAX));
3688    }
3689
3690    #[test]
3691    fn message_ingress_byte_budget_is_released_on_delivery_and_close() {
3692        let ingress = WebMessageIngress::default();
3693        let native_view = next_native_webview_id();
3694        let chunk = "x".repeat(MAX_WEB_MESSAGE_BYTES);
3695        let accepted = MAX_PENDING_WEB_MESSAGE_BYTES / MAX_WEB_MESSAGE_BYTES;
3696
3697        for index in 0..accepted {
3698            assert_eq!(
3699                ingress.enqueue(message(&chunk, native_view)),
3700                if index == 0 {
3701                    WebMessageEnqueue::Schedule
3702                } else {
3703                    WebMessageEnqueue::Queued
3704                }
3705            );
3706        }
3707        assert_eq!(ingress.queued_bytes(), MAX_PENDING_WEB_MESSAGE_BYTES);
3708        assert_eq!(
3709            ingress.enqueue(message("overflow", native_view)),
3710            WebMessageEnqueue::Rejected {
3711                reason: WebMessageRejectReason::QueueByteLimit,
3712                count: 1,
3713            }
3714        );
3715
3716        let first = ingress.begin_delivery().expect("first queued message");
3717        assert_eq!(first.body().len(), MAX_WEB_MESSAGE_BYTES);
3718        assert_eq!(
3719            ingress.queued_bytes(),
3720            MAX_PENDING_WEB_MESSAGE_BYTES - MAX_WEB_MESSAGE_BYTES
3721        );
3722        ingress.finish_delivery();
3723        assert_eq!(
3724            ingress.enqueue(message(&chunk, native_view)),
3725            WebMessageEnqueue::Queued
3726        );
3727        ingress.close();
3728        assert_eq!(ingress.queued_bytes(), 0);
3729        assert!(ingress.begin_delivery().is_none());
3730    }
3731
3732    #[test]
3733    fn ingress_rejection_counters_are_reason_coded_and_logs_are_sampled() {
3734        let ingress = WebMessageIngress::default();
3735        let native_view = next_native_webview_id();
3736        let oversized = "x".repeat(MAX_WEB_MESSAGE_BYTES + 1);
3737        assert_eq!(
3738            ingress.reject(WebMessageRejectReason::MessageTooLarge),
3739            WebMessageEnqueue::Rejected {
3740                reason: WebMessageRejectReason::MessageTooLarge,
3741                count: 1,
3742            }
3743        );
3744        for expected_count in 2..=4 {
3745            assert_eq!(
3746                ingress.enqueue(message(&oversized, native_view)),
3747                WebMessageEnqueue::Rejected {
3748                    reason: WebMessageRejectReason::MessageTooLarge,
3749                    count: expected_count,
3750                }
3751            );
3752        }
3753        assert_eq!(
3754            ingress.rejection_count(WebMessageRejectReason::MessageTooLarge),
3755            4
3756        );
3757        assert!(should_sample_rejection(1));
3758        assert!(should_sample_rejection(2));
3759        assert!(!should_sample_rejection(3));
3760        assert!(should_sample_rejection(4));
3761    }
3762
3763    #[test]
3764    fn message_ingress_preserves_fifo_across_producer_threads() {
3765        let ingress = Arc::new(WebMessageIngress::default());
3766        let native_view = next_native_webview_id();
3767        let (a_to_b_tx, a_to_b_rx) = mpsc::channel();
3768        let (b_to_a_tx, b_to_a_rx) = mpsc::channel();
3769
3770        let a_ingress = Arc::clone(&ingress);
3771        let producer_a = thread::spawn(move || {
3772            assert_eq!(
3773                a_ingress.enqueue(message("0", native_view)),
3774                WebMessageEnqueue::Schedule
3775            );
3776            a_to_b_tx.send(()).unwrap();
3777            for value in (2..64).step_by(2) {
3778                b_to_a_rx.recv().unwrap();
3779                assert_eq!(
3780                    a_ingress.enqueue(message(&value.to_string(), native_view)),
3781                    WebMessageEnqueue::Queued
3782                );
3783                a_to_b_tx.send(()).unwrap();
3784            }
3785        });
3786
3787        let b_ingress = Arc::clone(&ingress);
3788        let producer_b = thread::spawn(move || {
3789            for value in (1..64).step_by(2) {
3790                a_to_b_rx.recv().unwrap();
3791                assert_eq!(
3792                    b_ingress.enqueue(message(&value.to_string(), native_view)),
3793                    WebMessageEnqueue::Queued
3794                );
3795                if value != 63 {
3796                    b_to_a_tx.send(()).unwrap();
3797                }
3798            }
3799        });
3800
3801        producer_a.join().unwrap();
3802        producer_b.join().unwrap();
3803
3804        let mut accepted = Vec::new();
3805        while let Some(incoming) = ingress.begin_delivery() {
3806            accepted.push(incoming.body().to_owned());
3807            ingress.finish_delivery();
3808        }
3809        assert_eq!(
3810            accepted,
3811            (0..64).map(|value| value.to_string()).collect::<Vec<_>>()
3812        );
3813    }
3814
3815    #[test]
3816    fn destroying_ingress_discards_queued_messages_but_allows_admitted_delivery() {
3817        let ingress = Arc::new(WebMessageIngress::default());
3818        let native_view = next_native_webview_id();
3819        assert_eq!(
3820            ingress.enqueue(message("in-flight", native_view)),
3821            WebMessageEnqueue::Schedule
3822        );
3823        assert_eq!(
3824            ingress.enqueue(message("queued", native_view)),
3825            WebMessageEnqueue::Queued
3826        );
3827
3828        let delivered = Mutex::new(Vec::new());
3829        let ingress_for_delivery = Arc::clone(&ingress);
3830        ingress.drain(|incoming| {
3831            delivered.lock().unwrap().push(incoming.body().to_owned());
3832            ingress_for_delivery.close();
3833            assert_eq!(
3834                ingress_for_delivery.enqueue(message("after-close", native_view)),
3835                WebMessageEnqueue::Rejected {
3836                    reason: WebMessageRejectReason::Closed,
3837                    count: 1,
3838                }
3839            );
3840        });
3841
3842        assert_eq!(*delivered.lock().unwrap(), vec!["in-flight"]);
3843        assert!(ingress.begin_delivery().is_none());
3844    }
3845
3846    #[test]
3847    fn closing_before_registry_removal_prevents_queued_message_admission() {
3848        let ingress = WebMessageIngress::default();
3849        let native_view = next_native_webview_id();
3850        assert_eq!(
3851            ingress.enqueue(message("queued", native_view)),
3852            WebMessageEnqueue::Schedule
3853        );
3854
3855        // This mirrors `destroy_webview_if_matches`: close is the destroy
3856        // linearization point and happens before the registry mutation.
3857        ingress.close();
3858
3859        assert!(ingress.begin_delivery().is_none());
3860        assert_eq!(
3861            ingress.enqueue(message("late", native_view)),
3862            WebMessageEnqueue::Rejected {
3863                reason: WebMessageRejectReason::Closed,
3864                count: 1,
3865            }
3866        );
3867    }
3868
3869    #[test]
3870    fn delegate_panic_does_not_stall_following_messages() {
3871        let ingress = WebMessageIngress::default();
3872        let native_view = next_native_webview_id();
3873        assert_eq!(
3874            ingress.enqueue(message("panic", native_view)),
3875            WebMessageEnqueue::Schedule
3876        );
3877        assert_eq!(
3878            ingress.enqueue(message("after", native_view)),
3879            WebMessageEnqueue::Queued
3880        );
3881
3882        let delivered = Mutex::new(Vec::new());
3883        ingress.drain(|incoming| {
3884            if incoming.body() == "panic" {
3885                panic!("test delegate panic");
3886            }
3887            delivered.lock().unwrap().push(incoming.body().to_owned());
3888        });
3889
3890        assert_eq!(*delivered.lock().unwrap(), vec!["after"]);
3891        assert_eq!(
3892            ingress.enqueue(message("recovered", native_view)),
3893            WebMessageEnqueue::Schedule
3894        );
3895    }
3896
3897    #[test]
3898    fn conditional_instance_removal_uses_arc_identity() {
3899        let current = Arc::new(7_u8);
3900        let same_value_different_instance = Arc::new(7_u8);
3901        let mut entries = HashMap::from([("tab".to_string(), current.clone())]);
3902
3903        assert!(
3904            remove_arc_if_matches(&mut entries, "tab", &same_value_different_instance).is_none()
3905        );
3906        assert!(Arc::ptr_eq(entries.get("tab").unwrap(), &current));
3907
3908        let removed = remove_arc_if_matches(&mut entries, "tab", &current).unwrap();
3909        assert!(Arc::ptr_eq(&removed, &current));
3910        assert!(!entries.contains_key("tab"));
3911    }
3912
3913    #[test]
3914    fn superseded_sender_completes_without_removing_current_generation() {
3915        let webtag = WebTag::from("test:pages/superseded#9173");
3916        let superseded = WebViewSessionSignals::new();
3917        let current = WebViewSessionSignals::new();
3918        replace_session_signals(&webtag, current.clone());
3919
3920        WebViewCreateSender::new(webtag.clone(), superseded.clone()).cancel_superseded();
3921
3922        assert!(
3923            superseded
3924                .terminal_result()
3925                .is_some_and(|result| result.is_err())
3926        );
3927        assert!(current.terminal_result().is_none());
3928        let sessions = WEBVIEW_SESSIONS.get().unwrap().lock().unwrap();
3929        assert!(
3930            sessions
3931                .get(webtag.key())
3932                .is_some_and(|signals| Arc::ptr_eq(signals, &current))
3933        );
3934        drop(sessions);
3935        assert!(remove_session_signals_if_matches(&webtag, &current));
3936    }
3937}