#[cfg(feature = "dom")]
mod browser;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use std::task::Poll;
use day_core::{BuildCx, Flex, Piece, RNode};
use day_pieces::{Decorated, IntoText};
use day_reactive::{AnyBinding, Binding, Signal, Trigger, watch};
use day_spec::Event;
pub mod renderer;
mod resources;
pub use resources::{ResourceProvider, ResourceRequest, ResourceResponse};
#[cfg(all(
any(target_os = "macos", target_os = "ios"),
any(feature = "appkit", feature = "uikit", feature = "gtk")
))]
mod resources_apple;
#[cfg(all(feature = "arkui", target_env = "ohos"))]
mod resources_arkui;
#[cfg(any(test, all(feature = "arkui", target_env = "ohos")))]
mod resource_content_type;
#[cfg(any(
test,
all(
any(target_os = "macos", target_os = "ios"),
any(feature = "appkit", feature = "uikit", feature = "gtk")
)
))]
fn external_document_link(
current: Option<&str>,
target: &str,
activated: bool,
new_window: bool,
subframe: bool,
) -> bool {
if subframe || target.is_empty() || target == "about:blank" || !(activated || new_window) {
return false;
}
let without_fragment = |url: &str| url.split('#').next().unwrap_or("").to_owned();
!current.is_some_and(|url| without_fragment(url) == without_fragment(target))
}
pub const KIND: &str = "day.piece.webview";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WebProfile {
pub name: String,
pub directory: Option<String>,
pub private: bool,
}
impl WebProfile {
pub fn persistent(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Self::default()
}
}
pub fn private(name: impl Into<String>) -> Self {
Self {
name: name.into(),
private: true,
directory: None,
}
}
pub fn directory(mut self, path: impl Into<String>) -> Self {
self.directory = Some(path.into());
self
}
pub fn storage_key(&self) -> String {
let text = format!("{}:{:?}:{}", self.name, self.directory, self.private);
let hash = text.bytes().fold(14695981039346656037u64, |h, b| {
(h ^ u64::from(b)).wrapping_mul(1099511628211)
});
format!("day-{hash:016x}")
}
}
pub fn private_browsing_support() -> day_spec::Support {
if cfg!(any(
all(feature = "material", target_os = "android"),
all(feature = "dom", target_arch = "wasm32")
)) {
day_spec::Support::Unsupported
} else {
support()
}
}
#[allow(dead_code)]
pub(crate) fn cookie_matches(url: &str, domain: &str, path: &str, secure: bool) -> bool {
let Ok(url) = url::Url::parse(url) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") || (secure && url.scheme() != "https") {
return false;
}
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
let domain = domain.to_ascii_lowercase();
let bare = domain.trim_start_matches('.');
let domain_match =
host == bare || (domain.starts_with('.') && host.ends_with(&format!(".{bare}")));
let path = if path.is_empty() { "/" } else { path };
domain_match
&& (url.path() == path
|| (url.path().starts_with(path)
&& (path.ends_with('/') || url.path().as_bytes().get(path.len()) == Some(&b'/'))))
}
fn cookie_source_valid(source: &str, header: &str) -> bool {
if header.contains(['\r', '\n']) {
return false;
}
let Ok(url) = url::Url::parse(source) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
let mut fields = header.split(';');
let Some((name, _)) = fields.next().and_then(|field| field.trim().split_once('=')) else {
return false;
};
let mut secure = false;
let mut domain_set = false;
let mut root_path = false;
for field in fields {
let (key, value) = field.trim().split_once('=').unwrap_or((field.trim(), ""));
if key.eq_ignore_ascii_case("domain") {
domain_set = true;
let domain = value.trim().trim_start_matches('.').to_ascii_lowercase();
if psl::suffix(domain.as_bytes())
.is_some_and(|suffix| suffix.is_known() && suffix.as_bytes() == domain.as_bytes())
{
return false;
}
if domain.is_empty()
|| !(host == domain
|| (url::Host::parse(&host).is_ok_and(|h| matches!(h, url::Host::Domain(_)))
&& host.ends_with(&format!(".{domain}"))))
{
return false;
}
} else if key.eq_ignore_ascii_case("secure") {
secure = true;
} else if key.eq_ignore_ascii_case("path") {
root_path = value.trim() == "/";
}
}
if secure && url.scheme() != "https" {
return false;
}
if name.starts_with("__Secure-") && !secure {
return false;
}
if name.starts_with("__Host-") && (!secure || domain_set || !root_path) {
return false;
}
true
}
#[cfg(any(
all(any(feature = "appkit", feature = "gtk"), target_os = "macos"),
all(feature = "uikit", target_os = "ios")
))]
mod storage_apple;
pub fn profile_support() -> day_spec::Support {
#[cfg(all(feature = "material", target_os = "android"))]
let result = android_impl::capability("supportsProfiles");
#[cfg(not(all(feature = "material", target_os = "android")))]
let result = {
#[cfg(any(
all(any(feature = "appkit", feature = "gtk"), target_os = "macos"),
all(feature = "uikit", target_os = "ios")
))]
if !storage_apple::named_profiles_available() {
return day_spec::Support::Emulated;
}
if cfg!(all(feature = "arkui", target_env = "ohos")) {
day_spec::Support::Emulated
} else if cfg!(all(feature = "dom", target_arch = "wasm32")) {
day_spec::Support::Unsupported
} else {
support()
}
};
result
}
pub fn clear_data_support() -> day_spec::Support {
#[cfg(all(feature = "material", target_os = "android"))]
let result = android_impl::capability("supportsDataClearing");
#[cfg(not(all(feature = "material", target_os = "android")))]
let result = if cfg!(any(
all(feature = "arkui", target_env = "ohos"),
all(feature = "dom", target_arch = "wasm32")
)) {
day_spec::Support::Unsupported
} else {
support()
};
result
}
const DATA_REQUEST: &str = "day-web-data:";
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WebProps {
pub renderer: WebRenderer,
pub profile: WebProfile,
pub resources: Option<ResourceProvider>,
pub external_links: bool,
pub tabs: Option<TabLinkLabels>,
pub url: String,
pub session: u64,
pub inline_root: String,
pub inline_start: String,
pub inline_assets: bool,
pub transparent: bool,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct WebSession(u64);
impl WebSession {
pub fn global(key: &'static str) -> WebSession {
day_core::tls_group! {
static KEYS: RefCell<HashMap<&'static str, u64>> = RefCell::new(HashMap::new());
static NEXT: Cell<u64> = const { Cell::new(1) };
}
WebSession(KEYS.with(|m| {
*m.borrow_mut().entry(key).or_insert_with(|| {
NEXT.with(|c| {
let v = c.get();
c.set(v + 1);
v
})
})
}))
}
pub fn id(self) -> u64 {
self.0
}
}
const LINK_REPORT: f64 = -1.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinkPolicy {
OpenSystem,
InView,
Ignore,
}
#[allow(dead_code)]
pub(crate) fn split_page(page: &str) -> (&str, &str) {
match page.find(['?', '#']) {
Some(i) => page.split_at(i),
None => (page, ""),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InlineSite {
root: String,
}
impl InlineSite {
pub fn root(&self) -> &str {
&self.root
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PrepareError {
MissingIndex(String),
Extract(String),
}
impl std::fmt::Display for PrepareError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PrepareError::MissingIndex(root) => {
write!(f, "no index.html under resource/assets/{root}")
}
PrepareError::Extract(e) => write!(f, "extracting the inline site: {e}"),
}
}
}
pub struct PrepareSite {
result: Option<Result<InlineSite, PrepareError>>,
}
impl Future for PrepareSite {
type Output = Result<InlineSite, PrepareError>;
fn poll(mut self: Pin<&mut Self>, _cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
match self.result.take() {
Some(r) => Poll::Ready(r),
None => Poll::Pending,
}
}
}
pub trait AssetDirSiteExt {
fn prepare_site(self) -> PrepareSite;
}
impl AssetDirSiteExt for day_core::AssetDir {
fn prepare_site(self) -> PrepareSite {
let root = self.as_str().trim_matches('/').to_string();
#[cfg(target_arch = "wasm32")]
let result = Ok(InlineSite { root });
#[cfg(not(target_arch = "wasm32"))]
let result = {
let index = day_spec::AssetName::dynamic(format!("{root}/index.html"));
match day_spec::resource(index) {
Some(_) => prepare_backend(root),
None => Err(PrepareError::MissingIndex(root)),
}
};
PrepareSite {
result: Some(result),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn prepare_backend(root: String) -> Result<InlineSite, PrepareError> {
#[cfg(all(feature = "gtk", not(target_os = "macos"), not(windows)))]
if let Err(e) = gtk_impl::extract_site(&root) {
return Err(PrepareError::Extract(e));
}
Ok(InlineSite { root })
}
pub trait IntoInlineSite {
fn into_inline_site(self) -> InlineSite;
}
impl IntoInlineSite for InlineSite {
fn into_inline_site(self) -> InlineSite {
self
}
}
impl IntoInlineSite for day_core::AssetDir {
fn into_inline_site(self) -> InlineSite {
InlineSite {
root: self.as_str().trim_matches('/').to_string(),
}
}
}
pub fn inline_support() -> day_spec::Support {
if cfg!(any(
all(
any(feature = "appkit", feature = "gtk"),
target_os = "macos"
),
all(feature = "uikit", target_os = "ios"),
all(feature = "material", target_os = "android"),
feature = "qt",
all(feature = "xaml", windows),
all(feature = "gtk", not(target_os = "macos")),
all(feature = "arkui", target_env = "ohos"),
all(feature = "dom", target_arch = "wasm32"),
)) {
day_spec::Support::Native
} else {
day_spec::Support::Unsupported
}
}
pub fn force_reload_support() -> day_spec::Support {
if cfg!(all(feature = "dom", target_arch = "wasm32")) {
day_spec::Support::Unsupported
} else {
support()
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum WebPatch {
Load(String),
Back,
Forward,
Stop,
Reload,
ForceReload,
Eval {
req: u64,
script: String,
},
}
const SEP: char = '\u{1f}';
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EvalError {
Unsupported,
Threw { name: String, message: String },
ViewGone,
Engine(String),
}
impl std::fmt::Display for EvalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EvalError::Unsupported => write!(f, "javascript evaluation is unsupported here"),
EvalError::Threw { name, message } => write!(f, "{name}: {message}"),
EvalError::ViewGone => write!(f, "the web view is gone"),
EvalError::Engine(raw) => write!(f, "undecodable reply: {raw}"),
}
}
}
fn js_string_literal(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn wrap_script(script: &str) -> String {
let src = js_string_literal(script);
format!(
"(function(){{var v;\
try{{v=eval({src});}}\
catch(e){{return \"0\\u001f\"+((e&&e.name)||\"Error\")+\"\\u001f\"+((e&&e.message)||String(e));}}\
try{{var s=JSON.stringify(v);return \"1\\u001f\"+(s===undefined?\"null\":s);}}\
catch(e){{return \"0\\u001f\"+((e&&e.name)||\"TypeError\")+\"\\u001f\"+((e&&e.message)||String(e));}}\
}})()"
)
}
#[allow(dead_code)]
pub(crate) fn engine_error(name: &str, message: &str) -> String {
format!("0{SEP}{name}{SEP}{message}")
}
fn decode(payload: &str) -> Result<String, EvalError> {
match payload.split_once(SEP) {
Some(("1", json)) => Ok(json.to_string()),
Some(("0", rest)) => {
let (name, message) = rest.split_once(SEP).unwrap_or(("Error", rest));
Err(EvalError::Threw {
name: name.to_string(),
message: message.to_string(),
})
}
_ => Err(EvalError::Engine(payload.to_string())),
}
}
struct EvalShared {
result: RefCell<Option<Result<String, EvalError>>>,
waker: RefCell<Option<std::task::Waker>>,
}
day_core::tls_group! {
static PRIVATE_BLOCKED_NODES: RefCell<std::collections::HashSet<RNode>> = RefCell::new(std::collections::HashSet::new());
static REQUEST_NODES: RefCell<HashMap<u64, RNode>> = RefCell::new(HashMap::new());
static NEXT_REQ: Cell<u64> = const { Cell::new(1) };
static PENDING: RefCell<HashMap<u64, Rc<EvalShared>>> = RefCell::new(HashMap::new());
static PENDING_CB: RefCell<HashMap<u64, day_core::PieceOperationDone>> =
RefCell::new(HashMap::new());
}
fn resolve(req: u64, payload: &str) {
REQUEST_NODES.with(|p| p.borrow_mut().remove(&req));
if let Some(done) = PENDING_CB.with(|p| p.borrow_mut().remove(&req)) {
done(decode(payload).map_err(|e| e.to_string()));
return;
}
let Some(shared) = PENDING.with(|p| p.borrow_mut().remove(&req)) else {
return;
};
*shared.result.borrow_mut() = Some(decode(payload));
if let Some(waker) = shared.waker.borrow_mut().take() {
waker.wake();
}
}
fn register_script_eval() {
let handler: day_core::PieceOperationFn = std::rc::Rc::new(|node, script, done| {
if PRIVATE_BLOCKED_NODES.with(|nodes| nodes.borrow().contains(&node))
|| (!renderer::surface::owns(node) && eval_support() != day_spec::Support::Native)
{
done(Err(EvalError::Unsupported.to_string()));
return;
}
let req = NEXT_REQ.with(|c| {
let v = c.get();
c.set(v + 1);
v
});
let wrapped = wrap_script(script);
REQUEST_NODES.with(|p| p.borrow_mut().insert(req, node));
PENDING_CB.with(|p| p.borrow_mut().insert(req, done));
renderer::send_patch(
node,
WebPatch::Eval {
req,
script: wrapped,
},
);
});
day_core::register_piece_operation(KIND, "day.webview.eval", handler.clone());
day_core::register_piece_operation(
renderer::surface::KIND,
"day.webview.eval",
Rc::new(move |node, script, done| {
if renderer::surface::owns(node) {
handler(node, script, done);
} else {
done(Err(EvalError::Unsupported.to_string()));
}
}),
);
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)]
pub struct NavigationState {
pub url: String,
pub title: String,
pub can_go_back: bool,
pub can_go_forward: bool,
pub loading: bool,
}
#[derive(Clone, Copy)]
pub struct JsHandle {
node: Signal<Option<RNode>>,
blocked: Signal<bool>,
renderer: Signal<WebRenderer>,
}
impl Default for JsHandle {
fn default() -> Self {
Self::new()
}
}
impl JsHandle {
pub fn renderer(&self) -> WebRenderer {
self.renderer.get_untracked()
}
#[track_caller]
pub fn new() -> Self {
JsHandle {
node: Signal::new(None),
blocked: Signal::new(false),
renderer: Signal::new(WebRenderer::default()),
}
}
fn data_request(&self, operation: &str, url: &str, cookie: &str) -> EvalFuture {
let mut future = self.eval("");
future.script = Some(format!(
"{DATA_REQUEST}{}",
serde_json::json!({"operation":operation,"url":url,"cookie":cookie})
));
future
}
pub async fn navigation_state(&self) -> Result<NavigationState, EvalError> {
let reply = self.data_request("navigation", "", "").await?;
serde_json::from_str(&reply).map_err(|_| EvalError::Unsupported)
}
pub async fn cookie_header(&self, url: &str) -> Result<String, EvalError> {
if !url::Url::parse(url).is_ok_and(|url| matches!(url.scheme(), "http" | "https")) {
return Err(EvalError::Unsupported);
}
let reply = self.data_request("cookies", url, "").await?;
serde_json::from_str(&reply).map_err(|_| EvalError::Unsupported)
}
pub fn set_cookie(&self, url: &str, cookie: &str) -> EvalFuture {
let future = self.data_request("set-cookie", url, cookie);
if !cookie_source_valid(url, cookie) {
*future.shared.result.borrow_mut() = Some(Err(EvalError::Unsupported));
}
future
}
pub fn clear_data(&self) -> EvalFuture {
self.data_request("clear", "", "")
}
pub fn eval(&self, script: impl AsRef<str>) -> EvalFuture {
EvalFuture {
req: NEXT_REQ.with(|c| {
let v = c.get();
c.set(v + 1);
v
}),
node: self.node,
blocked: self.blocked,
script: Some(wrap_script(script.as_ref())),
shared: Rc::new(EvalShared {
result: RefCell::new(None),
waker: RefCell::new(None),
}),
sent: false,
}
}
}
pub struct EvalFuture {
blocked: Signal<bool>,
req: u64,
node: Signal<Option<RNode>>,
script: Option<String>,
shared: Rc<EvalShared>,
sent: bool,
}
impl Future for EvalFuture {
type Output = Result<String, EvalError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
if let Some(result) = self.shared.result.borrow_mut().take() {
return Poll::Ready(result);
}
let Some(blocked) = day_reactive::untrack(|| self.blocked.try_get()) else {
return Poll::Ready(Err(EvalError::ViewGone));
};
if blocked {
return Poll::Ready(Err(EvalError::Unsupported));
}
if !self.sent {
self.sent = true;
let Some(node) = day_reactive::untrack(|| self.node.try_get()).flatten() else {
return Poll::Ready(Err(EvalError::ViewGone));
};
if !renderer::surface::owns(node) && eval_support() != day_spec::Support::Native {
return Poll::Ready(Err(EvalError::Unsupported));
}
let (req, script) = (self.req, self.script.take().unwrap_or_default());
REQUEST_NODES.with(|p| p.borrow_mut().insert(req, node));
PENDING.with(|p| p.borrow_mut().insert(req, self.shared.clone()));
renderer::send_patch(node, WebPatch::Eval { req, script });
if let Some(result) = self.shared.result.borrow_mut().take() {
return Poll::Ready(result);
}
}
*self.shared.waker.borrow_mut() = Some(cx.waker().clone());
Poll::Pending
}
}
impl Drop for EvalFuture {
fn drop(&mut self) {
REQUEST_NODES.with(|p| p.borrow_mut().remove(&self.req));
PENDING.with(|p| p.borrow_mut().remove(&self.req));
}
}
pub fn eval_support() -> day_spec::Support {
if cfg!(any(
all(
any(feature = "appkit", feature = "gtk"),
target_os = "macos"
),
all(feature = "uikit", target_os = "ios"),
feature = "qt",
all(feature = "dom", target_arch = "wasm32"),
all(feature = "gtk", not(target_os = "macos")),
all(feature = "xaml", target_os = "windows"),
all(feature = "material", target_os = "android"),
all(feature = "arkui", target_env = "ohos"),
)) {
day_spec::Support::Native
} else {
day_spec::Support::Unsupported
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct NewTabRequest {
pub url: String,
pub background: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TabLinkLabels {
pub foreground: String,
pub background: String,
}
const NEW_TAB_REPORT: f64 = -4.0;
fn new_tab_event(url: String, background: bool) -> Event {
Event::Custom {
tag: "webview:new-tab",
num: NEW_TAB_REPORT,
text: serde_json::to_string(&NewTabRequest { url, background }).unwrap_or_default(),
}
}
#[cfg(all(target_os = "ios", feature = "uikit"))]
const TAB_MODIFIER_SCRIPT: &str = r#"(() => {
const click = e => {
const a = e.composedPath().find(n => n?.tagName === 'A' && n.href);
if (!a || !(e.metaKey || e.ctrlKey || e.button === 1)) return;
e.preventDefault(); e.stopImmediatePropagation();
location.href = 'day-tab://open?url=' + encodeURIComponent(a.href) + '&background=' + (e.shiftKey ? '0' : '1');
};
addEventListener('click', click, true); addEventListener('auxclick', click, true);
})();"#;
#[cfg(all(target_os = "ios", feature = "uikit"))]
fn tab_scheme_request(input: &str) -> Option<NewTabRequest> {
let url = url::Url::parse(input).ok()?;
if url.scheme() != "day-tab" {
return None;
}
let pairs: HashMap<_, _> = url.query_pairs().into_owned().collect();
Some(NewTabRequest {
url: pairs.get("url")?.clone(),
background: pairs.get("background").is_some_and(|v| v == "1"),
})
}
type LinkDecider = Rc<dyn Fn(&str) -> LinkPolicy>;
type NewTabHandler = (TabLinkLabels, Rc<dyn Fn(&NewTabRequest)>);
pub struct WebView {
renderer: WebRenderer,
profile: WebProfile,
resources: Option<ResourceProvider>,
url: AnyBinding<String>,
go: Option<Trigger>,
back: Option<Trigger>,
forward: Option<Trigger>,
stop: Option<Trigger>,
reload: Option<Trigger>,
force_reload: Option<Trigger>,
js: Option<JsHandle>,
session: Option<WebSession>,
inline: Option<InlineSite>,
inline_start: String,
inline_assets: bool,
transparent: bool,
on_link: Option<LinkDecider>,
tabs: Option<NewTabHandler>,
on_load: Option<Rc<dyn Fn()>>,
}
pub fn web_view(url: impl Binding<String>) -> WebView {
#[cfg(all(feature = "dom", target_arch = "wasm32"))]
dom_impl::register();
register_script_eval();
WebView {
renderer: WebRenderer::default(),
profile: WebProfile::default(),
url: AnyBinding::new(url),
go: None,
back: None,
forward: None,
stop: None,
reload: None,
force_reload: None,
js: None,
session: None,
inline: None,
inline_start: String::new(),
inline_assets: false,
transparent: false,
on_link: None,
tabs: None,
on_load: None,
resources: None,
}
}
pub fn web_view_inline(site: impl IntoInlineSite) -> WebView {
let mut v = web_view(Signal::new(String::new()));
v.inline = Some(site.into_inline_site());
v.inline_start = "index.html".into();
v
}
pub fn web_view_resources(provider: ResourceProvider, page: &str) -> WebView {
let mut view = web_view(Signal::new(provider.url(page)));
view.resources = Some(provider);
view
}
#[day_pieces::builder(WebViewBuilder)]
impl WebView {
pub fn renderer(mut self, renderer: WebRenderer) -> Result<Self, RendererUnavailable> {
if !renderer.available() {
return Err(RendererUnavailable(renderer));
}
self.renderer = renderer;
Ok(self)
}
pub fn profile(mut self, profile: WebProfile) -> Self {
self.profile = profile;
self
}
pub fn url_binding(mut self, url: impl Binding<String>) -> Self {
let url = AnyBinding::new(url);
if url.get_untracked().is_empty() {
url.set(self.url.get_untracked());
}
self.url = url;
self
}
pub fn on_load(mut self, callback: impl Fn() + 'static) -> Self {
self.on_load = Some(Rc::new(callback));
self
}
pub fn go(mut self, trigger: Trigger) -> Self {
self.go = Some(trigger);
self
}
pub fn back(mut self, trigger: Trigger) -> Self {
self.back = Some(trigger);
self
}
pub fn forward(mut self, trigger: Trigger) -> Self {
self.forward = Some(trigger);
self
}
pub fn stop(mut self, trigger: Trigger) -> Self {
self.stop = Some(trigger);
self
}
pub fn reload(mut self, trigger: Trigger) -> Self {
self.reload = Some(trigger);
self
}
pub fn force_reload(mut self, trigger: Trigger) -> Self {
self.force_reload = Some(trigger);
self
}
pub fn js(mut self, handle: JsHandle) -> Self {
self.js = Some(handle);
self
}
pub fn session(mut self, session: WebSession) -> Self {
self.session = Some(session);
self
}
pub fn start_page(mut self, page: impl Into<String>) -> Self {
self.inline_start = page.into();
self
}
pub fn app_assets(mut self) -> Self {
self.inline_assets = true;
self
}
pub fn transparent(mut self) -> Self {
self.transparent = true;
self
}
pub fn on_new_tab<M1, M2>(
mut self,
foreground_label: impl IntoText<M1>,
background_label: impl IntoText<M2>,
f: impl Fn(&NewTabRequest) + 'static,
) -> Self {
self.tabs = Some((
TabLinkLabels {
foreground: foreground_label.into_text().initial(),
background: background_label.into_text().initial(),
},
Rc::new(f),
));
self
}
pub fn on_external_link(mut self, f: impl Fn(&str) -> LinkPolicy + 'static) -> Self {
self.on_link = Some(Rc::new(f));
self
}
}
pub fn support() -> day_spec::Support {
if cfg!(all(feature = "dom", target_arch = "wasm32")) {
day_spec::Support::Emulated
} else if cfg!(any(
all(
any(feature = "appkit", feature = "gtk"),
target_os = "macos"
),
all(feature = "uikit", target_os = "ios"),
all(feature = "material", target_os = "android"),
all(feature = "gtk", not(target_os = "macos")),
feature = "qt",
all(feature = "xaml", windows),
all(feature = "arkui", target_env = "ohos"),
)) {
day_spec::Support::Native
} else {
day_spec::Support::Unsupported
}
}
impl Piece for WebView {
fn build(self, cx: &mut BuildCx) -> RNode {
let WebView {
renderer,
profile,
url,
go,
back,
forward,
stop,
reload,
force_reload,
js,
session,
inline,
inline_start,
inline_assets,
transparent,
on_link,
tabs,
on_load,
mut resources,
} = self;
let session_id = session.map(WebSession::id).unwrap_or(0);
let session_id = if session_id == 0 || profile == WebProfile::default() {
session_id
} else {
profile.storage_key().bytes().fold(session_id, |h, b| {
(h ^ u64::from(b)).wrapping_mul(1099511628211)
})
};
let mut initial_url = url.get_untracked();
if let (Some(_session), Some(provider)) = (session, &resources) {
day_core::tls_group! {
static SESSION_RESOURCES: RefCell<HashMap<(WebRenderer, u64), (ResourceProvider, String)>> = RefCell::new(HashMap::new());
}
let retained = SESSION_RESOURCES.with(|m| {
m.borrow_mut()
.entry((renderer, session_id))
.or_insert_with(|| (provider.clone(), initial_url.clone()))
.clone()
});
resources = Some(retained.0);
initial_url = retained.1;
url.set(initial_url.clone());
}
let blocked_private = (profile.private
&& renderer.private_browsing_support() == day_spec::Support::Unsupported)
|| (renderer != WebRenderer::WebView
&& renderer.profile_support() == day_spec::Support::Unsupported
&& profile != WebProfile::default());
let initial = WebProps {
renderer,
profile,
resources: if blocked_private {
None
} else {
resources.clone()
},
external_links: on_link.is_some(),
tabs: tabs.as_ref().map(|(labels, _)| labels.clone()),
url: if blocked_private {
"about:blank".into()
} else {
initial_url
},
session: session_id,
inline_root: if blocked_private {
String::new()
} else {
inline.as_ref().map(|s| s.root.clone()).unwrap_or_default()
},
inline_start: if inline.is_some() {
inline_start
} else {
String::new()
},
inline_assets: inline.is_some() && inline_assets,
transparent,
};
let surface = renderer::surface::get(renderer);
let flex = Flex {
grow_w: true,
grow_h: true,
..Default::default()
};
let node = if surface.is_some() {
cx.leaf(
renderer::surface::KIND,
&day_spec::props::CanvasProps::default(),
flex,
)
} else {
cx.leaf(KIND, &initial, flex)
};
if let Some(host) = surface {
renderer::surface::attach(host, cx, node, &initial);
}
day_reactive::Scope::current().on_cleanup(move || {
let requests = REQUEST_NODES.with(|p| {
p.borrow()
.iter()
.filter_map(|(req, owner)| (*owner == node).then_some(*req))
.collect::<Vec<_>>()
});
for req in requests {
REQUEST_NODES.with(|p| p.borrow_mut().remove(&req));
if let Some(done) = PENDING_CB.with(|p| p.borrow_mut().remove(&req)) {
done(Err(EvalError::ViewGone.to_string()));
}
if let Some(shared) = PENDING.with(|p| p.borrow_mut().remove(&req)) {
*shared.result.borrow_mut() = Some(Err(EvalError::ViewGone));
if let Some(waker) = shared.waker.borrow_mut().take() {
waker.wake();
}
}
}
});
if blocked_private {
PRIVATE_BLOCKED_NODES.with(|nodes| nodes.borrow_mut().insert(node));
day_reactive::Scope::current().on_cleanup(move || {
PRIVATE_BLOCKED_NODES.with(|nodes| nodes.borrow_mut().remove(&node));
});
}
let send = move |patch: WebPatch| {
if blocked_private {
if let WebPatch::Eval { req, .. } = patch {
resolve(
req,
&engine_error("Unsupported", "private browsing unavailable"),
);
}
return;
}
renderer::send_patch(node, patch);
};
if let Some(go) = go {
watch(
move || go.track(),
move |_, _| send(WebPatch::Load(url.get_untracked())),
);
}
if let Some(back) = back {
watch(move || back.track(), move |_, _| send(WebPatch::Back));
}
if let Some(forward) = forward {
watch(move || forward.track(), move |_, _| send(WebPatch::Forward));
}
if let Some(stop) = stop {
watch(move || stop.track(), move |_, _| send(WebPatch::Stop));
}
if let Some(reload) = reload {
watch(move || reload.track(), move |_, _| send(WebPatch::Reload));
}
if let Some(force_reload) = force_reload {
watch(
move || force_reload.track(),
move |_, _| send(WebPatch::ForceReload),
);
}
if let Some(js) = js {
js.renderer.set(renderer);
js.blocked.set(blocked_private);
js.node.set(Some(node));
day_reactive::Scope::current().on_cleanup(move || {
if js.node.try_get() == Some(Some(node)) {
js.node.set(None);
}
});
}
#[cfg(all(feature = "arkui", target_env = "ohos"))]
let registration = RefCell::new(None);
#[cfg(all(feature = "arkui", target_env = "ohos"))]
let resource_start = initial.url.clone();
cx.on(node, move |ev| {
let _keep_resources_alive = &resources;
if let Event::Custom { num, text, .. } = ev {
#[cfg(all(feature = "arkui", target_env = "ohos"))]
if *num == -3.0 {
if let Some(provider) = &resources {
*registration.borrow_mut() = resources_arkui::install(provider.id(), text);
if registration.borrow().is_some() {
send(WebPatch::Load(resource_start.clone()));
}
}
return;
}
if *num >= 1.0 {
resolve(*num as u64, text);
} else if *num == NEW_TAB_REPORT {
if let Some((_, callback)) = &tabs
&& let Ok(request) = serde_json::from_str::<NewTabRequest>(text)
{
callback(&request);
}
} else if *num == LINK_REPORT {
let policy = on_link
.as_ref()
.map(|f| f(text))
.unwrap_or(LinkPolicy::OpenSystem);
match policy {
LinkPolicy::OpenSystem => day_core::open_url(text),
LinkPolicy::InView => {
send(WebPatch::Load(text.clone()));
}
LinkPolicy::Ignore => {}
}
} else if *num == 0.0 {
url.set(text.clone());
if let Some(callback) = &on_load {
callback();
}
}
}
});
node
}
}
#[cfg(all(target_os = "macos", feature = "gtk"))]
mod gtk_assets;
#[cfg(all(target_os = "macos", feature = "gtk"))]
#[path = "lib-gtk-macos.rs"]
mod gtk_macos;
#[cfg(all(target_os = "macos", any(feature = "appkit", feature = "gtk")))]
mod macos_web;
day_pieces::glue_modules!(appkit, qt, uikit, material, xaml, arkui, dom);
#[cfg(any(test, all(feature = "xaml", windows)))]
mod xaml_path;
#[cfg(all(feature = "gtk", not(target_os = "macos"), not(windows)))]
#[path = "lib-gtk.rs"]
mod gtk_impl;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum WebRenderer {
#[default]
WebView,
Servo,
}
impl WebRenderer {
pub fn available(self) -> bool {
match self {
Self::WebView => support() != day_spec::Support::Unsupported,
Self::Servo => renderer::capabilities(self).is_some(),
}
}
pub fn private_browsing_support(self) -> day_spec::Support {
match self {
Self::WebView => private_browsing_support(),
Self::Servo => renderer::capabilities(self)
.map_or(day_spec::Support::Unsupported, |caps| caps.private_browsing),
}
}
pub fn profile_support(self) -> day_spec::Support {
match self {
Self::WebView => profile_support(),
Self::Servo => renderer::capabilities(self)
.map_or(day_spec::Support::Unsupported, |caps| caps.profiles),
}
}
pub fn clear_data_support(self) -> day_spec::Support {
match self {
Self::WebView => clear_data_support(),
Self::Servo => renderer::capabilities(self)
.map_or(day_spec::Support::Unsupported, |caps| caps.clear_data),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RendererUnavailable(pub WebRenderer);
impl std::fmt::Display for RendererUnavailable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?} renderer is unavailable in this build", self.0)
}
}
impl std::error::Error for RendererUnavailable {}
#[cfg(test)]
mod tests {
#[test]
fn servo_selection_is_explicit_and_does_not_claim_native_profiles() {
assert_eq!(super::WebRenderer::default(), super::WebRenderer::WebView);
assert!(!super::WebRenderer::Servo.available());
assert_eq!(
super::WebRenderer::Servo.private_browsing_support(),
day_spec::Support::Unsupported
);
assert_eq!(
super::WebRenderer::Servo.profile_support(),
day_spec::Support::Unsupported
);
}
use super::*;
#[test]
fn an_unpolled_evaluation_survives_its_tab_being_closed() {
let scope = day_reactive::Scope::child();
let mut future = scope.enter(|| JsHandle::new().eval("1 + 1"));
scope.dispose();
let mut context = std::task::Context::from_waker(std::task::Waker::noop());
assert!(matches!(
Pin::new(&mut future).poll(&mut context),
Poll::Ready(Err(EvalError::ViewGone))
));
}
#[test]
fn profiles_keep_identity_and_privacy_separate() {
let profile = WebProfile::persistent("publication");
assert_eq!(
profile.storage_key(),
WebProfile::persistent("publication").storage_key()
);
assert_ne!(
profile.storage_key(),
WebProfile::private("publication").storage_key()
);
assert_ne!(
profile.storage_key(),
WebProfile::persistent("other").storage_key()
);
assert_ne!(
profile.clone().directory("/fixture/a").storage_key(),
profile.directory("/fixture/b").storage_key()
);
}
#[test]
fn response_cookies_cannot_impersonate_another_origin() {
assert!(cookie_source_valid(
"https://news.example.com/story",
"session=fixture; Domain=.example.com; Secure; HttpOnly"
));
assert!(!cookie_source_valid(
"https://news.example.co.uk/",
"session=fixture; Domain=co.uk"
));
assert!(!cookie_source_valid(
"https://project.github.io/",
"session=fixture; Domain=github.io"
));
assert!(!cookie_source_valid(
"https://unrelated.test/",
"session=fixture; Domain=example.com"
));
assert!(!cookie_source_valid(
"https://example.com/",
"session=fixture\r\nInjected: header"
));
assert!(!cookie_source_valid(
"http://example.com/",
"session=fixture; Secure"
));
assert!(!cookie_source_valid(
"https://example.com/",
"__Host-session=fixture; Secure; Domain=example.com; Path=/"
));
assert!(cookie_source_valid(
"https://example.com/",
"__Host-session=fixture; Secure; Path=/"
));
}
#[test]
fn cookie_credentials_respect_host_path_and_transport_boundaries() {
assert!(cookie_matches(
"https://news.example.com/account/page",
".example.com",
"/account",
true
));
assert!(!cookie_matches(
"http://news.example.com/account",
".example.com",
"/account",
true
));
assert!(!cookie_matches(
"https://evil-example.com/account",
".example.com",
"/account",
false
));
assert!(!cookie_matches(
"https://news.example.com/accounting",
".example.com",
"/account",
false
));
assert!(!cookie_matches(
"https://news.example.com/",
"example.com",
"/",
false
));
assert!(!cookie_matches(
"file:///account",
".example.com",
"/",
false
));
assert!(cookie_matches(
"https://EXAMPLE.com/",
"example.com",
"",
false
));
}
fn reply(parts: &[&str]) -> String {
parts.join(&SEP.to_string())
}
#[test]
fn decodes_a_value() {
assert_eq!(decode(&reply(&["1", "2"])), Ok("2".into()));
assert_eq!(decode(&reply(&["1", "null"])), Ok("null".into()));
let obj = r#"{"a":1,"b":"x"}"#;
assert_eq!(decode(&reply(&["1", obj])), Ok(obj.into()));
}
#[test]
fn decodes_a_throw() {
assert_eq!(
decode(&reply(&["0", "TypeError", "boom"])),
Err(EvalError::Threw {
name: "TypeError".into(),
message: "boom".into(),
})
);
}
#[test]
fn a_message_may_contain_the_separator() {
assert_eq!(
decode(&reply(&["0", "Error", "a", "b"])),
Err(EvalError::Threw {
name: "Error".into(),
message: reply(&["a", "b"]),
})
);
}
#[test]
fn an_escaped_separator_inside_json_survives() {
let json = r#""a\u001fb""#;
assert_eq!(decode(&reply(&["1", json])), Ok(json.into()));
}
#[test]
fn an_undecodable_reply_is_an_engine_error() {
assert_eq!(decode(""), Err(EvalError::Engine(String::new())));
assert_eq!(decode("null"), Err(EvalError::Engine("null".into())));
}
#[test]
fn engine_errors_round_trip() {
assert_eq!(
decode(&engine_error("WebKitError", "process gone")),
Err(EvalError::Threw {
name: "WebKitError".into(),
message: "process gone".into(),
})
);
}
#[test]
fn the_wrapper_is_lexically_sealed() {
for hostile in [
"1 + 1 // add",
"\"unterminated",
"}}})(){{{",
"a\nb",
"x\\y",
] {
let js = wrap_script(hostile);
assert!(
js.trim_end().ends_with("})()"),
"wrapper not closed for {hostile:?}: {js}"
);
assert!(
!js.contains("\n"),
"raw newline leaked for {hostile:?}: {js}"
);
}
}
#[test]
fn a_start_page_keeps_its_query_out_of_the_path() {
assert_eq!(split_page("index.html"), ("index.html", ""));
assert_eq!(
split_page("player.html?src=hello&loop=1"),
("player.html", "?src=hello&loop=1")
);
assert_eq!(
split_page("docs/index.html#top"),
("docs/index.html", "#top")
);
}
#[test]
fn escapes_a_script_into_a_literal() {
assert_eq!(js_string_literal("a"), "\"a\"");
assert_eq!(js_string_literal("a\"b"), "\"a\\\"b\"");
assert_eq!(js_string_literal("a\\b"), "\"a\\\\b\"");
assert_eq!(js_string_literal("a\nb"), "\"a\\nb\"");
assert_eq!(js_string_literal("a\u{2028}b"), "\"a\\u2028b\"");
assert_eq!(js_string_literal("a\u{1}b"), "\"a\\u0001b\"");
}
}
#[cfg(test)]
mod external_link_tests {
use super::external_document_link as external;
#[test]
fn user_navigation_leaves_reader_but_loading_and_anchors_do_not() {
let page = Some("file:///tmp/fixture.html");
assert!(external(
page,
"https://example.test/story",
true,
false,
false
));
assert!(external(
page,
"https://example.test/story",
true,
true,
false
));
assert!(external(
page,
"https://example.test/story",
false,
true,
false
));
assert!(!external(
page,
"file:///tmp/fixture.html#note",
true,
false,
false
));
assert!(!external(
page,
"file:///tmp/next.html",
false,
false,
false
));
assert!(!external(
page,
"https://example.test/frame",
true,
false,
true
));
assert!(!external(page, "about:blank", false, true, false));
assert!(external(
Some("https://example.test/story?a=1#x"),
"https://example.test/story?a=2",
true,
false,
false
));
}
}