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