diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index eab2d7bb..ae3e5e94 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -28,6 +28,7 @@ use truapi_platform::{ }; use crate::SigningHostRuntime; +use crate::host_logic::dotns; use crate::subscription::Spawner; #[cfg(feature = "ws-bridge")] use crate::ws_bridge::{BridgeLogger, WsBridge, WsBridgeEndpoint, WsBridgeStartError}; @@ -383,6 +384,72 @@ impl From for v01::HostNavigateToError { } } +/// Native-friendly mirror of [`dotns::NavigateDecision`], so WebView hosts +/// classify navigations with the core's dotns logic instead of reimplementing +/// it. The open variants carry the ready-to-load canonical URL; `identifier` +/// stays lower-cased/NFC-normalized so hosts can compare it against the +/// current page's identifier for same-domain checks. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum NavigateDecision { + /// A `.dot` identifier plus path/query/hash suffix (no leading `/`). + DotName { + /// Lower-cased `.dot` host (e.g. `mytestapp.dot`). + identifier: String, + /// Path/query/hash suffix without a leading `/`. + path: String, + /// Loadable `https://` URL for this decision. + canonical_url: String, + }, + /// A `localhost[:port]` URL plus path/query/hash suffix (no leading `/`). + Localhost { + /// `localhost` with optional `:port` suffix. + host: String, + /// Path/query/hash suffix without a leading `/`. + path: String, + /// Loadable `http://` URL for this decision. + canonical_url: String, + }, + /// An absolute external URL with an `http(s):` scheme prepended if missing. + External { + /// Canonical URL string. + url: String, + }, + /// Input that fails every branch; must not be loaded. + Reject { + /// Human-readable reason for the rejection. + reason: String, + }, +} + +impl From for NavigateDecision { + fn from(decision: dotns::NavigateDecision) -> Self { + let canonical_url = decision.canonical_url(); + match decision { + dotns::NavigateDecision::DotName { identifier, path } => Self::DotName { + identifier, + path, + canonical_url: canonical_url.expect("DotName always has a canonical URL"), + }, + dotns::NavigateDecision::Localhost { host, path } => Self::Localhost { + host, + path, + canonical_url: canonical_url.expect("Localhost always has a canonical URL"), + }, + dotns::NavigateDecision::External { url } => Self::External { url }, + dotns::NavigateDecision::Reject { reason } => Self::Reject { reason }, + } + } +} + +/// Classify a navigation input exactly like the core's internal navigate host +/// call: `.dot` first, then `localhost`, then normalized external, with +/// everything else rejected. Pure and stateless; hosts call it on every +/// webview-internal navigation. +#[uniffi::export] +pub fn parse_navigate(input: String) -> NavigateDecision { + dotns::parse_navigate(&input).into() +} + /// Callback surface that iOS and Android implement. /// /// Threading contract: every callback is invoked on a background thread @@ -1759,4 +1826,69 @@ mod tests { core.stop_ws_bridge(); } + + #[test] + fn exported_parse_navigate_maps_every_variant() { + assert_eq!( + parse_navigate("mytestapp.dot/some/path?q=1".to_string()), + NavigateDecision::DotName { + identifier: "mytestapp.dot".to_string(), + path: "some/path?q=1".to_string(), + canonical_url: "https://mytestapp.dot/some/path?q=1".to_string(), + } + ); + assert_eq!( + parse_navigate("Example.DOT".to_string()), + NavigateDecision::DotName { + identifier: "example.dot".to_string(), + path: String::new(), + canonical_url: "https://example.dot".to_string(), + } + ); + assert_eq!( + parse_navigate("localhost:3000/path#h".to_string()), + NavigateDecision::Localhost { + host: "localhost:3000".to_string(), + path: "path#h".to_string(), + canonical_url: "http://localhost:3000/path#h".to_string(), + } + ); + assert_eq!( + parse_navigate("google.com".to_string()), + NavigateDecision::External { + url: "https://google.com/".to_string(), + } + ); + assert!(matches!( + parse_navigate("javascript:alert(1)".to_string()), + NavigateDecision::Reject { .. } + )); + } + + /// The FFI mirror's canonical URL must stay byte-identical to what the + /// runtime's internal navigate path computes from the same input. + #[test] + fn exported_canonical_url_matches_host_logic() { + let inputs = [ + "mytestapp.dot", + "mytestapp.dot/some/path?q=1#frag", + "localhost", + "localhost:3000/path", + "https://example.com/page", + ]; + for input in inputs { + let expected = crate::host_logic::dotns::parse_navigate(input) + .canonical_url() + .expect("open decision has a canonical URL"); + let actual = match parse_navigate(input.to_string()) { + NavigateDecision::DotName { canonical_url, .. } + | NavigateDecision::Localhost { canonical_url, .. } => canonical_url, + NavigateDecision::External { url } => url, + NavigateDecision::Reject { reason } => { + panic!("{input}: unexpected rejection: {reason}") + } + }; + assert_eq!(actual, expected, "{input}"); + } + } }