Skip to main content

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}