day-piece-webview 0.4.4

An embedded native web view for Day apps: remote pages and bundled sites
Documentation
// Copyright © The Daybrite Project
// SPDX-License-Identifier: MPL-2.0

// ---------------------------------------------------------------------------
// Web (web-dom): an `<iframe>`.
//
// The one backend with no browser engine to embed, because the host page already is one. An iframe
// displays a URL faithfully; everything beyond that is governed by the same-origin policy, which
// forbids a parent document from reading or driving a cross-origin child. Three consequences, all
// documented in docs/webview.md:
//
// - **Session history is unreachable.** `contentWindow.history.back()` throws `SecurityError` on a
//   cross-origin frame, so `Back` and `Forward` are no-ops. Driving the top-level history instead
//   would be worse than doing nothing: day's web router owns that stack (`pushState` on hash
//   routes), so a "back" press would navigate the app off the page hosting the frame.
// - **A load cannot be cancelled.** `contentWindow.stop()` is blocked for the same reason.
// - **Navigation does not report back.** Reading `contentWindow.location.href` throws, so the URL
//   binding is one-way here: `.go()` loads the field's value, but the field does not follow the
//   user's clicks inside the frame. `Reload` therefore re-loads the last URL day itself set, not
//   wherever the frame has since navigated to.
//
// Same-origin content has none of these limits, but a piece cannot know the origin before loading
// and the failure is silent when it guesses wrong, so this arm reports `Support::Emulated` and
// behaves identically either way rather than working only sometimes. An app gates its history
// controls on `day_piece_webview::support()`.
//
// A cross-origin frame that refuses embedding (`X-Frame-Options`, CSP `frame-ancestors`) renders
// blank, and the parent cannot detect it; the load event fires either way. That is the browser's
// policy, not day's, and no arm of this piece can report it.
// ---------------------------------------------------------------------------

use super::*;
use day_toolkit_dom::{Dom, DomHandle};
use day_spec::{NodeId, Proposal, Size};
use std::cell::RefCell;
use std::collections::HashMap;

day_core::tls_group! {
    /// The last URL day assigned per frame, so `Reload` has something to re-assign. day-toolkit-dom is
    /// write-only (`set_attr` with no getter) and `WebPatch::Reload` carries no URL, so without
    /// this the command would have nothing to act on. Keyed by the shim element id; web-dom is
    /// single-threaded and handles are never reused, so a plain map is enough.
    static LAST_SRC: RefCell<HashMap<DomHandle, String>> = RefCell::new(HashMap::new());

}

/// Point the frame at `url` and remember it for `Reload`. Assigning `src` navigates, and assigning
/// the value it already holds re-navigates, which is what lets one helper serve both commands.
fn load(backend: &mut Dom, h: &DomHandle, url: &str) {
    backend.set_attr(h, "src", url);
    LAST_SRC.with(|m| m.borrow_mut().insert(*h, url.to_string()));
}

fn make(backend: &mut Dom, p: &WebProps, _id: NodeId) -> DomHandle {
    if p.profile.private {
        return backend.element("div");
    }
    let h = backend.element("iframe");
    super::browser::observe_load(h.0 as i32);
    if let Some(labels) = &p.tabs {
        super::browser::tabs(h.0 as i32, labels);
    }
    backend.set_attr(&h, "allow", "autoplay; fullscreen; gamepad");
    backend.set_attr(&h, "allowfullscreen", "");
    if let Some(provider) = &p.resources {
        super::browser::resources(h.0 as i32, &provider.base_url(), &p.url);
        LAST_SRC.with(|m| m.borrow_mut().insert(h, p.url.clone()));
    } else if !p.inline_root.is_empty() {
        // Inline mode (docs/webview.md): the bundled site deploys under `assets/data/` beside
        // the host page (web.rs), so a relative src is same-origin and the browser resolves
        // the site's internal references natively. The crate's browser bridge arms its
        // same-origin click hook before the first load: links leaving the site are cancelled
        // in-frame and reported (num -1), and the front-end runs the app's LinkPolicy.
        let base = format!("assets/data/{}/", p.inline_root);
        super::browser::attach(h.0 as i32, &base);
        load(backend, &h, &format!("{base}{}", p.inline_start));
    } else if !p.url.is_empty() {
        load(backend, &h, &p.url);
    }
    // No `sandbox` attribute: present-but-empty is deny-everything, which breaks scripts, forms and
    // same-origin reads on nearly every site. Absent is the permissive default we want.
    // The inline style fills the frame day's layout assigns, the growing-leaf contract the native
    // arms honor by sizing their native view to the same rect.
    // A frame is transparent wherever its page is, with one exception: when the frame's color
    // scheme differs from the page's, the browser paints an opaque canvas under it so light text
    // cannot land on a light parent. A page that declares no scheme is `normal`, so a
    // transparent frame declares the same, whatever the host page chose.
    let style = if p.transparent {
        "width:100%;height:100%;border:0;background:transparent;color-scheme:normal"
    } else {
        "width:100%;height:100%;border:0"
    };
    backend.set_attr(&h, "style", style);
    if p.transparent {
        // The legacy spelling some engines still read.
        backend.set_attr(&h, "allowtransparency", "true");
    }
    h
}

fn update(backend: &mut Dom, h: &DomHandle, patch: &WebPatch) {
    match patch {
        WebPatch::Load(url) => load(backend, h, url),
        WebPatch::ForceReload => {} // No iframe API can guarantee a server reload.
        WebPatch::Reload => {
            // Re-assign whatever we last set. Nothing to do before the first load.
            if let Some(url) = LAST_SRC.with(|m| m.borrow().get(h).cloned()) {
                backend.set_attr(h, "src", &url);
            }
        }
        // Unreachable across origins (see the header). Faking them through the top-level history
        // would drive day's router rather than the frame, so they stay no-ops and the app disables
        // the controls via `support()`.
        WebPatch::Back | WebPatch::Forward | WebPatch::Stop => {}
        // Same-origin frames can evaluate; the browser bridge reports cross-origin access as an error.
        WebPatch::Eval { req, script } => super::browser::eval(h.0 as i32, *req as f64, script),
    }
}

/// A growing leaf: take whatever the layout proposes, with a modest default when it proposes
/// nothing, the same posture as the native arms, which report their web view's intrinsic size.
fn measure(_backend: &mut Dom, _h: &DomHandle, p: Proposal) -> Size {
    Size::new(p.width.unwrap_or(320.0), p.height.unwrap_or(240.0))
}

fn release(_backend: &mut Dom, h: &DomHandle) {
    LAST_SRC.with(|m| m.borrow_mut().remove(h));
}

// Defines `register()`, which `web_view()` calls: web-dom's registry is populated at runtime,
// unlike the link-time `renderer!` the native arms use (wasm has no `linkme` slice).
day_pieces::dom_renderer!(day_toolkit_dom::register_renderer, Dom,
    kind: KIND, props: WebProps, patch: WebPatch,
    make: make, update: update, measure: measure, release: release);