Skip to main content

cranpose_services/
uri_handler.rs

1use 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
33/// Installs a platform URI handler, replacing any previously installed one.
34///
35/// Registered handlers take precedence over the built-in per-platform opener,
36/// so a backend with main-thread UIKit access can open links the app sandbox
37/// forbids launching through a subprocess. The iOS backend registers a
38/// `UIApplication`-based opener this way (see `cranpose::ios`), because
39/// spawning `open`/`xdg-open` is not available inside the iOS sandbox.
40pub fn set_platform_uri_handler(handler: UriHandlerRef) {
41    PLATFORM_URI_HANDLER.with(|cell| *cell.borrow_mut() = Some(handler));
42}
43
44/// Removes any registered platform URI handler (tests and teardown).
45pub 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#[composable]
141pub fn ProvideUriHandler(content: impl FnOnce()) {
142    let uri_handler = cranpose_core::remember(default_uri_handler).with(|state| state.clone());
143    let uri_local = local_uri_handler();
144
145    CompositionLocalProvider(vec![uri_local.provides(uri_handler)], move || {
146        content();
147    });
148}
149
150#[cfg(test)]
151#[path = "tests/uri_handler_tests.rs"]
152mod tests;