cranpose_services/
uri_handler.rs1use 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
35pub fn set_platform_uri_handler(handler: UriHandlerRef) {
43 PLATFORM_URI_HANDLER.with(|cell| *cell.borrow_mut() = Some(handler));
44}
45
46pub 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(¤t, &custom_handler));
226 assert!(!Rc::ptr_eq(¤t, &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}