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
31struct PlatformUriHandler;
32
33impl UriHandler for PlatformUriHandler {
34    fn open_uri(&self, uri: &str) -> Result<(), UriHandlerError> {
35        #[cfg(all(
36            not(target_arch = "wasm32"),
37            not(target_os = "android"),
38            feature = "uri-native"
39        ))]
40        {
41            open::that(uri).map_err(|err| UriHandlerError::OpenFailed(format!("{:?}", err)))?;
42            Ok(())
43        }
44
45        #[cfg(all(target_arch = "wasm32", feature = "uri-web"))]
46        {
47            let window = web_sys::window().ok_or(UriHandlerError::NoWindow)?;
48            let opened = window
49                .open_with_url_and_target(uri, "_blank")
50                .map_err(|err| UriHandlerError::OpenFailed(format!("{:?}", err)))?;
51            if opened.is_none() {
52                Err(UriHandlerError::PopupBlocked(uri.to_string()))
53            } else {
54                Ok(())
55            }
56        }
57
58        #[cfg(all(target_os = "android", feature = "uri-android"))]
59        {
60            webbrowser::open(uri).map_err(|err| UriHandlerError::OpenFailed(err.to_string()))?;
61            Ok(())
62        }
63
64        #[cfg(all(
65            not(target_arch = "wasm32"),
66            not(target_os = "android"),
67            not(feature = "uri-native")
68        ))]
69        {
70            let _ = uri;
71            Err(UriHandlerError::UnsupportedFeature {
72                operation: "native URI opening",
73                feature: "uri-native",
74            })
75        }
76
77        #[cfg(all(target_arch = "wasm32", not(feature = "uri-web")))]
78        {
79            let _ = uri;
80            Err(UriHandlerError::UnsupportedFeature {
81                operation: "web URI opening",
82                feature: "uri-web",
83            })
84        }
85
86        #[cfg(all(target_os = "android", not(feature = "uri-android")))]
87        {
88            let _ = uri;
89            Err(UriHandlerError::UnsupportedFeature {
90                operation: "Android URI opening",
91                feature: "uri-android",
92            })
93        }
94    }
95}
96
97pub fn default_uri_handler() -> UriHandlerRef {
98    Rc::new(PlatformUriHandler)
99}
100
101pub fn local_uri_handler() -> CompositionLocal<UriHandlerRef> {
102    thread_local! {
103        static LOCAL_URI_HANDLER: RefCell<Option<CompositionLocal<UriHandlerRef>>> = const { RefCell::new(None) };
104    }
105
106    LOCAL_URI_HANDLER.with(|cell| {
107        let mut local = cell.borrow_mut();
108        local
109            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_uri_handler, Rc::ptr_eq))
110            .clone()
111    })
112}
113
114#[allow(non_snake_case)]
115#[composable]
116pub fn ProvideUriHandler(content: impl FnOnce()) {
117    let uri_handler = cranpose_core::remember(default_uri_handler).with(|state| state.clone());
118    let uri_local = local_uri_handler();
119
120    CompositionLocalProvider(vec![uri_local.provides(uri_handler)], move || {
121        content();
122    });
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::run_test_composition;
129    use cranpose_core::CompositionLocalProvider;
130    use std::cell::RefCell;
131
132    struct TestUriHandler;
133
134    impl UriHandler for TestUriHandler {
135        fn open_uri(&self, _uri: &str) -> Result<(), UriHandlerError> {
136            Ok(())
137        }
138    }
139
140    #[test]
141    fn default_uri_handler_can_be_created() {
142        let handler = default_uri_handler();
143        assert_eq!(Rc::strong_count(&handler), 1);
144    }
145
146    #[cfg(all(
147        not(target_arch = "wasm32"),
148        not(target_os = "android"),
149        not(feature = "uri-native")
150    ))]
151    #[test]
152    fn default_uri_handler_reports_disabled_native_uri_feature() {
153        let error = default_uri_handler()
154            .open_uri("https://example.com")
155            .expect_err("native URI opening should be feature-gated");
156
157        assert!(matches!(
158            error,
159            UriHandlerError::UnsupportedFeature {
160                feature: "uri-native",
161                ..
162            }
163        ));
164    }
165
166    #[test]
167    fn local_uri_handler_can_be_overridden() {
168        let local = local_uri_handler();
169        let default_handler = default_uri_handler();
170        let custom_handler: UriHandlerRef = Rc::new(TestUriHandler);
171        let captured = Rc::new(RefCell::new(None));
172
173        {
174            let captured = Rc::clone(&captured);
175            let custom_handler = custom_handler.clone();
176            let local_for_provider = local.clone();
177            let local_for_read = local.clone();
178            run_test_composition(move || {
179                let captured = Rc::clone(&captured);
180                let custom_handler = custom_handler.clone();
181                let local_for_read = local_for_read.clone();
182                CompositionLocalProvider(
183                    vec![local_for_provider.provides(custom_handler)],
184                    move || {
185                        let current = local_for_read.current();
186                        *captured.borrow_mut() = Some(current);
187                    },
188                );
189            });
190        }
191
192        let current = captured
193            .borrow()
194            .as_ref()
195            .expect("handler captured")
196            .clone();
197        assert!(Rc::ptr_eq(&current, &custom_handler));
198        assert!(!Rc::ptr_eq(&current, &default_handler));
199    }
200
201    #[test]
202    fn provide_uri_handler_sets_current_handler() {
203        let local = local_uri_handler();
204        let captured = Rc::new(RefCell::new(None));
205
206        {
207            let captured = Rc::clone(&captured);
208            let local = local.clone();
209            run_test_composition(move || {
210                let captured = Rc::clone(&captured);
211                let local = local.clone();
212                ProvideUriHandler(move || {
213                    let current = local.current();
214                    *captured.borrow_mut() = Some(current);
215                });
216            });
217        }
218
219        assert!(captured.borrow().is_some());
220    }
221}