Skip to main content

cranpose_services/
share_sheet.rs

1//! System share sheet: hand a file (and optional text) to the OS share UI so
2//! the user can send it to another app.
3//!
4//! Like [`crate::uri_handler`], the compiled-in default is a no-op that reports
5//! [`ShareError::Unsupported`]; platform backends install a real implementation
6//! through [`set_platform_share_sheet`] (iOS `UIActivityViewController`, Android
7//! `ACTION_SEND`, the Web Share API). Desktop has no system share sheet — apps
8//! export through a save dialog instead — so `is_supported` is `false` there and
9//! the UI can offer "Save…" rather than "Share".
10
11use std::{cell::RefCell, rc::Rc};
12
13use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
14use cranpose_macros::composable;
15
16#[derive(thiserror::Error, Debug)]
17pub enum ShareError {
18    #[error("sharing is not supported on this platform")]
19    Unsupported,
20    #[error("failed to present the share sheet: {0}")]
21    Failed(String),
22}
23
24/// A single file to share, with an optional accompanying message/subject.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct ShareContent {
27    /// Suggested file name shown to the user and used by the receiving app.
28    pub file_name: String,
29    /// MIME type of `bytes` (e.g. `application/pdf`, `image/jpeg`).
30    pub mime_type: String,
31    /// File payload. Backends stage it to a temporary URL to share.
32    pub bytes: Vec<u8>,
33    /// Optional text/subject shared alongside the file.
34    pub text: Option<String>,
35}
36
37impl ShareContent {
38    /// A file to share, identified by name and MIME type.
39    pub fn file(
40        file_name: impl Into<String>,
41        mime_type: impl Into<String>,
42        bytes: Vec<u8>,
43    ) -> Self {
44        Self {
45            file_name: file_name.into(),
46            mime_type: mime_type.into(),
47            bytes,
48            text: None,
49        }
50    }
51
52    /// Attach accompanying text (subject / message) to the shared file.
53    pub fn with_text(mut self, text: impl Into<String>) -> Self {
54        self.text = Some(text.into());
55        self
56    }
57}
58
59/// The OS share sheet. Installed by the platform backend; the default is a
60/// no-op that reports [`ShareError::Unsupported`].
61pub trait ShareSheet {
62    /// Present the system share sheet for `content`. Fire-and-forget: it
63    /// resolves once presented and does not report the user's choice.
64    fn share(&self, content: ShareContent) -> Result<(), ShareError>;
65
66    /// Whether a real system share sheet is available (so UI can choose between
67    /// "Share" and "Save…").
68    fn is_supported(&self) -> bool;
69}
70
71pub type ShareSheetRef = Rc<dyn ShareSheet>;
72
73thread_local! {
74    static PLATFORM_SHARE_SHEET: RefCell<Option<ShareSheetRef>> = const { RefCell::new(None) };
75}
76
77/// Installs a platform share sheet, replacing any previously installed one.
78/// Backends with main-thread UIKit / Activity access register here.
79pub fn set_platform_share_sheet(share_sheet: ShareSheetRef) {
80    PLATFORM_SHARE_SHEET.with(|cell| *cell.borrow_mut() = Some(share_sheet));
81}
82
83/// Removes any registered platform share sheet (tests and teardown).
84pub fn clear_platform_share_sheet() {
85    PLATFORM_SHARE_SHEET.with(|cell| *cell.borrow_mut() = None);
86}
87
88fn registered_platform_share_sheet() -> Option<ShareSheetRef> {
89    PLATFORM_SHARE_SHEET.with(|cell| cell.borrow().clone())
90}
91
92struct PlatformShareSheet;
93
94impl ShareSheet for PlatformShareSheet {
95    fn share(&self, content: ShareContent) -> Result<(), ShareError> {
96        match registered_platform_share_sheet() {
97            Some(handler) => handler.share(content),
98            None => Err(ShareError::Unsupported),
99        }
100    }
101
102    fn is_supported(&self) -> bool {
103        registered_platform_share_sheet().is_some_and(|handler| handler.is_supported())
104    }
105}
106
107pub fn default_share_sheet() -> ShareSheetRef {
108    Rc::new(PlatformShareSheet)
109}
110
111pub fn local_share_sheet() -> CompositionLocal<ShareSheetRef> {
112    thread_local! {
113        static LOCAL_SHARE_SHEET: RefCell<Option<CompositionLocal<ShareSheetRef>>> = const { RefCell::new(None) };
114    }
115
116    LOCAL_SHARE_SHEET.with(|cell| {
117        let mut local = cell.borrow_mut();
118        local
119            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_share_sheet, Rc::ptr_eq))
120            .clone()
121    })
122}
123
124#[composable]
125pub fn ProvideShareSheet(content: impl FnOnce()) {
126    let share_sheet = cranpose_core::remember(default_share_sheet).with(|state| state.clone());
127    let local = local_share_sheet();
128
129    CompositionLocalProvider(vec![local.provides(share_sheet)], move || {
130        content();
131    });
132}
133
134#[cfg(test)]
135mod tests {
136    use std::cell::RefCell;
137
138    use super::*;
139
140    struct RecordingShareSheet {
141        shared: RefCell<Option<ShareContent>>,
142        supported: bool,
143    }
144
145    impl ShareSheet for RecordingShareSheet {
146        fn share(&self, content: ShareContent) -> Result<(), ShareError> {
147            *self.shared.borrow_mut() = Some(content);
148            Ok(())
149        }
150        fn is_supported(&self) -> bool {
151            self.supported
152        }
153    }
154
155    #[test]
156    fn default_share_sheet_is_unsupported_without_a_backend() {
157        clear_platform_share_sheet();
158        let sheet = default_share_sheet();
159        assert!(!sheet.is_supported());
160        let error = sheet
161            .share(ShareContent::file(
162                "a.pdf",
163                "application/pdf",
164                vec![1, 2, 3],
165            ))
166            .expect_err("no backend installed");
167        assert!(matches!(error, ShareError::Unsupported));
168    }
169
170    #[test]
171    fn registered_share_sheet_takes_precedence() {
172        let recorder = Rc::new(RecordingShareSheet {
173            shared: RefCell::new(None),
174            supported: true,
175        });
176        set_platform_share_sheet(recorder.clone());
177
178        let sheet = default_share_sheet();
179        assert!(sheet.is_supported());
180        sheet
181            .share(ShareContent::file("r.pdf", "application/pdf", vec![9]).with_text("hi"))
182            .expect("registered backend shares");
183
184        let shared = recorder.shared.borrow();
185        let shared = shared.as_ref().expect("content recorded");
186        assert_eq!(shared.file_name, "r.pdf");
187        assert_eq!(shared.text.as_deref(), Some("hi"));
188
189        clear_platform_share_sheet();
190        assert!(!default_share_sheet().is_supported());
191    }
192}