1use 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
23pub struct PickedEntry {
25 pub path: PathBuf,
26 pub is_dir: bool,
27}
28
29pub 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
121pub fn register_file_picker_functions(linker: &mut Linker<HostState>) -> Result<()> {
123 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 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 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 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 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 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}