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