Skip to main content

day_piece_webview/
lib.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! day-piece-webview, an external Day Piece (DESIGN.md §15) wrapping each toolkit's native web
5//! view: WKWebView on AppKit/UIKit, QWebEngineView on Qt, `android.webkit.WebView` on Android. One
6//! Rust API registered link-time into each backend's renderer slice without touching day. Alongside
7//! the picker it's a reference for pieces that carry both a front-end and their own native backend,
8//! here including an Android manifest permission contribution (INTERNET), see docs/extending.md.
9//!
10//! The view is a growing leaf that fills its space. Navigation is imperative and modeled with
11//! `Copy` `Trigger`s (`.go()` loads the bound URL, `.back()`/`.forward()`/`.stop()`/`.reload()`
12//! drive history), each `watch`ed to a `WebPatch`. The bound URL is two-way: `.go()` loads it, and
13//! native navigation reports the current URL back so a bound text field follows along.
14
15#[cfg(feature = "dom")]
16mod browser;
17
18use std::cell::{Cell, RefCell};
19use std::collections::HashMap;
20use std::future::Future;
21use std::pin::Pin;
22use std::rc::Rc;
23use std::task::Poll;
24
25use day_core::{BuildCx, Flex, Piece, RNode};
26use day_pieces::{Decorated, IntoText};
27use day_reactive::{AnyBinding, Binding, Signal, Trigger, watch};
28use day_spec::Event;
29
30pub mod renderer;
31mod resources;
32pub use resources::{ResourceProvider, ResourceRequest, ResourceResponse};
33
34#[cfg(all(
35    any(target_os = "macos", target_os = "ios"),
36    any(feature = "appkit", feature = "uikit", feature = "gtk")
37))]
38mod resources_apple;
39
40#[cfg(all(feature = "arkui", target_env = "ohos"))]
41mod resources_arkui;
42
43#[cfg(any(test, all(feature = "arkui", target_env = "ohos")))]
44mod resource_content_type;
45
46/// Ordinary documents opt in through `on_external_link`. Loading a document, reloading,
47/// fragment navigation, and embedded frames must not be mistaken for an outbound click.
48#[cfg(any(
49    test,
50    all(
51        any(target_os = "macos", target_os = "ios"),
52        any(feature = "appkit", feature = "uikit", feature = "gtk")
53    )
54))]
55fn external_document_link(
56    current: Option<&str>,
57    target: &str,
58    activated: bool,
59    new_window: bool,
60    subframe: bool,
61) -> bool {
62    if subframe || target.is_empty() || target == "about:blank" || !(activated || new_window) {
63        return false;
64    }
65    let without_fragment = |url: &str| url.split('#').next().unwrap_or("").to_owned();
66    !current.is_some_and(|url| without_fragment(url) == without_fragment(target))
67}
68
69pub const KIND: &str = "day.piece.webview";
70
71/// Storage shared by independent views. A private profile never shares persistent data.
72/// Configure before creating views. Directory is honored where the engine exposes a public
73/// storage-path API; Apple uses an identifier-backed, system-managed location instead.
74#[derive(Clone, Debug, Default, PartialEq, Eq)]
75pub struct WebProfile {
76    pub name: String,
77    pub directory: Option<String>,
78    pub private: bool,
79}
80impl WebProfile {
81    pub fn persistent(name: impl Into<String>) -> Self {
82        Self {
83            name: name.into(),
84            ..Self::default()
85        }
86    }
87    pub fn private(name: impl Into<String>) -> Self {
88        Self {
89            name: name.into(),
90            private: true,
91            directory: None,
92        }
93    }
94    pub fn directory(mut self, path: impl Into<String>) -> Self {
95        self.directory = Some(path.into());
96        self
97    }
98    /// Stable, filesystem-safe identity. Includes privacy mode and requested directory.
99    pub fn storage_key(&self) -> String {
100        let text = format!("{}:{:?}:{}", self.name, self.directory, self.private);
101        let hash = text.bytes().fold(14695981039346656037u64, |h, b| {
102            (h ^ u64::from(b)).wrapping_mul(1099511628211)
103        });
104        format!("day-{hash:016x}")
105    }
106}
107/// Whether an engine can guarantee an ephemeral, isolated data store. Unsupported private
108/// requests are blocked before loading anything; deleting data afterward is not private mode.
109pub fn private_browsing_support() -> day_spec::Support {
110    if cfg!(any(
111        all(feature = "material", target_os = "android"),
112        all(feature = "dom", target_arch = "wasm32")
113    )) {
114        day_spec::Support::Unsupported
115    } else {
116        support()
117    }
118}
119/// Native cookie URL filtering, shared by engines that expose a whole cookie jar.
120#[allow(dead_code)]
121pub(crate) fn cookie_matches(url: &str, domain: &str, path: &str, secure: bool) -> bool {
122    let Ok(url) = url::Url::parse(url) else {
123        return false;
124    };
125    if !matches!(url.scheme(), "http" | "https") || (secure && url.scheme() != "https") {
126        return false;
127    }
128    let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
129    let domain = domain.to_ascii_lowercase();
130    let bare = domain.trim_start_matches('.');
131    let domain_match =
132        host == bare || (domain.starts_with('.') && host.ends_with(&format!(".{bare}")));
133    let path = if path.is_empty() { "/" } else { path };
134    domain_match
135        && (url.path() == path
136            || (url.path().starts_with(path)
137                && (path.ends_with('/') || url.path().as_bytes().get(path.len()) == Some(&b'/'))))
138}
139/// Validate the response origin before native APIs (some accept arbitrary cookie domains).
140fn cookie_source_valid(source: &str, header: &str) -> bool {
141    if header.contains(['\r', '\n']) {
142        return false;
143    }
144    let Ok(url) = url::Url::parse(source) else {
145        return false;
146    };
147    if !matches!(url.scheme(), "http" | "https") {
148        return false;
149    }
150    let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
151    let mut fields = header.split(';');
152    let Some((name, _)) = fields.next().and_then(|field| field.trim().split_once('=')) else {
153        return false;
154    };
155    let mut secure = false;
156    let mut domain_set = false;
157    let mut root_path = false;
158    for field in fields {
159        let (key, value) = field.trim().split_once('=').unwrap_or((field.trim(), ""));
160        if key.eq_ignore_ascii_case("domain") {
161            domain_set = true;
162            let domain = value.trim().trim_start_matches('.').to_ascii_lowercase();
163            if psl::suffix(domain.as_bytes())
164                .is_some_and(|suffix| suffix.is_known() && suffix.as_bytes() == domain.as_bytes())
165            {
166                return false;
167            }
168            if domain.is_empty()
169                || !(host == domain
170                    || (url::Host::parse(&host).is_ok_and(|h| matches!(h, url::Host::Domain(_)))
171                        && host.ends_with(&format!(".{domain}"))))
172            {
173                return false;
174            }
175        } else if key.eq_ignore_ascii_case("secure") {
176            secure = true;
177        } else if key.eq_ignore_ascii_case("path") {
178            root_path = value.trim() == "/";
179        }
180    }
181    if secure && url.scheme() != "https" {
182        return false;
183    }
184    if name.starts_with("__Secure-") && !secure {
185        return false;
186    }
187    if name.starts_with("__Host-") && (!secure || domain_set || !root_path) {
188        return false;
189    }
190    true
191}
192#[cfg(any(
193    all(any(feature = "appkit", feature = "gtk"), target_os = "macos"),
194    all(feature = "uikit", target_os = "ios")
195))]
196mod storage_apple;
197/// Named profile isolation. ArkWeb exposes app-wide stores only; its default and private
198/// contexts share data by origin, but do not implement independent named profiles.
199pub fn profile_support() -> day_spec::Support {
200    #[cfg(all(feature = "material", target_os = "android"))]
201    let result = android_impl::capability("supportsProfiles");
202    #[cfg(not(all(feature = "material", target_os = "android")))]
203    let result = {
204        #[cfg(any(
205            all(any(feature = "appkit", feature = "gtk"), target_os = "macos"),
206            all(feature = "uikit", target_os = "ios")
207        ))]
208        if !storage_apple::named_profiles_available() {
209            return day_spec::Support::Emulated;
210        }
211        if cfg!(all(feature = "arkui", target_env = "ohos")) {
212            day_spec::Support::Emulated
213        } else if cfg!(all(feature = "dom", target_arch = "wasm32")) {
214            day_spec::Support::Unsupported
215        } else {
216            support()
217        }
218    };
219    result
220}
221/// Complete deletion of a profile's cookie, cache and persistent site data.
222pub fn clear_data_support() -> day_spec::Support {
223    #[cfg(all(feature = "material", target_os = "android"))]
224    let result = android_impl::capability("supportsDataClearing");
225    #[cfg(not(all(feature = "material", target_os = "android")))]
226    let result = if cfg!(any(
227        all(feature = "arkui", target_env = "ohos"),
228        all(feature = "dom", target_arch = "wasm32")
229    )) {
230        day_spec::Support::Unsupported
231    } else {
232        support()
233    };
234    result
235}
236
237const DATA_REQUEST: &str = "day-web-data:";
238
239/// Full props (realize). The initial `url` is loaded when the native view is created.
240#[derive(Clone, Debug, Default, PartialEq)]
241pub struct WebProps {
242    pub renderer: WebRenderer,
243    pub profile: WebProfile,
244    pub resources: Option<ResourceProvider>,
245    /// On Apple backends, report user-activated links in ordinary documents as well.
246    pub external_links: bool,
247    pub tabs: Option<TabLinkLabels>,
248    pub url: String,
249    /// The [`WebSession`] this view belongs to, or `0` for none. A non-zero id asks the backend to
250    /// hand back the session's existing native view instead of creating one, so the loaded page
251    /// survives being navigated away from (docs/webview.md).
252    pub session: u64,
253    /// Inline mode (docs/webview.md): the `/`-relative `resource/assets/` path of the bundled
254    /// site's root directory, or `""` for remote mode. The arm resolves it to its native
255    /// browsable base, loads `<base>/<inline_start>`, and cancels+reports navigations that
256    /// leave the site (`Event::Custom` with `num` = the link-report tag).
257    pub inline_root: String,
258    /// The start page within `inline_root` (`"index.html"` unless overridden), which may carry a
259    /// query or fragment (`"player.html?src=hello"`). Empty in remote mode.
260    pub inline_start: String,
261    /// Inline mode: the page may read the app's whole `resource/assets/` tree rather than only
262    /// the site's own directory ([`WebView::app_assets`]). Backends whose browsable base is the
263    /// asset tree already (Qt's qrc, Android's `file:///android_asset/`, WebView2's virtual host,
264    /// the deployed `assets/data/` on web-dom) read the same either way; it is WebKit's file-URL
265    /// read access and the GTK cache extraction that narrow to the site without it.
266    pub inline_assets: bool,
267    /// Draw no background of the view's own, so a page whose own background is transparent shows
268    /// the app behind it ([`WebView::transparent`]). A view normally paints white under the page.
269    pub transparent: bool,
270}
271
272/// A retained browsing session: the thing that outlives the view showing it.
273///
274/// Day rebuilds a page's whole subtree on every navigation, so a plain `web_view` gets a brand-new
275/// native view each visit and reloads from scratch. A session moves the *engine* out of that
276/// lifetime: the piece keeps the native web view alive against this id, and a later `web_view`
277/// bound to the same session re-attaches it with its page, scroll position, history and JavaScript
278/// context intact.
279///
280/// This is the shape Apple settled on for the same problem (`WebPage` holds the session and
281/// `WebView` renders it), and the reason it works is the same: a web view's content lives in the
282/// object and its content process, not in its attachment to a parent view.
283///
284/// Sessions are keyed by a `&'static str`, so [`WebSession::global`] is idempotent and safe to
285/// call from a page function that runs again on every navigation. There is no way to mint an
286/// anonymous one: an id that changed per build would retain a new view each visit and leak them
287/// all.
288///
289/// The retained view is never freed: one session is one live web view for the process's lifetime.
290/// Use them for pages a user returns to, not per-item.
291#[derive(Clone, Copy, PartialEq, Eq, Debug)]
292pub struct WebSession(u64);
293
294impl WebSession {
295    /// The session named `key`, created on first use. Calling this repeatedly with the same key
296    /// returns the same session.
297    pub fn global(key: &'static str) -> WebSession {
298        day_core::tls_group! {
299            static KEYS: RefCell<HashMap<&'static str, u64>> = RefCell::new(HashMap::new());
300            static NEXT: Cell<u64> = const { Cell::new(1) };
301        }
302        WebSession(KEYS.with(|m| {
303            *m.borrow_mut().entry(key).or_insert_with(|| {
304                NEXT.with(|c| {
305                    let v = c.get();
306                    c.set(v + 1);
307                    v
308                })
309            })
310        }))
311    }
312
313    /// The raw id, as it reaches a backend arm through [`WebProps::session`].
314    pub fn id(self) -> u64 {
315        self.0
316    }
317}
318
319// ---------------------------------------------------------------------------
320// Inline (app-embedded) sites (docs/webview.md). A directory under `resource/assets/` ships a
321// whole site (html/css/js/images, structure preserved, §18.5); the view loads it through the
322// backend's own local-content channel (a file URL into the bundle on Apple,
323// `file:///android_asset/` on Android, the same-origin `assets/data/` URL on web-dom), so the
324// page's relative references resolve natively. Navigations that leave the site are cancelled
325// in-view and dispatched per [`LinkPolicy`], the system browser by default.
326// ---------------------------------------------------------------------------
327
328/// The `num` the arms tag an external-link report with on the shared `Event::Custom` channel:
329/// navigation reports are `0`, eval replies are `≥ 1`, link reports are this.
330const LINK_REPORT: f64 = -1.0;
331
332/// What to do with a navigation that leaves an inline site: the answer an
333/// [`WebView::on_external_link`] handler returns. Without a handler, every external link is
334/// [`LinkPolicy::OpenSystem`].
335#[derive(Clone, Copy, Debug, PartialEq, Eq)]
336pub enum LinkPolicy {
337    /// Hand the URL to the operating system (`day`'s `open_url`): the default browser for
338    /// `https://`, the mail client for `mailto:`, whatever the OS maps the scheme to.
339    OpenSystem,
340    /// Let the navigation proceed inside the web view after all.
341    InView,
342    /// Swallow it. The handler has already done whatever the link means in-app (navigate the
343    /// day app, record it, show UI).
344    Ignore,
345}
346
347/// Split a start page into its path and the query/fragment behind it (`"player.html?src=hello"`
348/// → `("player.html", "?src=hello")`). The arms that browse a site by URL keep the page whole;
349/// the two that build a file URL from a path need the tail back, because `fileURLWithPath:`
350/// would percent-encode `?` and `#` into the file name.
351// Only the two file-URL arms call this; the test below covers it in every build.
352#[allow(dead_code)]
353pub(crate) fn split_page(page: &str) -> (&str, &str) {
354    match page.find(['?', '#']) {
355        Some(i) => page.split_at(i),
356        None => (page, ""),
357    }
358}
359
360/// A bundled site ready for [`web_view_inline`], the marker [`AssetDirSiteExt::prepare_site`]
361/// resolves to (or an unchecked one via `From`-style [`IntoInlineSite`] on the raw directory).
362#[derive(Clone, Debug, PartialEq, Eq)]
363pub struct InlineSite {
364    root: String,
365}
366
367impl InlineSite {
368    /// The site root's `/`-relative path under `resource/assets/`.
369    pub fn root(&self) -> &str {
370        &self.root
371    }
372}
373
374/// Why [`AssetDirSiteExt::prepare_site`] declined the directory.
375#[derive(Clone, Debug, PartialEq, Eq)]
376pub enum PrepareError {
377    /// `<root>/index.html` is not among the bundled assets.
378    MissingIndex(String),
379    /// A backend that serves inline sites from extracted files (GTK today) failed to extract
380    /// the tree to the platform cache.
381    Extract(String),
382}
383
384impl std::fmt::Display for PrepareError {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        match self {
387            PrepareError::MissingIndex(root) => {
388                write!(f, "no index.html under resource/assets/{root}")
389            }
390            PrepareError::Extract(e) => write!(f, "extracting the inline site: {e}"),
391        }
392    }
393}
394
395/// The future [`AssetDirSiteExt::prepare_site`] returns. Today every v1 backend serves the site
396/// from where it already lives, so this resolves on first poll; the shape is a future because
397/// backends whose engine cannot read embedded stores in place (GTK's GResource, HarmonyOS
398/// rawfile) will extract to the platform cache here, and a large site should not block the UI
399/// thread when they land.
400pub struct PrepareSite {
401    result: Option<Result<InlineSite, PrepareError>>,
402}
403
404impl Future for PrepareSite {
405    type Output = Result<InlineSite, PrepareError>;
406    fn poll(mut self: Pin<&mut Self>, _cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
407        match self.result.take() {
408            Some(r) => Poll::Ready(r),
409            // Fused: polled again after completion (which day's task runner never does).
410            None => Poll::Pending,
411        }
412    }
413}
414
415/// `res::assets::<dir>.prepare_site()`: validate and prepare a bundled directory as an inline
416/// site (docs/webview.md).
417pub trait AssetDirSiteExt {
418    fn prepare_site(self) -> PrepareSite;
419}
420
421impl AssetDirSiteExt for day_core::AssetDir {
422    fn prepare_site(self) -> PrepareSite {
423        let root = self.as_str().trim_matches('/').to_string();
424        // web-dom has no resource opener (the browser fetches the served tree directly), so the
425        // index probe is skipped there and a missing index surfaces as the frame's own 404.
426        #[cfg(target_arch = "wasm32")]
427        let result = Ok(InlineSite { root });
428        #[cfg(not(target_arch = "wasm32"))]
429        let result = {
430            let index = day_spec::AssetName::dynamic(format!("{root}/index.html"));
431            match day_spec::resource(index) {
432                Some(_) => prepare_backend(root),
433                None => Err(PrepareError::MissingIndex(root)),
434            }
435        };
436        PrepareSite {
437            result: Some(result),
438        }
439    }
440}
441
442/// The per-backend half of `prepare_site`: where a backend whose engine cannot read the
443/// embedded store in place gets the site onto loose files ahead of the view (docs/webview.md).
444#[cfg(not(target_arch = "wasm32"))]
445fn prepare_backend(root: String) -> Result<InlineSite, PrepareError> {
446    // GTK: the assets live in a GResource blob WebKitGTK cannot browse; extract to the user
447    // cache now so `make` finds it warm. The lazy `web_view_inline(dir)` route extracts at
448    // realize instead.
449    #[cfg(all(feature = "gtk", not(target_os = "macos"), not(windows)))]
450    if let Err(e) = gtk_impl::extract_site(&root) {
451        return Err(PrepareError::Extract(e));
452    }
453    Ok(InlineSite { root })
454}
455
456/// What [`web_view_inline`] accepts: a prepared [`InlineSite`], or the raw generated
457/// `res::assets::…` directory constant for the lazy path (no ahead-of-time index check; a
458/// missing page surfaces in the view itself).
459pub trait IntoInlineSite {
460    fn into_inline_site(self) -> InlineSite;
461}
462
463impl IntoInlineSite for InlineSite {
464    fn into_inline_site(self) -> InlineSite {
465        self
466    }
467}
468
469impl IntoInlineSite for day_core::AssetDir {
470    fn into_inline_site(self) -> InlineSite {
471        InlineSite {
472            root: self.as_str().trim_matches('/').to_string(),
473        }
474    }
475}
476
477/// Whether this backend can show an inline (app-embedded) site. A separate axis from
478/// [`support`]: web-dom's iframe is `Emulated` for remote browsing but fully capable here:
479/// the bundled site is same-origin, so loading, relative navigation and the link policy all
480/// work. `Unsupported` means no renderer is compiled for this target.
481pub fn inline_support() -> day_spec::Support {
482    if cfg!(any(
483        all(
484            any(feature = "appkit", feature = "gtk"),
485            target_os = "macos"
486        ),
487        all(feature = "uikit", target_os = "ios"),
488        all(feature = "material", target_os = "android"),
489        // QWebEngine reads qrc assets.
490        feature = "qt",
491        // WebView2 browses the exe-relative assets tree through a virtual-host mapping
492        // (degrades to the URL label when the WebView2 Runtime is absent, like `support()`).
493        all(feature = "xaml", windows),
494        // GTK hosts extract the bundled site before navigation.
495        all(feature = "gtk", not(target_os = "macos")),
496        // ArkWeb browses the rawfile-staged tree through `resource://rawfile/` URLs.
497        all(feature = "arkui", target_env = "ohos"),
498        // The deployed `assets/data/` tree is same-origin with the host page: the browser
499        // resolves the site natively and the shim's click hook polices leaving links.
500        all(feature = "dom", target_arch = "wasm32"),
501    )) {
502        day_spec::Support::Native
503    } else {
504        day_spec::Support::Unsupported
505    }
506}
507
508/// Native cache-bypassing reload. Cross-origin iframe hosts cannot force cache policy.
509pub fn force_reload_support() -> day_spec::Support {
510    if cfg!(all(feature = "dom", target_arch = "wasm32")) {
511        day_spec::Support::Unsupported
512    } else {
513        support()
514    }
515}
516
517/// Sparse imperative commands sent to the native view after creation.
518#[derive(Clone, Debug, PartialEq)]
519pub enum WebPatch {
520    /// Load a URL (from `.go()`).
521    Load(String),
522    /// History back / forward.
523    Back,
524    Forward,
525    /// Stop the in-flight load (the demo's "cancel").
526    Stop,
527    /// Reload the current page.
528    Reload,
529    /// Reload while bypassing the engine resource cache (never clears cookies or site data).
530    ForceReload,
531    /// Evaluate `script` (already wrapped by [`wrap_script`]) and report the result back as an
532    /// `Event::Custom` whose `num` is `req`. See docs/webview-eval.md.
533    Eval {
534        req: u64,
535        script: String,
536    },
537}
538
539// ---------------------------------------------------------------------------
540// JavaScript evaluation (docs/webview-eval.md)
541// ---------------------------------------------------------------------------
542
543/// Field separator inside an evaluation reply. A raw 0x1F can never appear inside JSON text
544/// (`JSON.stringify` escapes control characters as the six ASCII chars `\u001f`), so splitting on
545/// it is unambiguous and needs no JSON parser on the Rust side.
546const SEP: char = '\u{1f}';
547
548/// Why an evaluation did not produce a value.
549#[derive(Clone, Debug, PartialEq, Eq)]
550pub enum EvalError {
551    /// This backend has no engine to evaluate in (web-dom's `<iframe>`, or no renderer at all).
552    Unsupported,
553    /// The script threw. `name`/`message` come from the caught exception; a cyclic value that
554    /// `JSON.stringify` refuses arrives here too, as a `TypeError`.
555    Threw { name: String, message: String },
556    /// The web view was never realized, or went away before the reply arrived.
557    ViewGone,
558    /// The engine ran but the reply did not decode; the raw payload is included.
559    Engine(String),
560}
561
562impl std::fmt::Display for EvalError {
563    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564        match self {
565            EvalError::Unsupported => write!(f, "javascript evaluation is unsupported here"),
566            EvalError::Threw { name, message } => write!(f, "{name}: {message}"),
567            EvalError::ViewGone => write!(f, "the web view is gone"),
568            EvalError::Engine(raw) => write!(f, "undecodable reply: {raw}"),
569        }
570    }
571}
572
573/// Escape `s` into a JavaScript string literal, quotes included.
574///
575/// U+2028 and U+2029 get named escapes because they are literal line terminators in JS source and
576/// would end the string; everything below U+0020 goes to `\uXXXX` because a raw control character
577/// is not legal inside a literal either.
578fn js_string_literal(s: &str) -> String {
579    let mut out = String::with_capacity(s.len() + 2);
580    out.push('"');
581    for c in s.chars() {
582        match c {
583            '"' => out.push_str("\\\""),
584            '\\' => out.push_str("\\\\"),
585            '\n' => out.push_str("\\n"),
586            '\r' => out.push_str("\\r"),
587            '\t' => out.push_str("\\t"),
588            '\u{2028}' => out.push_str("\\u2028"),
589            '\u{2029}' => out.push_str("\\u2029"),
590            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
591            c => out.push(c),
592        }
593    }
594    out.push('"');
595    out
596}
597
598/// Wrap a user script so every backend reports errors the same way.
599///
600/// Qt and Android have no error channel at all (a throw, a syntax error and a `null` result all
601/// arrive identically), so the channel is built in JavaScript instead, and every backend then
602/// behaves like the ones with real errors. The reply is `1␟<json>` or `0␟<name>␟<message>`.
603///
604/// The script is passed to `eval` as a **string literal** rather than spliced in as source. That
605/// costs one escape pass and buys three things splicing cannot:
606///
607/// - **Syntax errors become catchable.** Spliced in, the wrapper and the script compile as one
608///   unit, so a bad script kills the wrapper too and the backend reports its uninformative
609///   "no result". `eval` compiles at run time, inside the `try`.
610/// - **Statements work.** `throw new Error("x")` and `var a = 1; a + 1` are statements, so
611///   `v = (…)` around them is itself a syntax error. `eval` takes a program and yields its
612///   completion value, which is the behavior a console user expects.
613/// - **No lexical hazards.** A trailing `//` comment or an unbalanced brace in the script cannot
614///   reach the wrapper's own tokens.
615///
616/// The cost is that a page whose Content-Security-Policy omits `unsafe-eval` refuses to run it;
617/// that surfaces as a caught `EvalError`, which is at least legible.
618///
619/// Two `try` blocks, not one: the second catches `JSON.stringify` refusing a cyclic value, which is
620/// a different failure from the script throwing.
621fn wrap_script(script: &str) -> String {
622    let src = js_string_literal(script);
623    format!(
624        "(function(){{var v;\
625         try{{v=eval({src});}}\
626         catch(e){{return \"0\\u001f\"+((e&&e.name)||\"Error\")+\"\\u001f\"+((e&&e.message)||String(e));}}\
627         try{{var s=JSON.stringify(v);return \"1\\u001f\"+(s===undefined?\"null\":s);}}\
628         catch(e){{return \"0\\u001f\"+((e&&e.name)||\"TypeError\")+\"\\u001f\"+((e&&e.message)||String(e));}}\
629         }})()"
630    )
631}
632
633/// Build a reply an arm can send when the engine failed rather than the script: a dead content
634/// process, a missing web view, a reply of the wrong type. Shaped like the wrapper's own error arm
635/// so [`decode`] needs only one format.
636// Used by the per-backend arms, each of which is `#[cfg]`-gated to one toolkit, so on any single
637// build all but one caller is compiled out, and on a build whose backend has no eval arm yet there
638// are none. Which callers exist is a build-configuration accident, not a sign this is unused.
639#[allow(dead_code)]
640pub(crate) fn engine_error(name: &str, message: &str) -> String {
641    format!("0{SEP}{name}{SEP}{message}")
642}
643
644/// Decode one reply produced by [`wrap_script`]. `undefined` and values `JSON.stringify` drops
645/// (a function, a symbol) both arrive as `null`; the wrapper normalizes them so the payload is
646/// always valid JSON.
647fn decode(payload: &str) -> Result<String, EvalError> {
648    match payload.split_once(SEP) {
649        Some(("1", json)) => Ok(json.to_string()),
650        Some(("0", rest)) => {
651            let (name, message) = rest.split_once(SEP).unwrap_or(("Error", rest));
652            Err(EvalError::Threw {
653                name: name.to_string(),
654                message: message.to_string(),
655            })
656        }
657        _ => Err(EvalError::Engine(payload.to_string())),
658    }
659}
660
661struct EvalShared {
662    result: RefCell<Option<Result<String, EvalError>>>,
663    waker: RefCell<Option<std::task::Waker>>,
664}
665
666day_core::tls_group! {
667    /// Request ids start at 1 so 0 stays free for the URL report, which shares this channel.
668    static PRIVATE_BLOCKED_NODES: RefCell<std::collections::HashSet<RNode>> = RefCell::new(std::collections::HashSet::new());
669    static REQUEST_NODES: RefCell<HashMap<u64, RNode>> = RefCell::new(HashMap::new());
670    static NEXT_REQ: Cell<u64> = const { Cell::new(1) };
671    static PENDING: RefCell<HashMap<u64, Rc<EvalShared>>> = RefCell::new(HashMap::new());
672    /// Callback-shaped requests (the dayscript `web_eval` step, via day-core's
673    /// `register_piece_operation`): same req space and reply channel as the futures above,
674    /// different completion shape.
675    static PENDING_CB: RefCell<HashMap<u64, day_core::PieceOperationDone>> =
676        RefCell::new(HashMap::new());
677}
678
679/// Deliver a reply to whichever request is waiting on `req`: an awaited [`EvalFuture`] or a
680/// `web_eval` step callback. A reply for a dropped future finds nothing pending and is
681/// discarded.
682fn resolve(req: u64, payload: &str) {
683    REQUEST_NODES.with(|p| p.borrow_mut().remove(&req));
684    if let Some(done) = PENDING_CB.with(|p| p.borrow_mut().remove(&req)) {
685        done(decode(payload).map_err(|e| e.to_string()));
686        return;
687    }
688    let Some(shared) = PENDING.with(|p| p.borrow_mut().remove(&req)) else {
689        return;
690    };
691    *shared.result.borrow_mut() = Some(decode(payload));
692    if let Some(waker) = shared.waker.borrow_mut().take() {
693        waker.wake();
694    }
695}
696
697/// Register this piece's evaluator with day-core, for the dayscript `web_eval` step
698/// (docs/webview-eval.md). Called from the constructors; idempotent, and an app that never
699/// builds a web view never registers, which the step then reports. The provider applies
700/// the same [`wrap_script`] envelope and reply channel as [`JsHandle::eval`]; on a backend
701/// whose arm answers `eval_support() != Native` it fails the callback immediately rather
702/// than letting the step wait out its retry window.
703fn register_script_eval() {
704    let handler: day_core::PieceOperationFn = std::rc::Rc::new(|node, script, done| {
705        if PRIVATE_BLOCKED_NODES.with(|nodes| nodes.borrow().contains(&node))
706            || (!renderer::surface::owns(node) && eval_support() != day_spec::Support::Native)
707        {
708            done(Err(EvalError::Unsupported.to_string()));
709            return;
710        }
711        let req = NEXT_REQ.with(|c| {
712            let v = c.get();
713            c.set(v + 1);
714            v
715        });
716        let wrapped = wrap_script(script);
717        REQUEST_NODES.with(|p| p.borrow_mut().insert(req, node));
718        PENDING_CB.with(|p| p.borrow_mut().insert(req, done));
719        renderer::send_patch(
720            node,
721            WebPatch::Eval {
722                req,
723                script: wrapped,
724            },
725        );
726    });
727    day_core::register_piece_operation(KIND, "day.webview.eval", handler.clone());
728    day_core::register_piece_operation(
729        renderer::surface::KIND,
730        "day.webview.eval",
731        Rc::new(move |node, script, done| {
732            if renderer::surface::owns(node) {
733                handler(node, script, done);
734            } else {
735                done(Err(EvalError::Unsupported.to_string()));
736            }
737        }),
738    );
739}
740
741/// A native snapshot of the current browser page and session history.
742/// Query after navigation or periodically while visible; no script or network request runs.
743#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)]
744pub struct NavigationState {
745    pub url: String,
746    pub title: String,
747    pub can_go_back: bool,
748    pub can_go_forward: bool,
749    pub loading: bool,
750}
751
752/// A handle for running JavaScript in a web view. `Copy`, like `Trigger`, so it can be captured by
753/// several closures. Bind it with [`WebView::js`]; evaluating before the view is realized fails
754/// with [`EvalError::ViewGone`].
755#[derive(Clone, Copy)]
756pub struct JsHandle {
757    node: Signal<Option<RNode>>,
758    blocked: Signal<bool>,
759    renderer: Signal<WebRenderer>,
760}
761
762impl Default for JsHandle {
763    fn default() -> Self {
764        Self::new()
765    }
766}
767
768impl JsHandle {
769    /// Engine currently attached to this handle.
770    pub fn renderer(&self) -> WebRenderer {
771        self.renderer.get_untracked()
772    }
773    #[track_caller]
774    pub fn new() -> Self {
775        JsHandle {
776            node: Signal::new(None),
777            blocked: Signal::new(false),
778            renderer: Signal::new(WebRenderer::default()),
779        }
780    }
781
782    fn data_request(&self, operation: &str, url: &str, cookie: &str) -> EvalFuture {
783        let mut future = self.eval("");
784        future.script = Some(format!(
785            "{DATA_REQUEST}{}",
786            serde_json::json!({"operation":operation,"url":url,"cookie":cookie})
787        ));
788        future
789    }
790    /// Read URL, title, loading status and actual native history availability.
791    /// Emulated iframe hosts return Unsupported rather than guessing browser history.
792    pub async fn navigation_state(&self) -> Result<NavigationState, EvalError> {
793        let reply = self.data_request("navigation", "", "").await?;
794        serde_json::from_str(&reply).map_err(|_| EvalError::Unsupported)
795    }
796    /// Read cookies (including HttpOnly) eligible for this URL from the bound view's profile.
797    /// Treat the returned header as a credential; never log it or send it to a different origin.
798    pub async fn cookie_header(&self, url: &str) -> Result<String, EvalError> {
799        if !url::Url::parse(url).is_ok_and(|url| matches!(url.scheme(), "http" | "https")) {
800            return Err(EvalError::Unsupported);
801        }
802        let reply = self.data_request("cookies", url, "").await?;
803        serde_json::from_str(&reply).map_err(|_| EvalError::Unsupported)
804    }
805    /// Store an HTTP Set-Cookie response in the browser's own jar.
806    pub fn set_cookie(&self, url: &str, cookie: &str) -> EvalFuture {
807        let future = self.data_request("set-cookie", url, cookie);
808        if !cookie_source_valid(url, cookie) {
809            *future.shared.result.borrow_mut() = Some(Err(EvalError::Unsupported));
810        }
811        future
812    }
813    /// Clear all browser data in this profile. Use a dedicated site profile to forget one site.
814    /// Await completion before reusing its cookies or allowing another request to that site.
815    pub fn clear_data(&self) -> EvalFuture {
816        self.data_request("clear", "", "")
817    }
818
819    /// Evaluate `script` and resolve with its result as JSON text.
820    ///
821    /// Nothing is dispatched until the future is polled. Dropping it deregisters the request, so a
822    /// late reply is discarded, but the script keeps running: no backend can cancel one.
823    pub fn eval(&self, script: impl AsRef<str>) -> EvalFuture {
824        EvalFuture {
825            req: NEXT_REQ.with(|c| {
826                let v = c.get();
827                c.set(v + 1);
828                v
829            }),
830            node: self.node,
831            blocked: self.blocked,
832            script: Some(wrap_script(script.as_ref())),
833            shared: Rc::new(EvalShared {
834                result: RefCell::new(None),
835                waker: RefCell::new(None),
836            }),
837            sent: false,
838        }
839    }
840}
841
842/// The pending result of [`JsHandle::eval`].
843pub struct EvalFuture {
844    blocked: Signal<bool>,
845    req: u64,
846    node: Signal<Option<RNode>>,
847    script: Option<String>,
848    shared: Rc<EvalShared>,
849    sent: bool,
850}
851
852impl Future for EvalFuture {
853    type Output = Result<String, EvalError>;
854
855    fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
856        if let Some(result) = self.shared.result.borrow_mut().take() {
857            return Poll::Ready(result);
858        }
859        let Some(blocked) = day_reactive::untrack(|| self.blocked.try_get()) else {
860            return Poll::Ready(Err(EvalError::ViewGone));
861        };
862        if blocked {
863            return Poll::Ready(Err(EvalError::Unsupported));
864        }
865        // Dispatch on first poll, not at construction, so an eval that is never awaited never runs.
866        if !self.sent {
867            self.sent = true;
868            let Some(node) = day_reactive::untrack(|| self.node.try_get()).flatten() else {
869                return Poll::Ready(Err(EvalError::ViewGone));
870            };
871            if !renderer::surface::owns(node) && eval_support() != day_spec::Support::Native {
872                return Poll::Ready(Err(EvalError::Unsupported));
873            }
874            let (req, script) = (self.req, self.script.take().unwrap_or_default());
875            REQUEST_NODES.with(|p| p.borrow_mut().insert(req, node));
876            PENDING.with(|p| p.borrow_mut().insert(req, self.shared.clone()));
877            renderer::send_patch(node, WebPatch::Eval { req, script });
878            // An arm that fails a request without reaching the engine replies from inside that
879            // patch — before this poll has a waker to wake, so `resolve` found none and left the
880            // answer here. Take it now: nothing will ever wake this future on its behalf.
881            // (The XAML arm does exactly this while WebView2 is still starting up.)
882            if let Some(result) = self.shared.result.borrow_mut().take() {
883                return Poll::Ready(result);
884            }
885        }
886        *self.shared.waker.borrow_mut() = Some(cx.waker().clone());
887        Poll::Pending
888    }
889}
890
891impl Drop for EvalFuture {
892    fn drop(&mut self) {
893        REQUEST_NODES.with(|p| p.borrow_mut().remove(&self.req));
894        PENDING.with(|p| p.borrow_mut().remove(&self.req));
895    }
896}
897
898/// Whether this backend can evaluate JavaScript. A separate axis from [`support`]: web-dom loads
899/// pages but can evaluate only same-origin frames.
900pub fn eval_support() -> day_spec::Support {
901    if cfg!(any(
902        all(
903            any(feature = "appkit", feature = "gtk"),
904            target_os = "macos"
905        ),
906        all(feature = "uikit", target_os = "ios"),
907        feature = "qt",
908        all(feature = "dom", target_arch = "wasm32"),
909        all(feature = "gtk", not(target_os = "macos")),
910        all(feature = "xaml", target_os = "windows"),
911        // `evaluateJavascript` in DayWebView.evalJs, with the outer-JSON unquote and the
912        // null/empty→engine-error mapping (docs/webview-eval.md).
913        all(feature = "material", target_os = "android"),
914        // `runJavaScript` in the ArkTS component, replying on `pieceEvent`'s num slot.
915        all(feature = "arkui", target_env = "ohos"),
916    )) {
917        day_spec::Support::Native
918    } else {
919        // No renderer with a JavaScript engine on this platform.
920        day_spec::Support::Unsupported
921    }
922}
923
924/// A native web view bound to `url`. Attach command triggers with `.go()/.back()/.forward()/
925/// .stop()/.reload()`; fire them (`Trigger::notify`) from buttons.
926/// A request from a link, modifier click, or a new-window navigation. The source
927/// remains alive; the application creates a separately owned browsing context.
928#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
929pub struct NewTabRequest {
930    pub url: String,
931    pub background: bool,
932}
933/// App-localized labels for native link menus.
934#[derive(Clone, Debug, PartialEq, Eq)]
935pub struct TabLinkLabels {
936    pub foreground: String,
937    pub background: String,
938}
939const NEW_TAB_REPORT: f64 = -4.0;
940fn new_tab_event(url: String, background: bool) -> Event {
941    Event::Custom {
942        tag: "webview:new-tab",
943        num: NEW_TAB_REPORT,
944        text: serde_json::to_string(&NewTabRequest { url, background }).unwrap_or_default(),
945    }
946}
947
948// A document-start fallback for engines that don't expose pointer modifiers in
949// navigation requests. It carries only link intent; normal navigation remains native.
950#[cfg(all(target_os = "ios", feature = "uikit"))]
951const TAB_MODIFIER_SCRIPT: &str = r#"(() => {
952  const click = e => {
953    const a = e.composedPath().find(n => n?.tagName === 'A' && n.href);
954    if (!a || !(e.metaKey || e.ctrlKey || e.button === 1)) return;
955    e.preventDefault(); e.stopImmediatePropagation();
956    location.href = 'day-tab://open?url=' + encodeURIComponent(a.href) + '&background=' + (e.shiftKey ? '0' : '1');
957  };
958  addEventListener('click', click, true); addEventListener('auxclick', click, true);
959})();"#;
960#[cfg(all(target_os = "ios", feature = "uikit"))]
961fn tab_scheme_request(input: &str) -> Option<NewTabRequest> {
962    let url = url::Url::parse(input).ok()?;
963    if url.scheme() != "day-tab" {
964        return None;
965    }
966    let pairs: HashMap<_, _> = url.query_pairs().into_owned().collect();
967    Some(NewTabRequest {
968        url: pairs.get("url")?.clone(),
969        background: pairs.get("background").is_some_and(|v| v == "1"),
970    })
971}
972
973/// What `.on_link(…)` stores: decides what a navigation to a URL should do.
974type LinkDecider = Rc<dyn Fn(&str) -> LinkPolicy>;
975type NewTabHandler = (TabLinkLabels, Rc<dyn Fn(&NewTabRequest)>);
976
977pub struct WebView {
978    renderer: WebRenderer,
979    profile: WebProfile,
980    resources: Option<ResourceProvider>,
981    url: AnyBinding<String>,
982    go: Option<Trigger>,
983    back: Option<Trigger>,
984    forward: Option<Trigger>,
985    stop: Option<Trigger>,
986    reload: Option<Trigger>,
987    force_reload: Option<Trigger>,
988    js: Option<JsHandle>,
989    session: Option<WebSession>,
990    inline: Option<InlineSite>,
991    inline_start: String,
992    inline_assets: bool,
993    transparent: bool,
994    on_link: Option<LinkDecider>,
995    tabs: Option<NewTabHandler>,
996    on_load: Option<Rc<dyn Fn()>>,
997}
998
999/// `web_view(url)`: a native web view showing `url`. The initial value loads on creation; call
1000/// `.go(trigger)` and fire the trigger to (re)load whatever `url` currently holds.
1001pub fn web_view(url: impl Binding<String>) -> WebView {
1002    // Self-register the web renderer. wasm has no link-time renderer slice, and a constructor is
1003    // the earliest point the piece is known to be in play, always before its node is realized.
1004    #[cfg(all(feature = "dom", target_arch = "wasm32"))]
1005    dom_impl::register();
1006    // Same earliest-point reasoning for the dayscript `web_eval` registration: a step can only
1007    // target a webview some constructor built, so registration here is always in time.
1008    register_script_eval();
1009    WebView {
1010        renderer: WebRenderer::default(),
1011        profile: WebProfile::default(),
1012        url: AnyBinding::new(url),
1013        go: None,
1014        back: None,
1015        forward: None,
1016        stop: None,
1017        reload: None,
1018        force_reload: None,
1019        js: None,
1020        session: None,
1021        inline: None,
1022        inline_start: String::new(),
1023        inline_assets: false,
1024        transparent: false,
1025        on_link: None,
1026        tabs: None,
1027        on_load: None,
1028        resources: None,
1029    }
1030}
1031
1032/// `web_view_inline(site)`: a web view showing a site bundled inside the app
1033/// (docs/webview.md): a directory under `resource/assets/`, shipped whole. Relative references
1034/// within the site resolve natively; navigations that leave it are cancelled and dispatched
1035/// per [`LinkPolicy`], the system browser unless [`WebView::on_external_link`] says otherwise.
1036///
1037/// Takes a prepared [`InlineSite`] (`res::assets::<dir>.prepare_site().await?`, the checked
1038/// route) or the raw `res::assets::<dir>` constant (lazy; a missing page surfaces in the view).
1039/// Gate on [`inline_support`].
1040pub fn web_view_inline(site: impl IntoInlineSite) -> WebView {
1041    let mut v = web_view(Signal::new(String::new()));
1042    v.inline = Some(site.into_inline_site());
1043    v.inline_start = "index.html".into();
1044    v
1045}
1046
1047/// Display an application-provided resource tree. Relative references are resolved by the engine.
1048pub fn web_view_resources(provider: ResourceProvider, page: &str) -> WebView {
1049    let mut view = web_view(Signal::new(provider.url(page)));
1050    view.resources = Some(provider);
1051    view
1052}
1053
1054#[day_pieces::builder(WebViewBuilder)]
1055impl WebView {
1056    /// Select an engine for this context, failing instead of falling back silently.
1057    /// Rebuild the piece at its current URL to switch an existing page's renderer.
1058    pub fn renderer(mut self, renderer: WebRenderer) -> Result<Self, RendererUnavailable> {
1059        if !renderer.available() {
1060            return Err(RendererUnavailable(renderer));
1061        }
1062        self.renderer = renderer;
1063        Ok(self)
1064    }
1065    /// Share cookies, local storage, cache and engine site data with other views using this profile.
1066    pub fn profile(mut self, profile: WebProfile) -> Self {
1067        self.profile = profile;
1068        self
1069    }
1070    /// Bind the actual page URL, including native navigation reports, to application state.
1071    /// Useful for an address bar on bundled/resource views. Notify `.go()` to load the value.
1072    pub fn url_binding(mut self, url: impl Binding<String>) -> Self {
1073        let url = AnyBinding::new(url);
1074        if url.get_untracked().is_empty() {
1075            url.set(self.url.get_untracked());
1076        }
1077        self.url = url;
1078        self
1079    }
1080    /// Called on the UI thread after the backend reports a completed navigation. Useful for
1081    /// applying the latest presentation state through `JsHandle`, including same-URL reloads.
1082    /// Available on native backends that report navigation; not on emulated iframe hosts.
1083    pub fn on_load(mut self, callback: impl Fn() + 'static) -> Self {
1084        self.on_load = Some(Rc::new(callback));
1085        self
1086    }
1087    /// Load the current value of the bound `url` whenever `trigger` fires.
1088    pub fn go(mut self, trigger: Trigger) -> Self {
1089        self.go = Some(trigger);
1090        self
1091    }
1092    /// Navigate back in history whenever `trigger` fires.
1093    pub fn back(mut self, trigger: Trigger) -> Self {
1094        self.back = Some(trigger);
1095        self
1096    }
1097    /// Navigate forward in history whenever `trigger` fires.
1098    pub fn forward(mut self, trigger: Trigger) -> Self {
1099        self.forward = Some(trigger);
1100        self
1101    }
1102    /// Stop the current load whenever `trigger` fires.
1103    pub fn stop(mut self, trigger: Trigger) -> Self {
1104        self.stop = Some(trigger);
1105        self
1106    }
1107    /// Reload the current page whenever `trigger` fires.
1108    pub fn reload(mut self, trigger: Trigger) -> Self {
1109        self.reload = Some(trigger);
1110        self
1111    }
1112    /// Reload from the server, bypassing cached resources. Check [`force_reload_support`].
1113    pub fn force_reload(mut self, trigger: Trigger) -> Self {
1114        self.force_reload = Some(trigger);
1115        self
1116    }
1117    /// Bind a [`JsHandle`] so `handle.eval(…)` runs in this view (docs/webview-eval.md).
1118    pub fn js(mut self, handle: JsHandle) -> Self {
1119        self.js = Some(handle);
1120        self
1121    }
1122    /// Show a retained [`WebSession`], so the loaded page survives navigating away and back.
1123    pub fn session(mut self, session: WebSession) -> Self {
1124        self.session = Some(session);
1125        self
1126    }
1127    /// Inline mode only: the page within the site to open first, instead of `index.html`. A
1128    /// query or fragment is part of the page (`"player.html?src=hello"`), and the site reads it
1129    /// back through `location.search` / `location.hash` as it would on a server.
1130    pub fn start_page(mut self, page: impl Into<String>) -> Self {
1131        self.inline_start = page.into();
1132        self
1133    }
1134    /// Inline mode only: let the site read the app's whole `resource/assets/` tree, not just its
1135    /// own directory.
1136    ///
1137    /// A self-contained site needs nothing here. Ask for it when the page's job is to show the
1138    /// app's own files — a player, a viewer, a renderer — because those files live in the app's
1139    /// asset namespace and the site's relative URLs have to reach them
1140    /// (`day-piece-lottie`'s player is this: one page, any of the app's animations).
1141    pub fn app_assets(mut self) -> Self {
1142        self.inline_assets = true;
1143        self
1144    }
1145    /// Draw no background of the view's own: where the page's own background is transparent, the
1146    /// app shows through, the way an image with an alpha channel sits on whatever is behind it.
1147    ///
1148    /// What a web view is for here is content that belongs to the app's own surface (an
1149    /// animation player, a rendered diagram, a badge), where the view's usual white sheet reads as
1150    /// a hole in the window. A page that sets a background of its own still draws it. Every arm
1151    /// honors it (docs/webview.md "A transparent view").
1152    pub fn transparent(mut self) -> Self {
1153        self.transparent = true;
1154        self
1155    }
1156    /// Decide what happens to a navigation that leaves an inline/resource site. On Apple
1157    /// backends, also opts ordinary documents into reporting user-activated links and new
1158    /// windows; initial loads, subframes and same-document fragments stay in view. Runs on the
1159    /// main thread with the target URL; without it every external link is
1160    /// [`LinkPolicy::OpenSystem`]. The closure may do arbitrary in-app work (navigate the day
1161    /// app, log) and return [`LinkPolicy::Ignore`].
1162    /// Handle a request for a separate tab without replacing the source document.
1163    /// Labels must be localized by the application. Background requests preserve focus.
1164    pub fn on_new_tab<M1, M2>(
1165        mut self,
1166        foreground_label: impl IntoText<M1>,
1167        background_label: impl IntoText<M2>,
1168        f: impl Fn(&NewTabRequest) + 'static,
1169    ) -> Self {
1170        self.tabs = Some((
1171            TabLinkLabels {
1172                foreground: foreground_label.into_text().initial(),
1173                background: background_label.into_text().initial(),
1174            },
1175            Rc::new(f),
1176        ));
1177        self
1178    }
1179    pub fn on_external_link(mut self, f: impl Fn(&str) -> LinkPolicy + 'static) -> Self {
1180        self.on_link = Some(Rc::new(f));
1181        self
1182    }
1183}
1184
1185/// What this backend realizes. `Native` is a real embedded browser engine with the full command
1186/// set; `Emulated` loads pages but cannot drive history or report navigation back (web-dom's
1187/// `<iframe>`, see docs/webview.md); `Unsupported` renders day's placeholder leaf.
1188///
1189/// Gate history controls on this: `.back()`, `.forward()` and `.stop()` are no-ops below `Native`,
1190/// so an app should disable those buttons rather than offer ones that do nothing.
1191pub fn support() -> day_spec::Support {
1192    // Browser frames retain remote-navigation restrictions. Native hosts supply an engine.
1193    if cfg!(all(feature = "dom", target_arch = "wasm32")) {
1194        day_spec::Support::Emulated
1195    } else if cfg!(any(
1196        all(
1197            any(feature = "appkit", feature = "gtk"),
1198            target_os = "macos"
1199        ),
1200        all(feature = "uikit", target_os = "ios"),
1201        all(feature = "material", target_os = "android"),
1202        all(feature = "gtk", not(target_os = "macos")),
1203        feature = "qt",
1204        all(feature = "xaml", windows),
1205        all(feature = "arkui", target_env = "ohos"),
1206    )) {
1207        day_spec::Support::Native
1208    } else {
1209        day_spec::Support::Unsupported
1210    }
1211}
1212
1213impl Piece for WebView {
1214    fn build(self, cx: &mut BuildCx) -> RNode {
1215        let WebView {
1216            renderer,
1217            profile,
1218            url,
1219            go,
1220            back,
1221            forward,
1222            stop,
1223            reload,
1224            force_reload,
1225            js,
1226            session,
1227            inline,
1228            inline_start,
1229            inline_assets,
1230            transparent,
1231            on_link,
1232            tabs,
1233            on_load,
1234            mut resources,
1235        } = self;
1236        let session_id = session.map(WebSession::id).unwrap_or(0);
1237        let session_id = if session_id == 0 || profile == WebProfile::default() {
1238            session_id
1239        } else {
1240            profile.storage_key().bytes().fold(session_id, |h, b| {
1241                (h ^ u64::from(b)).wrapping_mul(1099511628211)
1242            })
1243        };
1244        let mut initial_url = url.get_untracked();
1245        if let (Some(_session), Some(provider)) = (session, &resources) {
1246            // A retained engine still needs its original namespace while detached. Like the
1247            // retained page itself, the first provider wins until process shutdown.
1248            day_core::tls_group! {
1249                static SESSION_RESOURCES: RefCell<HashMap<(WebRenderer, u64), (ResourceProvider, String)>> = RefCell::new(HashMap::new());
1250            }
1251            let retained = SESSION_RESOURCES.with(|m| {
1252                m.borrow_mut()
1253                    .entry((renderer, session_id))
1254                    .or_insert_with(|| (provider.clone(), initial_url.clone()))
1255                    .clone()
1256            });
1257            resources = Some(retained.0);
1258            initial_url = retained.1;
1259            url.set(initial_url.clone());
1260        }
1261        let blocked_private = (profile.private
1262            && renderer.private_browsing_support() == day_spec::Support::Unsupported)
1263            || (renderer != WebRenderer::WebView
1264                && renderer.profile_support() == day_spec::Support::Unsupported
1265                && profile != WebProfile::default());
1266        let initial = WebProps {
1267            renderer,
1268            profile,
1269            resources: if blocked_private {
1270                None
1271            } else {
1272                resources.clone()
1273            },
1274            external_links: on_link.is_some(),
1275            tabs: tabs.as_ref().map(|(labels, _)| labels.clone()),
1276            url: if blocked_private {
1277                "about:blank".into()
1278            } else {
1279                initial_url
1280            },
1281            session: session_id,
1282            inline_root: if blocked_private {
1283                String::new()
1284            } else {
1285                inline.as_ref().map(|s| s.root.clone()).unwrap_or_default()
1286            },
1287            inline_start: if inline.is_some() {
1288                inline_start
1289            } else {
1290                String::new()
1291            },
1292            inline_assets: inline.is_some() && inline_assets,
1293            transparent,
1294        };
1295        // A web view has no intrinsic size; it fills whatever space its container offers.
1296        let surface = renderer::surface::get(renderer);
1297        let flex = Flex {
1298            grow_w: true,
1299            grow_h: true,
1300            ..Default::default()
1301        };
1302        let node = if surface.is_some() {
1303            cx.leaf(
1304                renderer::surface::KIND,
1305                &day_spec::props::CanvasProps::default(),
1306                flex,
1307            )
1308        } else {
1309            cx.leaf(KIND, &initial, flex)
1310        };
1311
1312        if let Some(host) = surface {
1313            renderer::surface::attach(host, cx, node, &initial);
1314        }
1315
1316        day_reactive::Scope::current().on_cleanup(move || {
1317            let requests = REQUEST_NODES.with(|p| {
1318                p.borrow()
1319                    .iter()
1320                    .filter_map(|(req, owner)| (*owner == node).then_some(*req))
1321                    .collect::<Vec<_>>()
1322            });
1323            for req in requests {
1324                REQUEST_NODES.with(|p| p.borrow_mut().remove(&req));
1325                if let Some(done) = PENDING_CB.with(|p| p.borrow_mut().remove(&req)) {
1326                    done(Err(EvalError::ViewGone.to_string()));
1327                }
1328                if let Some(shared) = PENDING.with(|p| p.borrow_mut().remove(&req)) {
1329                    *shared.result.borrow_mut() = Some(Err(EvalError::ViewGone));
1330                    if let Some(waker) = shared.waker.borrow_mut().take() {
1331                        waker.wake();
1332                    }
1333                }
1334            }
1335        });
1336        if blocked_private {
1337            PRIVATE_BLOCKED_NODES.with(|nodes| nodes.borrow_mut().insert(node));
1338            day_reactive::Scope::current().on_cleanup(move || {
1339                PRIVATE_BLOCKED_NODES.with(|nodes| nodes.borrow_mut().remove(&node));
1340            });
1341        }
1342        let send = move |patch: WebPatch| {
1343            if blocked_private {
1344                if let WebPatch::Eval { req, .. } = patch {
1345                    resolve(
1346                        req,
1347                        &engine_error("Unsupported", "private browsing unavailable"),
1348                    );
1349                }
1350                return;
1351            }
1352            renderer::send_patch(node, patch);
1353        };
1354
1355        // Each command trigger → one patch. `watch` never fires for the initial value, so wiring
1356        // these does not issue a spurious command at build time (the initial URL loads via props).
1357        if let Some(go) = go {
1358            watch(
1359                move || go.track(),
1360                move |_, _| send(WebPatch::Load(url.get_untracked())),
1361            );
1362        }
1363        if let Some(back) = back {
1364            watch(move || back.track(), move |_, _| send(WebPatch::Back));
1365        }
1366        if let Some(forward) = forward {
1367            watch(move || forward.track(), move |_, _| send(WebPatch::Forward));
1368        }
1369        if let Some(stop) = stop {
1370            watch(move || stop.track(), move |_, _| send(WebPatch::Stop));
1371        }
1372        if let Some(reload) = reload {
1373            watch(move || reload.track(), move |_, _| send(WebPatch::Reload));
1374        }
1375
1376        if let Some(force_reload) = force_reload {
1377            watch(
1378                move || force_reload.track(),
1379                move |_, _| send(WebPatch::ForceReload),
1380            );
1381        }
1382
1383        // Bind the eval handle to the realized node so `handle.eval(…)` knows where to send.
1384        if let Some(js) = js {
1385            js.renderer.set(renderer);
1386            js.blocked.set(blocked_private);
1387            js.node.set(Some(node));
1388            day_reactive::Scope::current().on_cleanup(move || {
1389                if js.node.try_get() == Some(Some(node)) {
1390                    js.node.set(None);
1391                }
1392            });
1393        }
1394
1395        // Two kinds of report share this node's `Event::Custom` channel, told apart by `num`:
1396        // 0 is navigation (the URL, so a bound text field follows along), anything else is an
1397        // evaluation reply keyed by its request id. In-process backends also tag them, but a
1398        // cross-boundary Custom (JNI, C-ABI) carries only `num`/`text`, so `num` is the
1399        // discriminator that works everywhere (§8.2's opened event channel).
1400        #[cfg(all(feature = "arkui", target_env = "ohos"))]
1401        let registration = RefCell::new(None);
1402        #[cfg(all(feature = "arkui", target_env = "ohos"))]
1403        let resource_start = initial.url.clone();
1404        cx.on(node, move |ev| {
1405            let _keep_resources_alive = &resources;
1406            if let Event::Custom { num, text, .. } = ev {
1407                #[cfg(all(feature = "arkui", target_env = "ohos"))]
1408                if *num == -3.0 {
1409                    if let Some(provider) = &resources {
1410                        *registration.borrow_mut() = resources_arkui::install(provider.id(), text);
1411                        if registration.borrow().is_some() {
1412                            send(WebPatch::Load(resource_start.clone()));
1413                        }
1414                    }
1415                    return;
1416                }
1417                if *num >= 1.0 {
1418                    resolve(*num as u64, text);
1419                } else if *num == NEW_TAB_REPORT {
1420                    if let Some((_, callback)) = &tabs
1421                        && let Ok(request) = serde_json::from_str::<NewTabRequest>(text)
1422                    {
1423                        callback(&request);
1424                    }
1425                } else if *num == LINK_REPORT {
1426                    // An inline site's navigation left the site: the arm already canceled it
1427                    // (§8.3 events are enqueue-only, so the native side can't ask), and the
1428                    // policy runs here. `InView` re-issues the load as a command.
1429                    let policy = on_link
1430                        .as_ref()
1431                        .map(|f| f(text))
1432                        .unwrap_or(LinkPolicy::OpenSystem);
1433                    match policy {
1434                        LinkPolicy::OpenSystem => day_core::open_url(text),
1435                        LinkPolicy::InView => {
1436                            send(WebPatch::Load(text.clone()));
1437                        }
1438                        LinkPolicy::Ignore => {}
1439                    }
1440                } else if *num == 0.0 {
1441                    url.set(text.clone());
1442                    if let Some(callback) = &on_load {
1443                        callback();
1444                    }
1445                }
1446            }
1447        });
1448        node
1449    }
1450}
1451
1452// ---------------------------------------------------------------------------
1453// Per-toolkit native renderers, one file per backend (this crate is a reference implementation,
1454// so each toolkit is split out for clarity). Each module registers a `Renderer` link-time into its
1455// backend's `RENDERERS` slice; `#[cfg]` gates each to its feature + target, and `#[path]` keeps the
1456// files grouped next to lib.rs.
1457// ---------------------------------------------------------------------------
1458
1459#[cfg(all(target_os = "macos", feature = "gtk"))]
1460mod gtk_assets;
1461#[cfg(all(target_os = "macos", feature = "gtk"))]
1462#[path = "lib-gtk-macos.rs"]
1463mod gtk_macos;
1464#[cfg(all(target_os = "macos", any(feature = "appkit", feature = "gtk")))]
1465mod macos_web;
1466
1467day_pieces::glue_modules!(appkit, qt, uikit, material, xaml, arkui, dom);
1468
1469#[cfg(any(test, all(feature = "xaml", windows)))]
1470mod xaml_path;
1471
1472// Linux GTK uses WebKitGTK; the other GTK hosts are registered above.
1473#[cfg(all(feature = "gtk", not(target_os = "macos"), not(windows)))]
1474#[path = "lib-gtk.rs"]
1475mod gtk_impl;
1476
1477/// Rendering engine selected independently for each view.
1478#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1479pub enum WebRenderer {
1480    /// The toolkit's embedded browser; the default.
1481    #[default]
1482    WebView,
1483    /// Experimental engine supplied by the separately registered `day-piece-webview-servo` package.
1484    Servo,
1485}
1486impl WebRenderer {
1487    /// Whether this build can create this renderer, without initializing an engine.
1488    pub fn available(self) -> bool {
1489        match self {
1490            Self::WebView => support() != day_spec::Support::Unsupported,
1491            Self::Servo => renderer::capabilities(self).is_some(),
1492        }
1493    }
1494    /// Whether isolated private browsing contexts are supported.
1495    pub fn private_browsing_support(self) -> day_spec::Support {
1496        match self {
1497            Self::WebView => private_browsing_support(),
1498            Self::Servo => renderer::capabilities(self)
1499                .map_or(day_spec::Support::Unsupported, |caps| caps.private_browsing),
1500        }
1501    }
1502    /// Whether named storage profiles are supported.
1503    pub fn profile_support(self) -> day_spec::Support {
1504        match self {
1505            Self::WebView => profile_support(),
1506            Self::Servo => renderer::capabilities(self)
1507                .map_or(day_spec::Support::Unsupported, |caps| caps.profiles),
1508        }
1509    }
1510    /// Whether all data in a profile can be deleted.
1511    pub fn clear_data_support(self) -> day_spec::Support {
1512        match self {
1513            Self::WebView => clear_data_support(),
1514            Self::Servo => renderer::capabilities(self)
1515                .map_or(day_spec::Support::Unsupported, |caps| caps.clear_data),
1516        }
1517    }
1518}
1519/// An explicitly selected renderer is unavailable in this build.
1520#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1521pub struct RendererUnavailable(pub WebRenderer);
1522impl std::fmt::Display for RendererUnavailable {
1523    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1524        write!(f, "{:?} renderer is unavailable in this build", self.0)
1525    }
1526}
1527impl std::error::Error for RendererUnavailable {}
1528
1529#[cfg(test)]
1530mod tests {
1531    #[test]
1532    fn servo_selection_is_explicit_and_does_not_claim_native_profiles() {
1533        assert_eq!(super::WebRenderer::default(), super::WebRenderer::WebView);
1534        assert!(!super::WebRenderer::Servo.available());
1535        assert_eq!(
1536            super::WebRenderer::Servo.private_browsing_support(),
1537            day_spec::Support::Unsupported
1538        );
1539        assert_eq!(
1540            super::WebRenderer::Servo.profile_support(),
1541            day_spec::Support::Unsupported
1542        );
1543    }
1544
1545    use super::*;
1546
1547    #[test]
1548    fn an_unpolled_evaluation_survives_its_tab_being_closed() {
1549        let scope = day_reactive::Scope::child();
1550        let mut future = scope.enter(|| JsHandle::new().eval("1 + 1"));
1551        scope.dispose();
1552        let mut context = std::task::Context::from_waker(std::task::Waker::noop());
1553        assert!(matches!(
1554            Pin::new(&mut future).poll(&mut context),
1555            Poll::Ready(Err(EvalError::ViewGone))
1556        ));
1557    }
1558
1559    #[test]
1560    fn profiles_keep_identity_and_privacy_separate() {
1561        let profile = WebProfile::persistent("publication");
1562        assert_eq!(
1563            profile.storage_key(),
1564            WebProfile::persistent("publication").storage_key()
1565        );
1566        assert_ne!(
1567            profile.storage_key(),
1568            WebProfile::private("publication").storage_key()
1569        );
1570        assert_ne!(
1571            profile.storage_key(),
1572            WebProfile::persistent("other").storage_key()
1573        );
1574        assert_ne!(
1575            profile.clone().directory("/fixture/a").storage_key(),
1576            profile.directory("/fixture/b").storage_key()
1577        );
1578    }
1579    #[test]
1580    fn response_cookies_cannot_impersonate_another_origin() {
1581        assert!(cookie_source_valid(
1582            "https://news.example.com/story",
1583            "session=fixture; Domain=.example.com; Secure; HttpOnly"
1584        ));
1585        assert!(!cookie_source_valid(
1586            "https://news.example.co.uk/",
1587            "session=fixture; Domain=co.uk"
1588        ));
1589        assert!(!cookie_source_valid(
1590            "https://project.github.io/",
1591            "session=fixture; Domain=github.io"
1592        ));
1593        assert!(!cookie_source_valid(
1594            "https://unrelated.test/",
1595            "session=fixture; Domain=example.com"
1596        ));
1597        assert!(!cookie_source_valid(
1598            "https://example.com/",
1599            "session=fixture\r\nInjected: header"
1600        ));
1601        assert!(!cookie_source_valid(
1602            "http://example.com/",
1603            "session=fixture; Secure"
1604        ));
1605        assert!(!cookie_source_valid(
1606            "https://example.com/",
1607            "__Host-session=fixture; Secure; Domain=example.com; Path=/"
1608        ));
1609        assert!(cookie_source_valid(
1610            "https://example.com/",
1611            "__Host-session=fixture; Secure; Path=/"
1612        ));
1613    }
1614    #[test]
1615    fn cookie_credentials_respect_host_path_and_transport_boundaries() {
1616        assert!(cookie_matches(
1617            "https://news.example.com/account/page",
1618            ".example.com",
1619            "/account",
1620            true
1621        ));
1622        assert!(!cookie_matches(
1623            "http://news.example.com/account",
1624            ".example.com",
1625            "/account",
1626            true
1627        ));
1628        assert!(!cookie_matches(
1629            "https://evil-example.com/account",
1630            ".example.com",
1631            "/account",
1632            false
1633        ));
1634        assert!(!cookie_matches(
1635            "https://news.example.com/accounting",
1636            ".example.com",
1637            "/account",
1638            false
1639        ));
1640        assert!(!cookie_matches(
1641            "https://news.example.com/",
1642            "example.com",
1643            "/",
1644            false
1645        ));
1646        assert!(!cookie_matches(
1647            "file:///account",
1648            ".example.com",
1649            "/",
1650            false
1651        ));
1652        assert!(cookie_matches(
1653            "https://EXAMPLE.com/",
1654            "example.com",
1655            "",
1656            false
1657        ));
1658    }
1659
1660    /// Build a reply the way an arm would, without a literal control char in the test source.
1661    fn reply(parts: &[&str]) -> String {
1662        parts.join(&SEP.to_string())
1663    }
1664
1665    #[test]
1666    fn decodes_a_value() {
1667        assert_eq!(decode(&reply(&["1", "2"])), Ok("2".into()));
1668        assert_eq!(decode(&reply(&["1", "null"])), Ok("null".into()));
1669        let obj = r#"{"a":1,"b":"x"}"#;
1670        assert_eq!(decode(&reply(&["1", obj])), Ok(obj.into()));
1671    }
1672
1673    #[test]
1674    fn decodes_a_throw() {
1675        assert_eq!(
1676            decode(&reply(&["0", "TypeError", "boom"])),
1677            Err(EvalError::Threw {
1678                name: "TypeError".into(),
1679                message: "boom".into(),
1680            })
1681        );
1682    }
1683
1684    /// Only the first separator splits the name off, so a message carrying more stays intact.
1685    #[test]
1686    fn a_message_may_contain_the_separator() {
1687        assert_eq!(
1688            decode(&reply(&["0", "Error", "a", "b"])),
1689            Err(EvalError::Threw {
1690                name: "Error".into(),
1691                message: reply(&["a", "b"]),
1692            })
1693        );
1694    }
1695
1696    /// JSON text can never hold a raw separator (`JSON.stringify` escapes control characters as
1697    /// six ASCII chars), so splitting on it cannot corrupt a value. This pins that.
1698    #[test]
1699    fn an_escaped_separator_inside_json_survives() {
1700        let json = r#""a\u001fb""#;
1701        assert_eq!(decode(&reply(&["1", json])), Ok(json.into()));
1702    }
1703
1704    #[test]
1705    fn an_undecodable_reply_is_an_engine_error() {
1706        assert_eq!(decode(""), Err(EvalError::Engine(String::new())));
1707        // What a backend reports when the wrapper never ran at all.
1708        assert_eq!(decode("null"), Err(EvalError::Engine("null".into())));
1709    }
1710
1711    /// The arms build engine failures with `engine_error`; it must decode like any other throw.
1712    #[test]
1713    fn engine_errors_round_trip() {
1714        assert_eq!(
1715            decode(&engine_error("WebKitError", "process gone")),
1716            Err(EvalError::Threw {
1717                name: "WebKitError".into(),
1718                message: "process gone".into(),
1719            })
1720        );
1721    }
1722
1723    /// The script rides inside a string literal, so nothing in it can reach the wrapper's own
1724    /// tokens: a trailing line comment, an unbalanced brace, a quote, a newline.
1725    #[test]
1726    fn the_wrapper_is_lexically_sealed() {
1727        for hostile in [
1728            "1 + 1 // add",
1729            "\"unterminated",
1730            "}}})(){{{",
1731            "a\nb",
1732            "x\\y",
1733        ] {
1734            let js = wrap_script(hostile);
1735            assert!(
1736                js.trim_end().ends_with("})()"),
1737                "wrapper not closed for {hostile:?}: {js}"
1738            );
1739            assert!(
1740                !js.contains("\n"),
1741                "raw newline leaked for {hostile:?}: {js}"
1742            );
1743        }
1744    }
1745
1746    #[test]
1747    fn a_start_page_keeps_its_query_out_of_the_path() {
1748        assert_eq!(split_page("index.html"), ("index.html", ""));
1749        assert_eq!(
1750            split_page("player.html?src=hello&loop=1"),
1751            ("player.html", "?src=hello&loop=1")
1752        );
1753        assert_eq!(
1754            split_page("docs/index.html#top"),
1755            ("docs/index.html", "#top")
1756        );
1757    }
1758
1759    #[test]
1760    fn escapes_a_script_into_a_literal() {
1761        assert_eq!(js_string_literal("a"), "\"a\"");
1762        assert_eq!(js_string_literal("a\"b"), "\"a\\\"b\"");
1763        assert_eq!(js_string_literal("a\\b"), "\"a\\\\b\"");
1764        assert_eq!(js_string_literal("a\nb"), "\"a\\nb\"");
1765        // A literal line terminator would end the string mid-source.
1766        assert_eq!(js_string_literal("a\u{2028}b"), "\"a\\u2028b\"");
1767        assert_eq!(js_string_literal("a\u{1}b"), "\"a\\u0001b\"");
1768    }
1769}
1770
1771#[cfg(test)]
1772mod external_link_tests {
1773    use super::external_document_link as external;
1774    #[test]
1775    fn user_navigation_leaves_reader_but_loading_and_anchors_do_not() {
1776        let page = Some("file:///tmp/fixture.html");
1777        assert!(external(
1778            page,
1779            "https://example.test/story",
1780            true,
1781            false,
1782            false
1783        ));
1784        assert!(external(
1785            page,
1786            "https://example.test/story",
1787            true,
1788            true,
1789            false
1790        ));
1791        assert!(external(
1792            page,
1793            "https://example.test/story",
1794            false,
1795            true,
1796            false
1797        ));
1798        assert!(!external(
1799            page,
1800            "file:///tmp/fixture.html#note",
1801            true,
1802            false,
1803            false
1804        ));
1805        assert!(!external(
1806            page,
1807            "file:///tmp/next.html",
1808            false,
1809            false,
1810            false
1811        ));
1812        assert!(!external(
1813            page,
1814            "https://example.test/frame",
1815            true,
1816            false,
1817            true
1818        ));
1819        assert!(!external(page, "about:blank", false, true, false));
1820        assert!(external(
1821            Some("https://example.test/story?a=1#x"),
1822            "https://example.test/story?a=2",
1823            true,
1824            false,
1825            false
1826        ));
1827    }
1828}