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
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(¤t, &custom_handler));
198 assert!(!Rc::ptr_eq(¤t, &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}