Skip to main content

oxide_browser/
file_picker.rs

1//! Host-side native file and folder picker for Oxide guest modules.
2//!
3//! Guests call `api_file_pick` / `api_folder_pick` to invoke the OS picker
4//! and receive opaque `u32` handles. Paths never cross the sandbox boundary;
5//! the host keeps a `HashMap<handle, PathBuf>` and exposes reads via
6//! `api_file_read`, `api_file_read_range`, and `api_file_metadata`.
7//!
8//! `api_folder_entries` lists a picked directory as JSON, pre-allocating
9//! sub-handles for each child so the guest can read files without ever
10//! seeing the underlying path.
11
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::time::UNIX_EPOCH;
15
16use anyhow::Result;
17use wasmtime::{Caller, Linker};
18
19use crate::capabilities::{
20    console_log, read_guest_string, write_guest_bytes, ConsoleLevel, HostState,
21};
22
23/// One picked file or folder, keyed by an opaque handle the guest holds.
24pub struct PickedEntry {
25    pub path: PathBuf,
26    pub is_dir: bool,
27}
28
29/// All picker state for a tab. Handles are never reused within a session.
30pub struct FilePickerState {
31    entries: HashMap<u32, PickedEntry>,
32    next_id: u32,
33}
34
35impl Default for FilePickerState {
36    fn default() -> Self {
37        Self {
38            entries: HashMap::new(),
39            next_id: 1,
40        }
41    }
42}
43
44impl FilePickerState {
45    fn alloc(&mut self, path: PathBuf, is_dir: bool) -> u32 {
46        let id = self.next_id;
47        self.next_id = self.next_id.wrapping_add(1).max(1);
48        self.entries.insert(id, PickedEntry { path, is_dir });
49        id
50    }
51
52    fn get(&self, handle: u32) -> Option<&PickedEntry> {
53        self.entries.get(&handle)
54    }
55}
56
57fn mime_for_extension(ext: &str) -> &'static str {
58    match ext.to_ascii_lowercase().as_str() {
59        "png" => "image/png",
60        "jpg" | "jpeg" => "image/jpeg",
61        "gif" => "image/gif",
62        "webp" => "image/webp",
63        "bmp" => "image/bmp",
64        "svg" => "image/svg+xml",
65        "ico" => "image/x-icon",
66        "mp3" => "audio/mpeg",
67        "wav" => "audio/wav",
68        "ogg" => "audio/ogg",
69        "flac" => "audio/flac",
70        "m4a" => "audio/mp4",
71        "mp4" => "video/mp4",
72        "webm" => "video/webm",
73        "mkv" => "video/x-matroska",
74        "mov" => "video/quicktime",
75        "txt" => "text/plain",
76        "md" => "text/markdown",
77        "html" | "htm" => "text/html",
78        "css" => "text/css",
79        "js" => "text/javascript",
80        "json" => "application/json",
81        "xml" => "application/xml",
82        "pdf" => "application/pdf",
83        "zip" => "application/zip",
84        "wasm" => "application/wasm",
85        _ => "application/octet-stream",
86    }
87}
88
89fn modified_ms(meta: &std::fs::Metadata) -> u64 {
90    meta.modified()
91        .ok()
92        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
93        .map(|d| d.as_millis() as u64)
94        .unwrap_or(0)
95}
96
97fn file_name_of(path: &std::path::Path) -> String {
98    path.file_name()
99        .map(|n| n.to_string_lossy().to_string())
100        .unwrap_or_default()
101}
102
103fn json_escape(s: &str, out: &mut String) {
104    out.push('"');
105    for ch in s.chars() {
106        match ch {
107            '"' => out.push_str("\\\""),
108            '\\' => out.push_str("\\\\"),
109            '\n' => out.push_str("\\n"),
110            '\r' => out.push_str("\\r"),
111            '\t' => out.push_str("\\t"),
112            c if (c as u32) < 0x20 => {
113                out.push_str(&format!("\\u{:04x}", c as u32));
114            }
115            c => out.push(c),
116        }
117    }
118    out.push('"');
119}
120
121/// Register all file picker host functions.
122pub fn register_file_picker_functions(linker: &mut Linker<HostState>) -> Result<()> {
123    // api_file_pick(title, title_len, filters, filters_len, multiple, out_ptr, out_cap) -> i32
124    //   filters: comma-separated extensions ("png,jpg,gif"); empty string = all files.
125    //   out buffer receives u32 handles (little-endian) up to `out_cap / 4`.
126    //   Returns count of handles written, or -1 if the user cancelled.
127    linker.func_wrap(
128        "oxide",
129        "api_file_pick",
130        |mut caller: Caller<'_, HostState>,
131         title_ptr: u32,
132         title_len: u32,
133         filters_ptr: u32,
134         filters_len: u32,
135         multiple: u32,
136         out_ptr: u32,
137         out_cap: u32|
138         -> i32 {
139            let mem = caller.data().memory.expect("memory not set");
140            let title = read_guest_string(&mem, &caller, title_ptr, title_len)
141                .unwrap_or_else(|_| "Oxide: Select a file".to_string());
142            let filters =
143                read_guest_string(&mem, &caller, filters_ptr, filters_len).unwrap_or_default();
144
145            let mut dialog = rfd::FileDialog::new().set_title(&title);
146            let exts: Vec<String> = filters
147                .split(',')
148                .map(|s| s.trim().trim_start_matches('.').to_string())
149                .filter(|s| !s.is_empty())
150                .collect();
151            if !exts.is_empty() {
152                let refs: Vec<&str> = exts.iter().map(|s| s.as_str()).collect();
153                dialog = dialog.add_filter("Files", &refs);
154            }
155
156            let paths: Vec<PathBuf> = if multiple != 0 {
157                dialog.pick_files().unwrap_or_default()
158            } else {
159                match dialog.pick_file() {
160                    Some(p) => vec![p],
161                    None => Vec::new(),
162                }
163            };
164
165            if paths.is_empty() {
166                return -1;
167            }
168
169            let picker = caller.data().file_picker.clone();
170            let mut state = picker.lock().unwrap();
171            let max = (out_cap / 4) as usize;
172            let mut handles: Vec<u8> = Vec::with_capacity(paths.len().min(max) * 4);
173            for path in paths.iter().take(max) {
174                let id = state.alloc(path.clone(), false);
175                handles.extend_from_slice(&id.to_le_bytes());
176            }
177            drop(state);
178
179            let count = (handles.len() / 4) as i32;
180            if write_guest_bytes(&mem, &mut caller, out_ptr, &handles).is_err() {
181                return -1;
182            }
183            count
184        },
185    )?;
186
187    // api_folder_pick(title_ptr, title_len) -> u32
188    //   Returns a folder handle, or 0 on cancel.
189    linker.func_wrap(
190        "oxide",
191        "api_folder_pick",
192        |caller: Caller<'_, HostState>, title_ptr: u32, title_len: u32| -> u32 {
193            let mem = caller.data().memory.expect("memory not set");
194            let title = read_guest_string(&mem, &caller, title_ptr, title_len)
195                .unwrap_or_else(|_| "Oxide: Select a folder".to_string());
196
197            let path = match rfd::FileDialog::new().set_title(&title).pick_folder() {
198                Some(p) => p,
199                None => return 0,
200            };
201            let picker = caller.data().file_picker.clone();
202            let id = picker.lock().unwrap().alloc(path, true);
203            id
204        },
205    )?;
206
207    // api_folder_entries(handle, out_ptr, out_cap) -> i32
208    //   Writes JSON array of entries:
209    //     [{"name":"a.txt","size":123,"is_dir":false,"handle":42}, ...]
210    //   Sub-handles are allocated on the fly so guests can read children
211    //   without learning any host path. Returns bytes written, -1 on bad
212    //   handle, -2 on io error, or negative of required size if truncated.
213    linker.func_wrap(
214        "oxide",
215        "api_folder_entries",
216        |mut caller: Caller<'_, HostState>, handle: u32, out_ptr: u32, out_cap: u32| -> i32 {
217            let mem = caller.data().memory.expect("memory not set");
218            let picker = caller.data().file_picker.clone();
219            let dir_path = {
220                let state = picker.lock().unwrap();
221                match state.get(handle) {
222                    Some(e) if e.is_dir => e.path.clone(),
223                    _ => return -1,
224                }
225            };
226
227            let read_dir = match std::fs::read_dir(&dir_path) {
228                Ok(it) => it,
229                Err(_) => return -2,
230            };
231
232            let mut children: Vec<(PathBuf, bool, u64)> = Vec::new();
233            for entry in read_dir.flatten() {
234                let path = entry.path();
235                let meta = match entry.metadata() {
236                    Ok(m) => m,
237                    Err(_) => continue,
238                };
239                children.push((path, meta.is_dir(), meta.len()));
240            }
241
242            let mut json = String::from("[");
243            let mut state = picker.lock().unwrap();
244            for (i, (path, is_dir, size)) in children.iter().enumerate() {
245                if i > 0 {
246                    json.push(',');
247                }
248                let id = state.alloc(path.clone(), *is_dir);
249                json.push_str("{\"name\":");
250                json_escape(&file_name_of(path), &mut json);
251                json.push_str(&format!(
252                    ",\"size\":{size},\"is_dir\":{is_dir},\"handle\":{id}}}",
253                    size = size,
254                    is_dir = is_dir,
255                    id = id,
256                ));
257            }
258            drop(state);
259            json.push(']');
260
261            let bytes = json.as_bytes();
262            if bytes.len() > out_cap as usize {
263                return -(bytes.len() as i32);
264            }
265            if write_guest_bytes(&mem, &mut caller, out_ptr, bytes).is_err() {
266                return -2;
267            }
268            bytes.len() as i32
269        },
270    )?;
271
272    // api_file_read(handle, out_ptr, out_cap) -> i64
273    //   Reads the full file. Returns bytes written, -1 invalid handle,
274    //   -2 io error, or -(required size) if the buffer is too small.
275    linker.func_wrap(
276        "oxide",
277        "api_file_read",
278        |mut caller: Caller<'_, HostState>, handle: u32, out_ptr: u32, out_cap: u32| -> i64 {
279            let mem = caller.data().memory.expect("memory not set");
280            let picker = caller.data().file_picker.clone();
281            let path = {
282                let state = picker.lock().unwrap();
283                match state.get(handle) {
284                    Some(e) if !e.is_dir => e.path.clone(),
285                    _ => return -1,
286                }
287            };
288            let data = match std::fs::read(&path) {
289                Ok(d) => d,
290                Err(_) => return -2,
291            };
292            if data.len() > out_cap as usize {
293                return -(data.len() as i64);
294            }
295            if write_guest_bytes(&mem, &mut caller, out_ptr, &data).is_err() {
296                return -2;
297            }
298            data.len() as i64
299        },
300    )?;
301
302    // api_file_read_range(handle, offset_lo, offset_hi, len, out_ptr, out_cap) -> i64
303    //   Reads [offset .. offset+len) from the file. Returns bytes written,
304    //   -1 invalid handle, -2 io error. Short reads are returned verbatim
305    //   (EOF reached before `len`).
306    linker.func_wrap(
307        "oxide",
308        "api_file_read_range",
309        |mut caller: Caller<'_, HostState>,
310         handle: u32,
311         offset_lo: u32,
312         offset_hi: u32,
313         len: u32,
314         out_ptr: u32,
315         out_cap: u32|
316         -> i64 {
317            use std::io::{Read, Seek, SeekFrom};
318            let mem = caller.data().memory.expect("memory not set");
319            let picker = caller.data().file_picker.clone();
320            let path = {
321                let state = picker.lock().unwrap();
322                match state.get(handle) {
323                    Some(e) if !e.is_dir => e.path.clone(),
324                    _ => return -1,
325                }
326            };
327            let want = (len as usize).min(out_cap as usize);
328            let offset = ((offset_hi as u64) << 32) | (offset_lo as u64);
329            let mut file = match std::fs::File::open(&path) {
330                Ok(f) => f,
331                Err(_) => return -2,
332            };
333            if file.seek(SeekFrom::Start(offset)).is_err() {
334                return -2;
335            }
336            let mut buf = vec![0u8; want];
337            let n = match file.read(&mut buf) {
338                Ok(n) => n,
339                Err(_) => return -2,
340            };
341            buf.truncate(n);
342            if write_guest_bytes(&mem, &mut caller, out_ptr, &buf).is_err() {
343                return -2;
344            }
345            n as i64
346        },
347    )?;
348
349    // api_file_metadata(handle, out_ptr, out_cap) -> i32
350    //   Writes JSON: {"name":"a.txt","size":123,"mime":"image/png",
351    //                 "modified_ms":1712000000000,"is_dir":false}
352    //   Returns bytes written, -1 invalid handle, -2 io error, or
353    //   -(required size) if the buffer is too small.
354    linker.func_wrap(
355        "oxide",
356        "api_file_metadata",
357        |mut caller: Caller<'_, HostState>, handle: u32, out_ptr: u32, out_cap: u32| -> i32 {
358            let mem = caller.data().memory.expect("memory not set");
359            let picker = caller.data().file_picker.clone();
360            let (path, is_dir) = {
361                let state = picker.lock().unwrap();
362                match state.get(handle) {
363                    Some(e) => (e.path.clone(), e.is_dir),
364                    None => return -1,
365                }
366            };
367            let meta = match std::fs::metadata(&path) {
368                Ok(m) => m,
369                Err(_) => return -2,
370            };
371            let name = file_name_of(&path);
372            let ext = path
373                .extension()
374                .map(|e| e.to_string_lossy().to_string())
375                .unwrap_or_default();
376            let mime = if is_dir {
377                "inode/directory"
378            } else {
379                mime_for_extension(&ext)
380            };
381            let mut json = String::new();
382            json.push_str("{\"name\":");
383            json_escape(&name, &mut json);
384            json.push_str(",\"size\":");
385            json.push_str(&meta.len().to_string());
386            json.push_str(",\"mime\":");
387            json_escape(mime, &mut json);
388            json.push_str(",\"modified_ms\":");
389            json.push_str(&modified_ms(&meta).to_string());
390            json.push_str(",\"is_dir\":");
391            json.push_str(if is_dir { "true" } else { "false" });
392            json.push('}');
393
394            let bytes = json.as_bytes();
395            if bytes.len() > out_cap as usize {
396                return -(bytes.len() as i32);
397            }
398            if write_guest_bytes(&mem, &mut caller, out_ptr, bytes).is_err() {
399                console_log(
400                    &caller.data().console,
401                    ConsoleLevel::Error,
402                    "[file_picker] failed to write metadata".to_string(),
403                );
404                return -2;
405            }
406            bytes.len() as i32
407        },
408    )?;
409
410    Ok(())
411}