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
4//! choosers *read* content the user selects, this lets an app persist its own
5//! data into a folder the user grants — a local directory on desktop, a Storage
6//! Access Framework tree on Android — and read it back on later runs. The
7//! motivating use is cross-device sync, where each device writes a small
8//! document into a shared folder (e.g. on a Tailnet/WebDAV mount) and reads its
9//! peers'.
10//!
11//! Two halves:
12//! - [`crate::launcher::rememberWritableFolderLauncher`] presents the system
13//! chooser and hands back the opaque, durable **handle** string the app
14//! stores. The launcher owns the request across host recreation.
15//! - [`open_writable_folder`] is synchronous and thread-safe. It rebuilds a
16//! [`WritableFolderStore`] from a stored handle, so a background worker can
17//! read and write without touching the UI thread.
18//!
19//! Stores expose whole-file operations, [`FolderEntry`] display metadata, and
20//! chunked [`FolderReader`]/[`FolderWriter`] streams for payloads that should
21//! not be buffered. Read-only or unreachable folders surface as
22//! [`FolderError::ReadOnly`] / [`FolderError::Io`] so callers can degrade
23//! gracefully. Backends: desktop (`std::fs`), Android (SAF tree URIs), iOS
24//! (security-scoped bookmarks). The web has no writable-folder concept and
25//! returns [`FolderError::Unsupported`].
26
27use std::sync::{Arc, OnceLock};
28
29/// Errors produced by writable-folder I/O.
30#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
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/// Display metadata for one file in a writable folder.
47#[derive(Clone, Debug, Default, PartialEq, Eq)]
48pub struct FolderEntry {
49 /// The file name, as stored.
50 pub name: String,
51 /// Byte length.
52 pub len: u64,
53 /// Last-modified time in milliseconds since the Unix epoch, when the
54 /// provider reports one.
55 pub modified_millis: Option<u64>,
56}
57
58/// Chunked reader over one file in a writable folder.
59///
60/// Synchronous and `Send` so a worker thread can drain it.
61pub trait FolderReader: Send {
62 /// Reads the next chunk, or `None` at end of file.
63 fn read_chunk(&mut self) -> Result<Option<Vec<u8>>, FolderError>;
64}
65
66/// Chunked writer over one file in a writable folder.
67///
68/// The file is only visible under its final name once [`finish`] succeeds;
69/// dropping a writer without finishing discards the partial write.
70///
71/// [`finish`]: FolderWriter::finish
72pub trait FolderWriter: Send {
73 /// Appends `bytes`.
74 fn write_chunk(&mut self, bytes: &[u8]) -> Result<(), FolderError>;
75
76 /// Commits the file under its final name.
77 fn finish(self: Box<Self>) -> Result<(), FolderError>;
78}
79
80/// Synchronous, thread-safe access to a user-chosen writable folder.
81///
82/// Implementations are `Send + Sync` so a background thread can read and write
83/// without involving the UI thread. Treat it as a flat store of named files
84/// (directories are not exposed by [`list`](WritableFolderStore::list)).
85pub trait WritableFolderStore: Send + Sync {
86 /// Writes (overwriting) the file `name` with `contents`.
87 fn write(&self, name: &str, contents: &[u8]) -> Result<(), FolderError>;
88
89 /// Reads the file `name`.
90 fn read(&self, name: &str) -> Result<Vec<u8>, FolderError>;
91
92 /// Lists the immediate child *files* with their display metadata
93 /// (directories excluded).
94 fn list(&self) -> Result<Vec<FolderEntry>, FolderError>;
95
96 /// Removes the file `name`. Succeeds if it is already absent.
97 fn remove(&self, name: &str) -> Result<(), FolderError>;
98
99 /// Opens a chunked reader over the file `name`.
100 fn open_read(&self, name: &str) -> Result<Box<dyn FolderReader>, FolderError>;
101
102 /// Opens a chunked writer that replaces the file `name` on
103 /// [`FolderWriter::finish`].
104 fn open_write(&self, name: &str) -> Result<Box<dyn FolderWriter>, FolderError>;
105
106 /// Cheaply probes whether the folder is writable right now (e.g. detects a
107 /// read-only WebDAV mount that still granted a write permission).
108 fn is_writable(&self) -> bool;
109
110 /// The durable handle (filesystem path / SAF tree URI) used to reopen this
111 /// folder with [`open_writable_folder`] on a later run.
112 fn handle(&self) -> String;
113
114 /// The folder's name as the user would recognise it. The default derives it
115 /// from the last component of [`handle`](WritableFolderStore::handle);
116 /// providers that know a nicer name (an SAF display name) override it.
117 fn display_name(&self) -> String {
118 let handle = self.handle();
119 handle
120 .trim_end_matches(['/', '\\'])
121 .rsplit(['/', '\\', ':'])
122 .find(|segment| !segment.is_empty())
123 .map(|segment| segment.to_string())
124 .unwrap_or(handle)
125 }
126
127 /// Display metadata for one file, without reading it.
128 ///
129 /// The default filters [`list`](WritableFolderStore::list); providers that
130 /// can stat a single file override it.
131 fn entry(&self, name: &str) -> Result<FolderEntry, FolderError> {
132 self.list()?
133 .into_iter()
134 .find(|entry| entry.name == name)
135 .ok_or_else(|| FolderError::NotFound(name.to_string()))
136 }
137}
138
139/// Shared handle to a [`WritableFolderStore`].
140pub type WritableFolderStoreRef = Arc<dyn WritableFolderStore>;
141
142/// Factory turning a stored handle into a [`WritableFolderStore`]. Thread-safe,
143/// so a worker thread can reopen the folder.
144type StoreFactory = Box<dyn Fn(&str) -> Option<WritableFolderStoreRef> + Send + Sync>;
145static STORE_FACTORY: OnceLock<StoreFactory> = OnceLock::new();
146
147/// Registers the platform store factory (Android, iOS). Called once at startup.
148/// No-op if already set.
149pub fn set_writable_folder_store_factory(factory: StoreFactory) {
150 let _ = STORE_FACTORY.set(factory);
151}
152
153/// Reopens a writable folder from a stored handle. Synchronous and callable from
154/// any thread; returns `None` only when writable folders are unsupported here.
155pub fn open_writable_folder(handle: &str) -> Option<WritableFolderStoreRef> {
156 if let Some(factory) = STORE_FACTORY.get() {
157 return factory(handle);
158 }
159 builtin_open(handle)
160}
161
162fn builtin_open(handle: &str) -> Option<WritableFolderStoreRef> {
163 #[cfg(all(
164 not(target_arch = "wasm32"),
165 not(target_os = "android"),
166 not(target_os = "ios"),
167 feature = "file-picker-native"
168 ))]
169 {
170 return Some(desktop::open(handle));
171 }
172 #[allow(unreachable_code)]
173 {
174 let _ = handle;
175 None
176 }
177}
178
179#[cfg(all(
180 not(target_arch = "wasm32"),
181 not(target_os = "android"),
182 not(target_os = "ios"),
183 feature = "file-picker-native"
184))]
185mod desktop;
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 struct FlatStore {
192 handle: String,
193 }
194
195 impl WritableFolderStore for FlatStore {
196 fn write(&self, _name: &str, _contents: &[u8]) -> Result<(), FolderError> {
197 Ok(())
198 }
199 fn read(&self, _name: &str) -> Result<Vec<u8>, FolderError> {
200 Ok(Vec::new())
201 }
202 fn list(&self) -> Result<Vec<FolderEntry>, FolderError> {
203 Ok(vec![FolderEntry {
204 name: "sync.json".into(),
205 len: 12,
206 modified_millis: Some(5),
207 }])
208 }
209 fn remove(&self, _name: &str) -> Result<(), FolderError> {
210 Ok(())
211 }
212 fn open_read(&self, _name: &str) -> Result<Box<dyn FolderReader>, FolderError> {
213 Err(FolderError::Unsupported)
214 }
215 fn open_write(&self, _name: &str) -> Result<Box<dyn FolderWriter>, FolderError> {
216 Err(FolderError::Unsupported)
217 }
218 fn is_writable(&self) -> bool {
219 true
220 }
221 fn handle(&self) -> String {
222 self.handle.clone()
223 }
224 }
225
226 #[test]
227 fn folder_error_messages_are_distinct() {
228 assert_eq!(
229 FolderError::ReadOnly.to_string(),
230 "writable folder is read-only"
231 );
232 assert!(FolderError::NotFound("a.txt".into())
233 .to_string()
234 .contains("a.txt"));
235 }
236
237 #[test]
238 fn the_default_display_name_is_the_last_handle_segment() {
239 let store = FlatStore {
240 handle: "/home/user/Shared Sync/".into(),
241 };
242 assert_eq!(store.display_name(), "Shared Sync");
243 let tree = FlatStore {
244 handle: "content://com.android.externalstorage.documents/tree/primary%3ASync".into(),
245 };
246 assert_eq!(tree.display_name(), "primary%3ASync");
247 }
248
249 #[test]
250 fn the_default_entry_lookup_filters_the_listing() {
251 let store = FlatStore {
252 handle: "sync-root".into(),
253 };
254 assert_eq!(store.entry("sync.json").unwrap().len, 12);
255 assert_eq!(
256 store.entry("absent.json"),
257 Err(FolderError::NotFound("absent.json".into()))
258 );
259 }
260}