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
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}