1#[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#[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#[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 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}
107pub 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#[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}
139fn 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;
197pub 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}
221pub 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#[derive(Clone, Debug, Default, PartialEq)]
241pub struct WebProps {
242 pub renderer: WebRenderer,
243 pub profile: WebProfile,
244 pub resources: Option<ResourceProvider>,
245 pub external_links: bool,
247 pub tabs: Option<TabLinkLabels>,
248 pub url: String,
249 pub session: u64,
253 pub inline_root: String,
258 pub inline_start: String,
261 pub inline_assets: bool,
267 pub transparent: bool,
270}
271
272#[derive(Clone, Copy, PartialEq, Eq, Debug)]
292pub struct WebSession(u64);
293
294impl WebSession {
295 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 pub fn id(self) -> u64 {
315 self.0
316 }
317}
318
319const LINK_REPORT: f64 = -1.0;
331
332#[derive(Clone, Copy, Debug, PartialEq, Eq)]
336pub enum LinkPolicy {
337 OpenSystem,
340 InView,
342 Ignore,
345}
346
347#[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#[derive(Clone, Debug, PartialEq, Eq)]
363pub struct InlineSite {
364 root: String,
365}
366
367impl InlineSite {
368 pub fn root(&self) -> &str {
370 &self.root
371 }
372}
373
374#[derive(Clone, Debug, PartialEq, Eq)]
376pub enum PrepareError {
377 MissingIndex(String),
379 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
395pub 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 None => Poll::Pending,
411 }
412 }
413}
414
415pub 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 #[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#[cfg(not(target_arch = "wasm32"))]
445fn prepare_backend(root: String) -> Result<InlineSite, PrepareError> {
446 #[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
456pub 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
477pub 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 feature = "qt",
491 all(feature = "xaml", windows),
494 all(feature = "gtk", not(target_os = "macos")),
496 all(feature = "arkui", target_env = "ohos"),
498 all(feature = "dom", target_arch = "wasm32"),
501 )) {
502 day_spec::Support::Native
503 } else {
504 day_spec::Support::Unsupported
505 }
506}
507
508pub 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#[derive(Clone, Debug, PartialEq)]
519pub enum WebPatch {
520 Load(String),
522 Back,
524 Forward,
525 Stop,
527 Reload,
529 ForceReload,
531 Eval {
534 req: u64,
535 script: String,
536 },
537}
538
539const SEP: char = '\u{1f}';
547
548#[derive(Clone, Debug, PartialEq, Eq)]
550pub enum EvalError {
551 Unsupported,
553 Threw { name: String, message: String },
556 ViewGone,
558 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
573fn 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
598fn 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#[allow(dead_code)]
640pub(crate) fn engine_error(name: &str, message: &str) -> String {
641 format!("0{SEP}{name}{SEP}{message}")
642}
643
644fn 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 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 static PENDING_CB: RefCell<HashMap<u64, day_core::PieceOperationDone>> =
676 RefCell::new(HashMap::new());
677}
678
679fn 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
697fn 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#[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#[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 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 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 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 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 pub fn clear_data(&self) -> EvalFuture {
816 self.data_request("clear", "", "")
817 }
818
819 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
842pub 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 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 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
898pub 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 all(feature = "material", target_os = "android"),
914 all(feature = "arkui", target_env = "ohos"),
916 )) {
917 day_spec::Support::Native
918 } else {
919 day_spec::Support::Unsupported
921 }
922}
923
924#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
929pub struct NewTabRequest {
930 pub url: String,
931 pub background: bool,
932}
933#[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#[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
973type 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
999pub fn web_view(url: impl Binding<String>) -> WebView {
1002 #[cfg(all(feature = "dom", target_arch = "wasm32"))]
1005 dom_impl::register();
1006 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
1032pub 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
1047pub 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 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 pub fn profile(mut self, profile: WebProfile) -> Self {
1067 self.profile = profile;
1068 self
1069 }
1070 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 pub fn on_load(mut self, callback: impl Fn() + 'static) -> Self {
1084 self.on_load = Some(Rc::new(callback));
1085 self
1086 }
1087 pub fn go(mut self, trigger: Trigger) -> Self {
1089 self.go = Some(trigger);
1090 self
1091 }
1092 pub fn back(mut self, trigger: Trigger) -> Self {
1094 self.back = Some(trigger);
1095 self
1096 }
1097 pub fn forward(mut self, trigger: Trigger) -> Self {
1099 self.forward = Some(trigger);
1100 self
1101 }
1102 pub fn stop(mut self, trigger: Trigger) -> Self {
1104 self.stop = Some(trigger);
1105 self
1106 }
1107 pub fn reload(mut self, trigger: Trigger) -> Self {
1109 self.reload = Some(trigger);
1110 self
1111 }
1112 pub fn force_reload(mut self, trigger: Trigger) -> Self {
1114 self.force_reload = Some(trigger);
1115 self
1116 }
1117 pub fn js(mut self, handle: JsHandle) -> Self {
1119 self.js = Some(handle);
1120 self
1121 }
1122 pub fn session(mut self, session: WebSession) -> Self {
1124 self.session = Some(session);
1125 self
1126 }
1127 pub fn start_page(mut self, page: impl Into<String>) -> Self {
1131 self.inline_start = page.into();
1132 self
1133 }
1134 pub fn app_assets(mut self) -> Self {
1142 self.inline_assets = true;
1143 self
1144 }
1145 pub fn transparent(mut self) -> Self {
1153 self.transparent = true;
1154 self
1155 }
1156 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
1185pub fn support() -> day_spec::Support {
1192 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 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 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 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 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 #[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 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#[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#[cfg(all(feature = "gtk", not(target_os = "macos"), not(windows)))]
1474#[path = "lib-gtk.rs"]
1475mod gtk_impl;
1476
1477#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
1479pub enum WebRenderer {
1480 #[default]
1482 WebView,
1483 Servo,
1485}
1486impl WebRenderer {
1487 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 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 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 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#[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 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 #[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 #[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 assert_eq!(decode("null"), Err(EvalError::Engine("null".into())));
1709 }
1710
1711 #[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 #[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 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}