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
83/// Shared handle to a [`WritableFolderPicker`].
84pub type WritableFolderPickerRef = Rc<dyn WritableFolderPicker>;
85
86thread_local! {
87    static PLATFORM_PICKER: RefCell<Option<WritableFolderPickerRef>> = const { RefCell::new(None) };
88}
89
90/// Registers the platform writable-folder picker (Android SAF). The cranpose
91/// crate's backend calls this during startup; once registered it takes
92/// precedence over the built-in desktop picker.
93pub fn set_platform_writable_folder_picker(picker: WritableFolderPickerRef) {
94    PLATFORM_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
95}
96
97/// Removes any registered platform picker (tests/teardown).
98pub fn clear_platform_writable_folder_picker() {
99    PLATFORM_PICKER.with(|cell| *cell.borrow_mut() = None);
100}
101
102fn registered_picker() -> Option<WritableFolderPickerRef> {
103    PLATFORM_PICKER.with(|cell| cell.borrow().clone())
104}
105
106/// Factory turning a stored handle into a [`WritableFolderStore`]. Unlike the
107/// picker this is thread-safe, so a worker thread can reopen the folder.
108type StoreFactory = Box<dyn Fn(&str) -> Option<WritableFolderStoreRef> + Send + Sync>;
109static STORE_FACTORY: OnceLock<StoreFactory> = OnceLock::new();
110
111/// Registers the platform store factory (Android). Called once at startup. No-op
112/// if already set.
113pub fn set_writable_folder_store_factory(factory: StoreFactory) {
114    let _ = STORE_FACTORY.set(factory);
115}
116
117/// Presents the writable-folder picker and resolves to a durable handle (or
118/// `None` if cancelled). Async; drive it from `LaunchedEffectAsync`.
119pub fn pick_writable_folder() -> PickerFuture<Result<Option<String>, FilePickerError>> {
120    if let Some(picker) = registered_picker() {
121        return picker.pick();
122    }
123    builtin_pick()
124}
125
126/// Reopens a writable folder from a stored handle. Synchronous and callable from
127/// any thread; returns `None` only when writable folders are unsupported here.
128pub fn open_writable_folder(handle: &str) -> Option<WritableFolderStoreRef> {
129    if let Some(factory) = STORE_FACTORY.get() {
130        return factory(handle);
131    }
132    builtin_open(handle)
133}
134
135fn builtin_pick() -> PickerFuture<Result<Option<String>, FilePickerError>> {
136    #[cfg(all(
137        not(target_arch = "wasm32"),
138        not(target_os = "android"),
139        not(target_os = "ios"),
140        feature = "file-picker-native"
141    ))]
142    {
143        return desktop::pick();
144    }
145    #[allow(unreachable_code)]
146    Box::pin(async { Err(FilePickerError::UnsupportedPlatform) })
147}
148
149fn builtin_open(handle: &str) -> Option<WritableFolderStoreRef> {
150    #[cfg(all(
151        not(target_arch = "wasm32"),
152        not(target_os = "android"),
153        not(target_os = "ios"),
154        feature = "file-picker-native"
155    ))]
156    {
157        return Some(desktop::open(handle));
158    }
159    #[allow(unreachable_code)]
160    {
161        let _ = handle;
162        None
163    }
164}
165
166#[cfg(all(
167    not(target_arch = "wasm32"),
168    not(target_os = "android"),
169    not(target_os = "ios"),
170    feature = "file-picker-native"
171))]
172mod desktop;
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn folder_error_messages_are_distinct() {
180        assert_eq!(
181            FolderError::ReadOnly.to_string(),
182            "writable folder is read-only"
183        );
184        assert!(FolderError::NotFound("a.txt".into())
185            .to_string()
186            .contains("a.txt"));
187    }
188
189    #[test]
190    fn picker_registration_round_trips() {
191        struct Marker;
192        impl WritableFolderPicker for Marker {
193            fn pick(&self) -> PickerFuture<Result<Option<String>, FilePickerError>> {
194                Box::pin(async { Ok(Some("handle".to_string())) })
195            }
196        }
197        clear_platform_writable_folder_picker();
198        assert!(registered_picker().is_none());
199        set_platform_writable_folder_picker(Rc::new(Marker));
200        assert!(registered_picker().is_some());
201        let result = pollster::block_on(pick_writable_folder());
202        assert!(matches!(result, Ok(Some(handle)) if handle == "handle"));
203        clear_platform_writable_folder_picker();
204    }
205}