cranpose_services/
uri_handler.rs1use std::{cell::RefCell, 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
29thread_local! {
30 static PLATFORM_URI_HANDLER: RefCell<Option<UriHandlerRef>> = const { RefCell::new(None) };
31}
32
33pub fn set_platform_uri_handler(handler: UriHandlerRef) {
41 PLATFORM_URI_HANDLER.with(|cell| *cell.borrow_mut() = Some(handler));
42}
43
44pub fn clear_platform_uri_handler() {
46 PLATFORM_URI_HANDLER.with(|cell| *cell.borrow_mut() = None);
47}
48
49fn registered_platform_uri_handler() -> Option<UriHandlerRef> {
50 PLATFORM_URI_HANDLER.with(|cell| cell.borrow().clone())
51}
52
53struct PlatformUriHandler;
54
55impl UriHandler for PlatformUriHandler {
56 fn open_uri(&self, uri: &str) -> Result<(), UriHandlerError> {
57 if let Some(handler) = registered_platform_uri_handler() {
58 return handler.open_uri(uri);
59 }
60
61 #[cfg(all(
62 not(target_arch = "wasm32"),
63 not(target_os = "android"),
64 feature = "uri-native"
65 ))]
66 {
67 open::that(uri).map_err(|err| UriHandlerError::OpenFailed(format!("{:?}", err)))?;
68 Ok(())
69 }
70
71 #[cfg(all(target_arch = "wasm32", feature = "uri-web"))]
72 {
73 let window = web_sys::window().ok_or(UriHandlerError::NoWindow)?;
74 let opened = window
75 .open_with_url_and_target(uri, "_blank")
76 .map_err(|err| UriHandlerError::OpenFailed(format!("{:?}", err)))?;
77 if opened.is_none() {
78 Err(UriHandlerError::PopupBlocked(uri.to_string()))
79 } else {
80 Ok(())
81 }
82 }
83
84 #[cfg(all(target_os = "android", feature = "uri-android"))]
85 {
86 webbrowser::open(uri).map_err(|err| UriHandlerError::OpenFailed(err.to_string()))?;
87 Ok(())
88 }
89
90 #[cfg(all(
91 not(target_arch = "wasm32"),
92 not(target_os = "android"),
93 not(feature = "uri-native")
94 ))]
95 {
96 let _ = uri;
97 Err(UriHandlerError::UnsupportedFeature {
98 operation: "native URI opening",
99 feature: "uri-native",
100 })
101 }
102
103 #[cfg(all(target_arch = "wasm32", not(feature = "uri-web")))]
104 {
105 let _ = uri;
106 Err(UriHandlerError::UnsupportedFeature {
107 operation: "web URI opening",
108 feature: "uri-web",
109 })
110 }
111
112 #[cfg(all(target_os = "android", not(feature = "uri-android")))]
113 {
114 let _ = uri;
115 Err(UriHandlerError::UnsupportedFeature {
116 operation: "Android URI opening",
117 feature: "uri-android",
118 })
119 }
120 }
121}
122
123pub fn default_uri_handler() -> UriHandlerRef {
124 Rc::new(PlatformUriHandler)
125}
126
127pub fn local_uri_handler() -> CompositionLocal<UriHandlerRef> {
128 thread_local! {
129 static LOCAL_URI_HANDLER: RefCell<Option<CompositionLocal<UriHandlerRef>>> = const { RefCell::new(None) };
130 }
131
132 LOCAL_URI_HANDLER.with(|cell| {
133 let mut local = cell.borrow_mut();
134 local
135 .get_or_insert_with(|| compositionLocalOfWithPolicy(default_uri_handler, Rc::ptr_eq))
136 .clone()
137 })
138}
139
140#[allow(non_snake_case)]
141#[composable]
142pub fn ProvideUriHandler(content: impl FnOnce()) {
143 let uri_handler = cranpose_core::remember(default_uri_handler).with(|state| state.clone());
144 let uri_local = local_uri_handler();
145
146 CompositionLocalProvider(vec![uri_local.provides(uri_handler)], move || {
147 content();
148 });
149}
150
151#[cfg(test)]
152mod tests {
153 use std::cell::RefCell;
154
155 use cranpose_core::CompositionLocalProvider;
156
157 use super::*;
158 use crate::run_test_composition;
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}