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