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(ToString::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
142type StoreFactory = Box<dyn Fn(&str) -> Option<WritableFolderStoreRef> + Send + Sync>;
143static STORE_FACTORY: OnceLock<StoreFactory> = OnceLock::new();
144
145/// Registers the platform store factory (Android, iOS). Called once at startup.
146/// No-op if already set.
147pub fn set_writable_folder_store_factory(factory: StoreFactory) {
148 let _ = STORE_FACTORY.set(factory);
149}
150
151/// Reopens a writable folder from a stored handle. Synchronous and callable from
152/// any thread; returns `None` only when writable folders are unsupported here.
153pub fn open_writable_folder(handle: &str) -> Option<WritableFolderStoreRef> {
154 if let Some(factory) = STORE_FACTORY.get() {
155 return factory(handle);
156 }
157 builtin_open(handle)
158}
159
160fn builtin_open(handle: &str) -> Option<WritableFolderStoreRef> {
161 #[cfg(all(
162 not(target_arch = "wasm32"),
163 not(target_os = "android"),
164 not(target_os = "ios"),
165 feature = "file-picker-native"
166 ))]
167 {
168 return Some(desktop::open(handle));
169 }
170 #[allow(unreachable_code)]
171 {
172 let _ = handle;
173 None
174 }
175}
176
177#[cfg(all(
178 not(target_arch = "wasm32"),
179 not(target_os = "android"),
180 not(target_os = "ios"),
181 feature = "file-picker-native"
182))]
183mod desktop;
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 struct FlatStore {
190 handle: String,
191 }
192
193 impl WritableFolderStore for FlatStore {
194 fn write(&self, _name: &str, _contents: &[u8]) -> Result<(), FolderError> {
195 Ok(())
196 }
197 fn read(&self, _name: &str) -> Result<Vec<u8>, FolderError> {
198 Ok(Vec::new())
199 }
200 fn list(&self) -> Result<Vec<FolderEntry>, FolderError> {
201 Ok(vec![FolderEntry {
202 name: "sync.json".into(),
203 len: 12,
204 modified_millis: Some(5),
205 }])
206 }
207 fn remove(&self, _name: &str) -> Result<(), FolderError> {
208 Ok(())
209 }
210 fn open_read(&self, _name: &str) -> Result<Box<dyn FolderReader>, FolderError> {
211 Err(FolderError::Unsupported)
212 }
213 fn open_write(&self, _name: &str) -> Result<Box<dyn FolderWriter>, FolderError> {
214 Err(FolderError::Unsupported)
215 }
216 fn is_writable(&self) -> bool {
217 true
218 }
219 fn handle(&self) -> String {
220 self.handle.clone()
221 }
222 }
223
224 #[test]
225 fn folder_error_messages_are_distinct() {
226 assert_eq!(
227 FolderError::ReadOnly.to_string(),
228 "writable folder is read-only"
229 );
230 assert!(
231 FolderError::NotFound("a.txt".into())
232 .to_string()
233 .contains("a.txt")
234 );
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}