Skip to main content

cranpose_services/
uri_handler.rs

1use std::rc::Rc;
2
3use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
4use cranpose_macros::composable;
5
6/// Reason why [`UriHandler::open_uri`] failed.
7#[derive(thiserror::Error, Debug)]
8pub enum UriHandlerError {
9    /// The platform failed to open the URL. The text describes the cause.
10    #[error("Failed to open URL: {0}")]
11    OpenFailed(String),
12    /// There is no browser window object to open the URL in.
13    #[error("No window object available")]
14    NoWindow,
15    /// The browser blocked the new window for the URL.
16    #[error("Popup blocked for URL: {0}")]
17    PopupBlocked(String),
18    /// The platform has no way to open external links.
19    #[error("Opening external links is not supported on this platform: {0}")]
20    UnsupportedPlatform(String),
21    /// The operation needs a `cranpose-services` feature that is turned off.
22    #[error("{operation} requires cranpose-services feature `{feature}`")]
23    UnsupportedFeature {
24        /// The operation that the build cannot run.
25        operation: &'static str,
26        /// Name of the `cranpose-services` feature that the operation needs.
27        feature: &'static str,
28    },
29}
30
31/// Opens a URI, such as a web link, in the application the platform picks for it.
32pub trait UriHandler {
33    /// Opens `uri` outside the app, for example in the system browser. Returns an error when the
34    /// platform cannot open it.
35    fn open_uri(&self, uri: &str) -> Result<(), UriHandlerError>;
36}
37
38/// Shared handle to a [`UriHandler`].
39pub type UriHandlerRef = Rc<dyn UriHandler>;
40
41/// Installs a platform URI handler, replacing any previously installed one.
42///
43/// Registered handlers take precedence over the built-in per-platform opener,
44/// so a backend with main-thread UIKit access can open links the app sandbox
45/// forbids launching through a subprocess. The iOS backend registers a
46/// `UIApplication`-based opener this way (see `cranpose::ios`), because
47/// spawning `open`/`xdg-open` is not available inside the iOS sandbox.
48pub fn set_platform_uri_handler(handler: UriHandlerRef) {
49    crate::thread_state::with(|state| *state.uri_handler.borrow_mut() = Some(handler));
50}
51
52/// Removes any registered platform URI handler (tests and teardown).
53pub fn clear_platform_uri_handler() {
54    crate::thread_state::with(|state| *state.uri_handler.borrow_mut() = None);
55}
56
57fn registered_platform_uri_handler() -> Option<UriHandlerRef> {
58    crate::thread_state::with(|state| state.uri_handler.borrow().clone())
59}
60
61struct PlatformUriHandler;
62
63impl UriHandler for PlatformUriHandler {
64    fn open_uri(&self, uri: &str) -> Result<(), UriHandlerError> {
65        if let Some(handler) = registered_platform_uri_handler() {
66            return handler.open_uri(uri);
67        }
68
69        #[cfg(all(
70            not(target_arch = "wasm32"),
71            not(target_os = "android"),
72            feature = "uri-native"
73        ))]
74        {
75            open::that(uri).map_err(|err| UriHandlerError::OpenFailed(format!("{err:?}")))?;
76            Ok(())
77        }
78
79        #[cfg(all(target_arch = "wasm32", feature = "uri-web"))]
80        {
81            let window = web_sys::window().ok_or(UriHandlerError::NoWindow)?;
82            let opened = window
83                .open_with_url_and_target(uri, "_blank")
84                .map_err(|err| UriHandlerError::OpenFailed(format!("{err:?}")))?;
85            if opened.is_none() {
86                Err(UriHandlerError::PopupBlocked(uri.to_string()))
87            } else {
88                Ok(())
89            }
90        }
91
92        #[cfg(all(target_os = "android", feature = "uri-android"))]
93        {
94            webbrowser::open(uri).map_err(|err| UriHandlerError::OpenFailed(err.to_string()))?;
95            Ok(())
96        }
97
98        #[cfg(all(
99            not(target_arch = "wasm32"),
100            not(target_os = "android"),
101            not(feature = "uri-native")
102        ))]
103        {
104            let _ = uri;
105            Err(UriHandlerError::UnsupportedFeature {
106                operation: "native URI opening",
107                feature: "uri-native",
108            })
109        }
110
111        #[cfg(all(target_arch = "wasm32", not(feature = "uri-web")))]
112        {
113            let _ = uri;
114            Err(UriHandlerError::UnsupportedFeature {
115                operation: "web URI opening",
116                feature: "uri-web",
117            })
118        }
119
120        #[cfg(all(target_os = "android", not(feature = "uri-android")))]
121        {
122            let _ = uri;
123            Err(UriHandlerError::UnsupportedFeature {
124                operation: "Android URI opening",
125                feature: "uri-android",
126            })
127        }
128    }
129}
130
131/// Returns the handler that opens a URI through a registered platform handler or else the
132/// built-in opener of the current platform.
133pub fn default_uri_handler() -> UriHandlerRef {
134    Rc::new(PlatformUriHandler)
135}
136
137/// The URI handler a composable reads. It is the platform handler unless a composition local
138/// provider above it supplies another one.
139pub fn local_uri_handler() -> CompositionLocal<UriHandlerRef> {
140    crate::composition_locals::cached_local(
141        |locals| &locals.uri_handler,
142        || compositionLocalOfWithPolicy(default_uri_handler, Rc::ptr_eq),
143    )
144}
145
146/// Provides the platform URI handler to `content` through [`local_uri_handler`].
147#[composable]
148pub fn ProvideUriHandler(content: impl FnOnce()) {
149    let uri_handler = cranpose_core::remember(default_uri_handler).with(|state| state.clone());
150    let uri_local = local_uri_handler();
151
152    CompositionLocalProvider([uri_local.provides(uri_handler)], move || {
153        content();
154    });
155}
156
157#[cfg(test)]
158#[path = "tests/uri_handler_tests.rs"]
159mod tests;