cranpose_services/writable_folder.rs
1//! Cross-platform access to a user-chosen **writable** folder.
2//!
3//! This is the write-side complement of [`crate::file_picker`]: where the picker
4//! *reads* files the user selects, this lets an app persist its own data into a
5//! folder the user grants — a local directory on desktop, a Storage Access
6//! Framework tree on Android — and read it back on later runs. The motivating
7//! use is cross-device sync, where each device writes a small document into a
8//! shared folder (e.g. on a Tailnet/WebDAV mount) and reads its peers'.
9//!
10//! Two halves, mirroring the picker:
11//! - [`pick_writable_folder`] — asynchronous, UI-thread. Presents the system
12//! folder picker with a *persistent read/write* grant and resolves to an
13//! opaque, durable **handle** string the app stores.
14//! - [`open_writable_folder`] — synchronous and thread-safe. Rebuilds a
15//! [`WritableFolderStore`] from a stored handle. Its I/O methods are
16//! synchronous and `Send + Sync`, so a background worker can call them without
17//! touching the UI thread.
18//!
19//! Read-only or unreachable folders surface as [`FolderError::ReadOnly`] /
20//! [`FolderError::Io`] so callers can degrade gracefully. iOS (security-scoped
21//! bookmarks) and the web are not supported yet and return
22//! [`FolderError::Unsupported`].
23
24use crate::file_picker::{FilePickerError, PickerFuture};
25use std::cell::RefCell;
26use std::rc::Rc;
27use std::sync::{Arc, OnceLock};
28
29/// Errors produced by writable-folder I/O.
30#[derive(thiserror::Error, Debug, Clone)]
31pub enum FolderError {
32 /// The folder (or backing store) is read-only.
33 #[error("writable folder is read-only")]
34 ReadOnly,
35 /// The named file does not exist.
36 #[error("file not found: {0}")]
37 NotFound(String),
38 /// Writable folders are not available on this platform/build.
39 #[error("writable folders are not supported on this platform")]
40 Unsupported,
41 /// Any other I/O failure.
42 #[error("{0}")]
43 Io(String),
44}
45
46/// Synchronous, thread-safe access to a user-chosen writable folder.
47///
48/// Implementations are `Send + Sync` so a background thread can read and write
49/// without involving the UI thread. Treat it as a flat store of named files
50/// (directories are not exposed by [`list`](WritableFolderStore::list)).
51pub trait WritableFolderStore: Send + Sync {
52 /// Writes (overwriting) the file `name` with `contents`.
53 fn write(&self, name: &str, contents: &[u8]) -> Result<(), FolderError>;
54
55 /// Reads the file `name`.
56 fn read(&self, name: &str) -> Result<Vec<u8>, FolderError>;
57
58 /// Lists the immediate child *file* names (directories excluded).
59 fn list(&self) -> Result<Vec<String>, FolderError>;
60
61 /// Removes the file `name`. Succeeds if it is already absent.
62 fn remove(&self, name: &str) -> Result<(), FolderError>;
63
64 /// Cheaply probes whether the folder is writable right now (e.g. detects a
65 /// read-only WebDAV mount that still granted a write permission).
66 fn is_writable(&self) -> bool;
67
68 /// The durable handle (filesystem path / SAF tree URI) used to reopen this
69 /// folder with [`open_writable_folder`] on a later run.
70 fn handle(&self) -> String;
71}
72
73/// Shared handle to a [`WritableFolderStore`].
74pub type WritableFolderStoreRef = Arc<dyn WritableFolderStore>;
75
76/// Presents the system "pick a writable folder" UI. Platform-provided (Android);
77/// desktop/web use the built-in backend.
78pub trait WritableFolderPicker {
79 /// Resolves to the durable handle string, or `None` if the user cancelled.
80 fn pick(&self) -> PickerFuture<Result<Option<String>, FilePickerError>>;
81
82 /// Reclaims a grant whose result arrived after the composition that
83 /// requested it was torn down. On Android the activity (and the native app)
84 /// can be destroyed and recreated while the SAF picker is in front, so a
85 /// folder picked at that moment would otherwise be lost; the app drains this
86 /// on startup to recover it. Returns the recovered durable handle, usually
87 /// `None`. Backends that never lose a result keep the default.
88 fn take_resumed_pick(&self) -> Option<String> {
89 None
90 }
91}
92
93/// Shared handle to a [`WritableFolderPicker`].
94pub type WritableFolderPickerRef = Rc<dyn WritableFolderPicker>;
95
96thread_local! {
97 static PLATFORM_PICKER: RefCell<Option<WritableFolderPickerRef>> = const { RefCell::new(None) };
98}
99
100/// Registers the platform writable-folder picker (Android SAF). The cranpose
101/// crate's backend calls this during startup; once registered it takes
102/// precedence over the built-in desktop picker.
103pub fn set_platform_writable_folder_picker(picker: WritableFolderPickerRef) {
104 PLATFORM_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
105}
106
107/// Removes any registered platform picker (tests/teardown).
108pub fn clear_platform_writable_folder_picker() {
109 PLATFORM_PICKER.with(|cell| *cell.borrow_mut() = None);
110}
111
112fn registered_picker() -> Option<WritableFolderPickerRef> {
113 PLATFORM_PICKER.with(|cell| cell.borrow().clone())
114}
115
116/// Factory turning a stored handle into a [`WritableFolderStore`]. Unlike the
117/// picker this is thread-safe, so a worker thread can reopen the folder.
118type StoreFactory = Box<dyn Fn(&str) -> Option<WritableFolderStoreRef> + Send + Sync>;
119static STORE_FACTORY: OnceLock<StoreFactory> = OnceLock::new();
120
121/// Registers the platform store factory (Android). Called once at startup. No-op
122/// if already set.
123pub fn set_writable_folder_store_factory(factory: StoreFactory) {
124 let _ = STORE_FACTORY.set(factory);
125}
126
127/// Presents the writable-folder picker and resolves to a durable handle (or
128/// `None` if cancelled). Async; drive it from `LaunchedEffectAsync`.
129pub fn pick_writable_folder() -> PickerFuture<Result<Option<String>, FilePickerError>> {
130 if let Some(picker) = registered_picker() {
131 return picker.pick();
132 }
133 builtin_pick()
134}
135
136/// Reclaims a writable-folder grant orphaned by an activity recreation while the
137/// SAF picker was in front (see [`WritableFolderPicker::take_resumed_pick`]).
138/// Returns the recovered durable handle, or `None`. The app drains this on
139/// startup; backends that never lose a result return `None`.
140pub fn take_resumed_writable_folder() -> Option<String> {
141 registered_picker().and_then(|picker| picker.take_resumed_pick())
142}
143
144/// Reopens a writable folder from a stored handle. Synchronous and callable from
145/// any thread; returns `None` only when writable folders are unsupported here.
146pub fn open_writable_folder(handle: &str) -> Option<WritableFolderStoreRef> {
147 if let Some(factory) = STORE_FACTORY.get() {
148 return factory(handle);
149 }
150 builtin_open(handle)
151}
152
153fn builtin_pick() -> PickerFuture<Result<Option<String>, FilePickerError>> {
154 #[cfg(all(
155 not(target_arch = "wasm32"),
156 not(target_os = "android"),
157 not(target_os = "ios"),
158 feature = "file-picker-native"
159 ))]
160 {
161 return desktop::pick();
162 }
163 #[allow(unreachable_code)]
164 Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
165}
166
167fn builtin_open(handle: &str) -> Option<WritableFolderStoreRef> {
168 #[cfg(all(
169 not(target_arch = "wasm32"),
170 not(target_os = "android"),
171 not(target_os = "ios"),
172 feature = "file-picker-native"
173 ))]
174 {
175 return Some(desktop::open(handle));
176 }
177 #[allow(unreachable_code)]
178 {
179 let _ = handle;
180 None
181 }
182}
183
184#[cfg(all(
185 not(target_arch = "wasm32"),
186 not(target_os = "android"),
187 not(target_os = "ios"),
188 feature = "file-picker-native"
189))]
190mod desktop;
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn folder_error_messages_are_distinct() {
198 assert_eq!(
199 FolderError::ReadOnly.to_string(),
200 "writable folder is read-only"
201 );
202 assert!(FolderError::NotFound("a.txt".into())
203 .to_string()
204 .contains("a.txt"));
205 }
206
207 #[test]
208 fn picker_registration_round_trips() {
209 struct Marker;
210 impl WritableFolderPicker for Marker {
211 fn pick(&self) -> PickerFuture<Result<Option<String>, FilePickerError>> {
212 Box::pin(async { Ok(Some("handle".to_string())) })
213 }
214 }
215 clear_platform_writable_folder_picker();
216 assert!(registered_picker().is_none());
217 set_platform_writable_folder_picker(Rc::new(Marker));
218 assert!(registered_picker().is_some());
219 let result = pollster::block_on(pick_writable_folder());
220 assert!(matches!(result, Ok(Some(handle)) if handle == "handle"));
221 clear_platform_writable_folder_picker();
222 }
223}