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::rc::Rc;
12
13use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
14use cranpose_macros::composable;
15
16/// Why the share sheet could not be shown.
17#[derive(thiserror::Error, Debug)]
18pub enum ShareError {
19    /// The platform has no share sheet.
20    #[error("sharing is not supported on this platform")]
21    Unsupported,
22    /// The platform failed to present the share sheet. The text describes the cause.
23    #[error("failed to present the share sheet: {0}")]
24    Failed(String),
25}
26
27/// A single file to share, with an optional accompanying message/subject.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct ShareContent {
30    /// Suggested file name shown to the user and used by the receiving app.
31    pub file_name: String,
32    /// MIME type of `bytes` (e.g. `application/pdf`, `image/jpeg`).
33    pub mime_type: String,
34    /// File payload. Backends stage it to a temporary URL to share.
35    pub bytes: Vec<u8>,
36    /// Optional text/subject shared alongside the file.
37    pub text: Option<String>,
38}
39
40impl ShareContent {
41    /// A file to share, identified by name and MIME type.
42    pub fn file(
43        file_name: impl Into<String>,
44        mime_type: impl Into<String>,
45        bytes: Vec<u8>,
46    ) -> Self {
47        Self {
48            file_name: file_name.into(),
49            mime_type: mime_type.into(),
50            bytes,
51            text: None,
52        }
53    }
54
55    /// Attach accompanying text (subject / message) to the shared file.
56    pub fn with_text(mut self, text: impl Into<String>) -> Self {
57        self.text = Some(text.into());
58        self
59    }
60}
61
62/// The OS share sheet. Installed by the platform backend; the default is a
63/// no-op that reports [`ShareError::Unsupported`].
64pub trait ShareSheet {
65    /// Present the system share sheet for `content`. Fire-and-forget: it
66    /// resolves once presented and does not report the user's choice.
67    fn share(&self, content: ShareContent) -> Result<(), ShareError>;
68
69    /// Whether a real system share sheet is available (so UI can choose between
70    /// "Share" and "Save…").
71    fn is_supported(&self) -> bool;
72}
73
74/// Shared handle to a [`ShareSheet`].
75pub type ShareSheetRef = Rc<dyn ShareSheet>;
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    crate::thread_state::with(|state| *state.share_sheet.borrow_mut() = Some(share_sheet));
81}
82
83/// Removes any registered platform share sheet (tests and teardown).
84pub fn clear_platform_share_sheet() {
85    crate::thread_state::with(|state| *state.share_sheet.borrow_mut() = None);
86}
87
88fn registered_platform_share_sheet() -> Option<ShareSheetRef> {
89    crate::thread_state::with(|state| state.share_sheet.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
107/// Returns the share sheet that forwards to the installed platform share sheet. Without one,
108/// every share reports [`ShareError::Unsupported`].
109pub fn default_share_sheet() -> ShareSheetRef {
110    Rc::new(PlatformShareSheet)
111}
112
113/// The share sheet a composable reads. It is the platform share sheet unless a composition
114/// local provider above it supplies another one.
115pub fn local_share_sheet() -> CompositionLocal<ShareSheetRef> {
116    crate::composition_locals::cached_local(
117        |locals| &locals.share_sheet,
118        || compositionLocalOfWithPolicy(default_share_sheet, Rc::ptr_eq),
119    )
120}
121
122/// Provides the platform share sheet to `content` through [`local_share_sheet`].
123#[composable]
124pub fn ProvideShareSheet(content: impl FnOnce()) {
125    let share_sheet = cranpose_core::remember(default_share_sheet).with(|state| state.clone());
126    let local = local_share_sheet();
127
128    CompositionLocalProvider([local.provides(share_sheet)], move || {
129        content();
130    });
131}
132
133#[cfg(test)]
134#[path = "tests/share_sheet_tests.rs"]
135mod tests;