Skip to main content

cranpose_services/
uri_handler.rs

1use cranpose_core::compositionLocalOfWithPolicy;
2use cranpose_core::CompositionLocal;
3use cranpose_core::CompositionLocalProvider;
4use cranpose_macros::composable;
5use std::cell::RefCell;
6use std::rc::Rc;
7
8#[derive(thiserror::Error, Debug)]
9pub enum UriHandlerError {
10    #[error("Failed to open URL: {0}")]
11    OpenFailed(String),
12    #[error("No window object available")]
13    NoWindow,
14    #[error("Popup blocked for URL: {0}")]
15    PopupBlocked(String),
16    #[error("Opening external links is not supported on this platform: {0}")]
17    UnsupportedPlatform(String),
18    #[error("{operation} requires cranpose-services feature `{feature}`")]
19    UnsupportedFeature {
20        operation: &'static str,
21        feature: &'static str,
22    },
23}
24
25pub trait UriHandler {
26    fn open_uri(&self, uri: &str) -> Result<(), UriHandlerError>;
27}
28
29pub type UriHandlerRef = Rc<dyn UriHandler>;
30
31thread_local! {
32    static PLATFORM_URI_HANDLER: RefCell<Option<UriHandlerRef>> = const { RefCell::new(None) };
33}
34
35/// Installs a platform URI handler, replacing any previously installed one.
36///
37/// Registered handlers take precedence over the built-in per-platform opener,
38/// so a backend with main-thread UIKit access can open links the app sandbox
39/// forbids launching through a subprocess. The iOS backend registers a
40/// `UIApplication`-based opener this way (see `cranpose::ios`), because
41/// spawning `open`/`xdg-open` is not available inside the iOS sandbox.
42pub fn set_platform_uri_handler(handler: UriHandlerRef) {
43    PLATFORM_URI_HANDLER.with(|cell| *cell.borrow_mut() = Some(handler));
44}
45
46/// Removes any registered platform URI handler (tests and teardown).
47pub fn clear_platform_uri_handler() {
48    PLATFORM_URI_HANDLER.with(|cell| *cell.borrow_mut() = None);
49}
50
51fn registered_platform_uri_handler() -> Option<UriHandlerRef> {
52    PLATFORM_URI_HANDLER.with(|cell| cell.borrow().clone())
53}
54
55struct PlatformUriHandler;
56
57impl UriHandler for PlatformUriHandler {
58    fn open_uri(&self, uri: &str) -> Result<(), UriHandlerError> {
59        if let Some(handler) = registered_platform_uri_handler() {
60            return handler.open_uri(uri);
61        }
62
63        #[cfg(all(
64            not(target_arch = "wasm32"),
65            not(target_os = "android"),
66            feature = "uri-native"
67        ))]
68        {
69            open::that(uri).map_err(|err| UriHandlerError::OpenFailed(format!("{:?}", err)))?;
70            Ok(())
71        }
72
73        #[cfg(all(target_arch = "wasm32", feature = "uri-web"))]
74        {
75            let window = web_sys::window().ok_or(UriHandlerError::NoWindow)?;
76            let opened = window
77                .open_with_url_and_target(uri, "_blank")
78                .map_err(|err| UriHandlerError::OpenFailed(format!("{:?}", err)))?;
79            if opened.is_none() {
80                Err(UriHandlerError::PopupBlocked(uri.to_string()))
81            } else {
82                Ok(())
83            }
84        }
85
86        #[cfg(all(target_os = "android", feature = "uri-android"))]
87        {
88            webbrowser::open(uri).map_err(|err| UriHandlerError::OpenFailed(err.to_string()))?;
89            Ok(())
90        }
91
92        #[cfg(all(
93            not(target_arch = "wasm32"),
94            not(target_os = "android"),
95            not(feature = "uri-native")
96        ))]
97        {
98            let _ = uri;
99            Err(UriHandlerError::UnsupportedFeature {
100                operation: "native URI opening",
101                feature: "uri-native",
102            })
103        }
104
105        #[cfg(all(target_arch = "wasm32", not(feature = "uri-web")))]
106        {
107            let _ = uri;
108            Err(UriHandlerError::UnsupportedFeature {
109                operation: "web URI opening",
110                feature: "uri-web",
111            })
112        }
113
114        #[cfg(all(target_os = "android", not(feature = "uri-android")))]
115        {
116            let _ = uri;
117            Err(UriHandlerError::UnsupportedFeature {
118                operation: "Android URI opening",
119                feature: "uri-android",
120            })
121        }
122    }
123}
124
125pub fn default_uri_handler() -> UriHandlerRef {
126    Rc::new(PlatformUriHandler)
127}
128
129pub fn local_uri_handler() -> CompositionLocal<UriHandlerRef> {
130    thread_local! {
131        static LOCAL_URI_HANDLER: RefCell<Option<CompositionLocal<UriHandlerRef>>> = const { RefCell::new(None) };
132    }
133
134    LOCAL_URI_HANDLER.with(|cell| {
135        let mut local = cell.borrow_mut();
136        local
137            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_uri_handler, Rc::ptr_eq))
138            .clone()
139    })
140}
141
142#[allow(non_snake_case)]
143#[composable]
144pub fn ProvideUriHandler(content: impl FnOnce()) {
145    let uri_handler = cranpose_core::remember(default_uri_handler).with(|state| state.clone());
146    let uri_local = local_uri_handler();
147
148    CompositionLocalProvider(vec![uri_local.provides(uri_handler)], move || {
149        content();
150    });
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::run_test_composition;
157    use cranpose_core::CompositionLocalProvider;
158    use std::cell::RefCell;
159
160    struct TestUriHandler;
161
162    impl UriHandler for TestUriHandler {
163        fn open_uri(&self, _uri: &str) -> Result<(), UriHandlerError> {
164            Ok(())
165        }
166    }
167
168    #[test]
169    fn default_uri_handler_can_be_created() {
170        let handler = default_uri_handler();
171        assert_eq!(Rc::strong_count(&handler), 1);
172    }
173
174    #[cfg(all(
175        not(target_arch = "wasm32"),
176        not(target_os = "android"),
177        not(feature = "uri-native")
178    ))]
179    #[test]
180    fn default_uri_handler_reports_disabled_native_uri_feature() {
181        let error = default_uri_handler()
182            .open_uri("https://example.com")
183            .expect_err("native URI opening should be feature-gated");
184
185        assert!(matches!(
186            error,
187            UriHandlerError::UnsupportedFeature {
188                feature: "uri-native",
189                ..
190            }
191        ));
192    }
193
194    #[test]
195    fn local_uri_handler_can_be_overridden() {
196        let local = local_uri_handler();
197        let default_handler = default_uri_handler();
198        let custom_handler: UriHandlerRef = Rc::new(TestUriHandler);
199        let captured = Rc::new(RefCell::new(None));
200
201        {
202            let captured = Rc::clone(&captured);
203            let custom_handler = custom_handler.clone();
204            let local_for_provider = local.clone();
205            let local_for_read = local.clone();
206            run_test_composition(move || {
207                let captured = Rc::clone(&captured);
208                let custom_handler = custom_handler.clone();
209                let local_for_read = local_for_read.clone();
210                CompositionLocalProvider(
211                    vec![local_for_provider.provides(custom_handler)],
212                    move || {
213                        let current = local_for_read.current();
214                        *captured.borrow_mut() = Some(current);
215                    },
216                );
217            });
218        }
219
220        let current = captured
221            .borrow()
222            .as_ref()
223            .expect("handler captured")
224            .clone();
225        assert!(Rc::ptr_eq(&current, &custom_handler));
226        assert!(!Rc::ptr_eq(&current, &default_handler));
227    }
228
229    #[test]
230    fn provide_uri_handler_sets_current_handler() {
231        let local = local_uri_handler();
232        let captured = Rc::new(RefCell::new(None));
233
234        {
235            let captured = Rc::clone(&captured);
236            let local = local.clone();
237            run_test_composition(move || {
238                let captured = Rc::clone(&captured);
239                let local = local.clone();
240                ProvideUriHandler(move || {
241                    let current = local.current();
242                    *captured.borrow_mut() = Some(current);
243                });
244            });
245        }
246
247        assert!(captured.borrow().is_some());
248    }
249}