Skip to main content

renamite_platform/
lib.rs

1//! Platform glue: file dialogs (via `rlobkit-dialogs`) + autosave storage.
2//!
3//! Pattern mirrors my `repadio`'s `player-platform`: a thin crate gated on the
4//! *target* (not a Cargo feature), with a non-blocking callback API that works
5//! on every platform and a few blocking helpers reserved for desktop. Dialogs
6//! go through `rlobkit-dialogs` so one crate serves every platform (native
7//! backends on desktop, browser/Activity pickers on WASM/Android).
8
9use std::path::PathBuf;
10
11/// File dialogs.
12pub mod dialogs {
13    use std::path::PathBuf;
14
15    /// A file picked by the user: a real path (desktop) or name+bytes
16    /// (WASM/Android, where the OS hands us a URI/blob, not a path).
17    #[derive(Clone, Debug)]
18    pub enum PickedFile {
19        Path(PathBuf),
20        Bytes { name: String, data: Vec<u8> },
21    }
22
23    /// Result of an async save. `path` is `Some` on desktop (the written
24    /// filesystem path); WASM/Android drives the save through the OS so they
25    /// only report success/failure.
26    #[derive(Clone, Debug)]
27    pub struct SaveOutcome {
28        pub ok: bool,
29        pub path: Option<PathBuf>,
30    }
31
32    /// Register platform I/O callbacks (Android only). No-op elsewhere.
33    /// Must be called once at app startup.
34    pub fn init() {
35        rlobkit_dialogs::init();
36    }
37
38    /// Build the `OpenFileOptions` used for a single-file picker.
39    #[allow(dead_code)] // used by the non-desktop target branches
40    fn open_options(title: &str, extensions: &[&str]) -> rlobkit_dialogs::picker::OpenFileOptions {
41        let exts: Vec<String> = extensions.iter().map(|s| s.to_string()).collect();
42        rlobkit_dialogs::picker::OpenFileOptions {
43            file_type: rlobkit_dialogs::RlobKitType::Custom {
44                extensions: exts,
45                mime_types: vec![],
46            },
47            mode: rlobkit_dialogs::RlobKitMode::Single,
48            title: Some(title.to_string()),
49            initial_directory: None,
50        }
51    }
52
53    /// Build `SaveFileOptions` sharing the `RlobKitType` construction.
54    #[allow(dead_code)] // used by the non-desktop target branches
55    fn save_options(
56        title: &str,
57        suggested_name: &str,
58        extensions: &[&str],
59    ) -> rlobkit_dialogs::picker::SaveFileOptions {
60        let exts: Vec<String> = extensions.iter().map(|s| s.to_string()).collect();
61        rlobkit_dialogs::picker::SaveFileOptions {
62            suggested_name: Some(suggested_name.to_string()),
63            file_type: Some(rlobkit_dialogs::RlobKitType::Custom {
64                extensions: exts,
65                mime_types: vec![],
66            }),
67            title: Some(title.to_string()),
68            ..Default::default()
69        }
70    }
71
72    /// Non-blocking single-file open dialog. Works on every target:
73    /// desktop spawns a thread running the native blocking dialog. Android
74    /// spawns a thread driving the Activity picker. WASM runs the browser
75    /// picker on the main thread. `on_done(None)` fires on cancel/error.
76    pub fn pick_open_file(
77        title: &'static str,
78        extensions: &'static [&'static str],
79        on_done: Box<dyn FnOnce(Option<PickedFile>) + Send + 'static>,
80    ) {
81        #[cfg(not(any(target_os = "android", target_arch = "wasm32")))]
82        {
83            std::thread::spawn(move || {
84                on_done(
85                    rlobkit_dialogs::blocking_open_file(title, extensions).map(PickedFile::Path),
86                );
87            });
88        }
89        #[cfg(target_os = "android")]
90        {
91            std::thread::spawn(move || {
92                let opts = open_options(title, extensions);
93                let picked = futures_lite::future::block_on(
94                    rlobkit_dialogs::RlobKit::open_file_picker(opts),
95                )
96                .ok()
97                .flatten()
98                .and_then(|mut v| v.pop())
99                .and_then(|f| {
100                    let name = f.name().to_string();
101                    match f.read_bytes() {
102                        Ok(data) => Some(PickedFile::Bytes {
103                            name,
104                            data: data.to_vec(),
105                        }),
106                        Err(e) => {
107                            log::error!("read picker file failed: {e}");
108                            None
109                        }
110                    }
111                });
112                on_done(picked);
113            });
114        }
115        #[cfg(target_arch = "wasm32")]
116        {
117            let opts = open_options(title, extensions);
118            wasm_bindgen_futures::spawn_local(async move {
119                let picked = rlobkit_dialogs::RlobKit::open_file_picker(opts)
120                    .await
121                    .ok()
122                    .flatten()
123                    .and_then(|mut v| v.pop())
124                    .and_then(|f| {
125                        let name = f.name().to_string();
126                        match f
127                            .data()
128                            .map(|b| b.to_vec())
129                            .or_else(|| f.read_bytes().ok().map(|b| b.to_vec()))
130                        {
131                            Some(data) => Some(PickedFile::Bytes { name, data }),
132                            None => None,
133                        }
134                    });
135                on_done(picked);
136            });
137        }
138    }
139
140    /// Non-blocking save dialog that writes `data`. Works on every target.
141    /// `on_done` is called with the outcome after the OS finishes.
142    pub fn save_bytes(
143        title: &'static str,
144        suggested_name: String,
145        extensions: &'static [&'static str],
146        data: Vec<u8>,
147        on_done: Box<dyn FnOnce(SaveOutcome) + Send + 'static>,
148    ) {
149        #[cfg(not(any(target_os = "android", target_arch = "wasm32")))]
150        {
151            std::thread::spawn(move || {
152                let outcome = match rlobkit_dialogs::blocking_save_file(
153                    title,
154                    &suggested_name,
155                    &extensions.join(","),
156                ) {
157                    Some(path) => SaveOutcome {
158                        ok: std::fs::write(&path, &data).is_ok(),
159                        path: Some(path),
160                    },
161                    None => SaveOutcome {
162                        ok: false,
163                        path: None,
164                    },
165                };
166                on_done(outcome);
167            });
168        }
169        #[cfg(target_os = "android")]
170        {
171            std::thread::spawn(move || {
172                let opts = save_options(title, &suggested_name, extensions);
173                let ok = futures_lite::future::block_on(rlobkit_dialogs::RlobKit::save_bytes(
174                    opts, &data,
175                ))
176                .ok()
177                .flatten()
178                .is_some();
179                on_done(SaveOutcome { ok, path: None });
180            });
181        }
182        #[cfg(target_arch = "wasm32")]
183        {
184            let opts = save_options(title, &suggested_name, extensions);
185            wasm_bindgen_futures::spawn_local(async move {
186                let ok = rlobkit_dialogs::RlobKit::save_bytes(opts, &data)
187                    .await
188                    .ok()
189                    .flatten()
190                    .is_some();
191                on_done(SaveOutcome { ok, path: None });
192            });
193        }
194    }
195
196    /// Ask for a write path *without* writing. Blocking. Desktop only
197    /// (used by the synchronous Save flow so the unsaved guard stays correct).
198    #[cfg(not(any(target_os = "android", target_arch = "wasm32")))]
199    pub fn export_path(title: &str, suggested_name: &str, extensions: &[&str]) -> Option<PathBuf> {
200        rlobkit_dialogs::blocking_save_file(title, suggested_name, &extensions.join(","))
201    }
202}
203
204/// Filesystem-backed autosave store (desktop).
205#[cfg(not(any(target_os = "android", target_arch = "wasm32")))]
206pub fn autosave_store() -> DirStore {
207    let base = std::env::var_os("RENAMITE_DATA_DIR")
208        .map(PathBuf::from)
209        .or_else(|| std::env::var_os("XDG_DATA_HOME").map(|p| PathBuf::from(p).join("renamite")))
210        .unwrap_or_else(|| PathBuf::from("."));
211    let dir = base.join("autosave");
212    let _ = std::fs::create_dir_all(&dir);
213    DirStore { dir }
214}
215
216/// Durable key/value storage for autosave.
217pub trait KvStore: Send + Sync {
218    fn get(&self, key: &str) -> Option<Vec<u8>>;
219    fn set(&self, key: &str, value: &[u8]);
220}
221
222/// Filesystem-backed store.
223pub struct DirStore {
224    pub dir: PathBuf,
225}
226
227impl KvStore for DirStore {
228    fn get(&self, key: &str) -> Option<Vec<u8>> {
229        let path = self.dir.join(sanitize_key(key));
230        std::fs::read(&path).ok()
231    }
232    fn set(&self, key: &str, value: &[u8]) {
233        let path = self.dir.join(sanitize_key(key));
234        let _ = std::fs::write(&path, value);
235    }
236}
237
238fn sanitize_key(key: &str) -> String {
239    key.chars()
240        .map(|c| {
241            if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
242                c
243            } else {
244                '_'
245            }
246        })
247        .collect()
248}
249
250/// Monotonic-ish milliseconds since the Unix epoch.
251pub fn now_ms() -> f64 {
252    std::time::SystemTime::now()
253        .duration_since(std::time::UNIX_EPOCH)
254        .map(|d| d.as_secs_f64() * 1000.0)
255        .unwrap_or(0.0)
256}