cranpose_services/
share_sheet.rs1use 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#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct ShareContent {
27 pub file_name: String,
29 pub mime_type: String,
31 pub bytes: Vec<u8>,
33 pub text: Option<String>,
35}
36
37impl ShareContent {
38 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 pub fn with_text(mut self, text: impl Into<String>) -> Self {
54 self.text = Some(text.into());
55 self
56 }
57}
58
59pub trait ShareSheet {
62 fn share(&self, content: ShareContent) -> Result<(), ShareError>;
65
66 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
77pub fn set_platform_share_sheet(share_sheet: ShareSheetRef) {
80 PLATFORM_SHARE_SHEET.with(|cell| *cell.borrow_mut() = Some(share_sheet));
81}
82
83pub 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}