Skip to main content

ply_engine/
storage.rs

1#[cfg(not(target_arch = "wasm32"))]
2use std::path::{Path, PathBuf};
3
4#[cfg(target_arch = "wasm32")]
5use macroquad::prelude::next_frame;
6#[cfg(target_arch = "wasm32")]
7use sapp_jsutils::JsObject;
8
9#[derive(Debug, Clone)]
10pub struct Storage {
11    #[cfg(not(target_arch = "wasm32"))]
12    root_path: PathBuf,
13    #[cfg(target_arch = "wasm32")]
14    root_id: i32,
15}
16
17impl Storage {
18    pub async fn new(path: &str) -> Result<Self, String> {
19        #[cfg(not(target_arch = "wasm32"))]
20        {
21            let candidate = PathBuf::from(path);
22            let root_path = if candidate.is_absolute() {
23                candidate
24            } else {
25                let normalized_root = normalize_relative_path(path, "Storage::new path")?;
26                let app_data_dir = platform_app_data_dir()?;
27                join_normalized_path(&app_data_dir, &normalized_root)
28            };
29
30            std::fs::create_dir_all(&root_path).map_err(|e| e.to_string())?;
31
32            Ok(Self { root_path })
33        }
34
35        #[cfg(target_arch = "wasm32")]
36        {
37            let normalized_root = normalize_relative_path(path, "Storage::new path")?;
38            let op_id = unsafe { ply_storage_new(JsObject::string(&normalized_root)) };
39            let result = wait_for_response(op_id).await?;
40            ensure_success(&result)?;
41
42            Ok(Self {
43                root_id: result.field_u32("storage_id") as i32,
44            })
45        }
46    }
47
48    pub async fn save_string(&self, path: &str, data: &str) -> Result<(), String> {
49        self.save_bytes(path, data.as_bytes()).await
50    }
51
52    pub async fn save_bytes(&self, path: &str, data: &[u8]) -> Result<(), String> {
53        #[cfg(not(target_arch = "wasm32"))]
54        {
55            let full_path = self.resolve_path(path)?;
56            if let Some(parent) = full_path.parent() {
57                std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
58            }
59            std::fs::write(full_path, data).map_err(|e| e.to_string())?;
60            Ok(())
61        }
62
63        #[cfg(target_arch = "wasm32")]
64        {
65            let normalized_path = normalize_relative_path(path, "storage save path")?;
66            let op_id = unsafe {
67                ply_storage_save_bytes(
68                    self.root_id,
69                    JsObject::string(&normalized_path),
70                    JsObject::buffer(data),
71                )
72            };
73            let result = wait_for_response(op_id).await?;
74            ensure_success(&result)
75        }
76    }
77
78    pub async fn load_string(&self, path: &str) -> Result<Option<String>, String> {
79        match self.load_bytes(path).await? {
80            Some(bytes) => {
81                let content = String::from_utf8(bytes)
82                    .map_err(|e| format!("Invalid UTF-8 data: {e}"))?;
83                Ok(Some(content))
84            }
85            None => Ok(None),
86        }
87    }
88
89    pub async fn load_bytes(&self, path: &str) -> Result<Option<Vec<u8>>, String> {
90        #[cfg(not(target_arch = "wasm32"))]
91        {
92            let full_path = self.resolve_path(path)?;
93            match std::fs::read(full_path) {
94                Ok(bytes) => Ok(Some(bytes)),
95                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
96                Err(error) => Err(error.to_string()),
97            }
98        }
99
100        #[cfg(target_arch = "wasm32")]
101        {
102            let normalized_path = normalize_relative_path(path, "storage load path")?;
103            let op_id = unsafe {
104                ply_storage_load_bytes(self.root_id, JsObject::string(&normalized_path))
105            };
106            let result = wait_for_response(op_id).await?;
107            ensure_success(&result)?;
108
109            if result.field_u32("exists") == 0 {
110                return Ok(None);
111            }
112
113            let mut bytes = Vec::new();
114            result.field("data").to_byte_buffer(&mut bytes);
115            Ok(Some(bytes))
116        }
117    }
118
119    pub async fn remove(&self, path: &str) -> Result<(), String> {
120        #[cfg(not(target_arch = "wasm32"))]
121        {
122            let full_path = self.resolve_path(path)?;
123            std::fs::remove_file(full_path).map_err(|e| e.to_string())?;
124            Ok(())
125        }
126
127        #[cfg(target_arch = "wasm32")]
128        {
129            let normalized_path = normalize_relative_path(path, "storage remove path")?;
130            let op_id = unsafe {
131                ply_storage_remove(self.root_id, JsObject::string(&normalized_path))
132            };
133            let result = wait_for_response(op_id).await?;
134            ensure_success(&result)
135        }
136    }
137
138    pub async fn export(&self, path: &str) -> Result<(), String> {
139        #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
140        {
141            let full_path = self.resolve_path(path)?;
142            let bytes = std::fs::read(&full_path).map_err(|e| e.to_string())?;
143
144            let normalized_path = normalize_relative_path(path, "storage export path")?;
145            let file_name = normalized_path
146                .rsplit('/')
147                .next()
148                .filter(|s| !s.is_empty())
149                .ok_or_else(|| "Invalid export file name".to_owned())?;
150
151            let target_path = rfd::FileDialog::new()
152                .set_file_name(file_name)
153                .save_file()
154                .ok_or_else(|| "Export canceled".to_owned())?;
155
156            std::fs::write(target_path, bytes).map_err(|e| e.to_string())?;
157            Ok(())
158        }
159
160        #[cfg(target_os = "android")]
161        {
162            let full_path = self.resolve_path(path)?;
163            std::fs::metadata(&full_path).map_err(|e| e.to_string())?;
164
165            let normalized_path = normalize_relative_path(path, "storage export path")?;
166            let file_name = normalized_path
167                .rsplit('/')
168                .next()
169                .filter(|s| !s.is_empty())
170                .ok_or_else(|| "Invalid export file name".to_owned())?;
171
172            let full_path_string = full_path.to_string_lossy().into_owned();
173            let source_path_c =
174                std::ffi::CString::new(full_path_string).map_err(|_| {
175                    "Export path contains unsupported NUL byte".to_owned()
176                })?;
177            let file_name_c = std::ffi::CString::new(file_name).map_err(|_| {
178                "Export file name contains unsupported NUL byte".to_owned()
179            })?;
180            let mime_type_c = std::ffi::CString::new(guess_mime_type(&normalized_path))
181                .map_err(|_| "Export MIME type contains unsupported NUL byte".to_owned())?;
182
183            unsafe {
184                let env = macroquad::miniquad::native::android::attach_jni_env();
185                let activity = macroquad::miniquad::native::android::ACTIVITY;
186                if activity.is_null() {
187                    return Err("Android activity is not available".to_owned());
188                }
189
190                let get_object_class = (**env).GetObjectClass.unwrap();
191                let get_method_id = (**env).GetMethodID.unwrap();
192                let call_void_method = (**env).CallVoidMethod.unwrap();
193                let new_string_utf = (**env).NewStringUTF.unwrap();
194                let delete_local_ref = (**env).DeleteLocalRef.unwrap();
195                let exception_check = (**env).ExceptionCheck.unwrap();
196                let exception_describe = (**env).ExceptionDescribe.unwrap();
197                let exception_clear = (**env).ExceptionClear.unwrap();
198
199                let class = get_object_class(env, activity);
200                if class.is_null() {
201                    return Err("Failed to access Android activity class".to_owned());
202                }
203
204                let method_name = std::ffi::CString::new("exportFile")
205                    .map_err(|_| "Invalid Android method name".to_owned())?;
206                let method_sig = std::ffi::CString::new(
207                    "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V",
208                )
209                .map_err(|_| "Invalid Android method signature".to_owned())?;
210                let method_id = get_method_id(
211                    env,
212                    class,
213                    method_name.as_ptr(),
214                    method_sig.as_ptr(),
215                );
216                if method_id.is_null() {
217                    delete_local_ref(env, class as _);
218                    return Err(
219                        "MainActivity.exportFile(String,String,String) was not found"
220                            .to_owned(),
221                    );
222                }
223
224                let j_source_path = new_string_utf(env, source_path_c.as_ptr());
225                let j_file_name = new_string_utf(env, file_name_c.as_ptr());
226                let j_mime_type = new_string_utf(env, mime_type_c.as_ptr());
227                if j_source_path.is_null() || j_file_name.is_null() || j_mime_type.is_null() {
228                    if !j_source_path.is_null() {
229                        delete_local_ref(env, j_source_path as _);
230                    }
231                    if !j_file_name.is_null() {
232                        delete_local_ref(env, j_file_name as _);
233                    }
234                    if !j_mime_type.is_null() {
235                        delete_local_ref(env, j_mime_type as _);
236                    }
237                    delete_local_ref(env, class as _);
238                    return Err("Failed to allocate Android export strings".to_owned());
239                }
240
241                call_void_method(
242                    env,
243                    activity,
244                    method_id,
245                    j_source_path,
246                    j_file_name,
247                    j_mime_type,
248                );
249
250                if exception_check(env) != 0 {
251                    exception_describe(env);
252                    exception_clear(env);
253                    delete_local_ref(env, j_source_path as _);
254                    delete_local_ref(env, j_file_name as _);
255                    delete_local_ref(env, j_mime_type as _);
256                    delete_local_ref(env, class as _);
257                    return Err(
258                        "Android export failed to open the document picker"
259                            .to_owned(),
260                    );
261                }
262
263                delete_local_ref(env, j_source_path as _);
264                delete_local_ref(env, j_file_name as _);
265                delete_local_ref(env, j_mime_type as _);
266                delete_local_ref(env, class as _);
267            }
268
269            Ok(())
270        }
271
272        #[cfg(target_os = "ios")]
273        {
274            use macroquad::miniquad::native::apple::apple_util::str_to_nsstring;
275            use macroquad::miniquad::native::apple::frameworks::{
276                class, msg_send, nil, NSRect, ObjcId,
277            };
278
279            let full_path = self.resolve_path(path)?;
280            std::fs::metadata(&full_path).map_err(|e| e.to_string())?;
281
282            let view_ctrl = macroquad::miniquad::window::apple_view_ctrl();
283            if view_ctrl.is_null() {
284                return Err("iOS view controller is not available".to_owned());
285            }
286
287            let full_path_string = full_path.to_string_lossy().into_owned();
288
289            unsafe {
290                let ns_path = str_to_nsstring(&full_path_string);
291                let file_url: ObjcId = msg_send![class!(NSURL), fileURLWithPath: ns_path];
292                if file_url.is_null() {
293                    return Err("Failed to create iOS file URL for export".to_owned());
294                }
295
296                let items: ObjcId = msg_send![class!(NSMutableArray), arrayWithObject: file_url];
297                let activity: ObjcId = msg_send![class!(UIActivityViewController), alloc];
298                let activity: ObjcId = msg_send![
299                    activity,
300                    initWithActivityItems: items
301                    applicationActivities: nil
302                ];
303                if activity.is_null() {
304                    return Err("Failed to create iOS share sheet".to_owned());
305                }
306
307                // iPad requires an anchor for popovers.
308                let popover: ObjcId = msg_send![activity, popoverPresentationController];
309                if !popover.is_null() {
310                    let view: ObjcId = msg_send![view_ctrl, view];
311                    let bounds: NSRect = msg_send![view, bounds];
312                    let _: () = msg_send![popover, setSourceView: view];
313                    let _: () = msg_send![popover, setSourceRect: bounds];
314                }
315
316                let _: () = msg_send![
317                    view_ctrl,
318                    presentViewController: activity
319                    animated: true
320                    completion: nil
321                ];
322            }
323
324            Ok(())
325        }
326
327        #[cfg(all(
328            not(target_arch = "wasm32"),
329            not(any(
330                target_os = "linux",
331                target_os = "macos",
332                target_os = "windows",
333                target_os = "android",
334                target_os = "ios"
335            ))
336        ))]
337        {
338            let _ = path;
339            Err("Storage::export is not supported on this platform yet".to_owned())
340        }
341
342        #[cfg(target_arch = "wasm32")]
343        {
344            let normalized_path = normalize_relative_path(path, "storage export path")?;
345            let op_id = unsafe {
346                ply_storage_export(self.root_id, JsObject::string(&normalized_path))
347            };
348            let result = wait_for_response(op_id).await?;
349            ensure_success(&result)
350        }
351    }
352
353    #[cfg(not(target_arch = "wasm32"))]
354    fn resolve_path(&self, relative_path: &str) -> Result<PathBuf, String> {
355        let normalized = normalize_relative_path(relative_path, "storage file path")?;
356        Ok(join_normalized_path(&self.root_path, &normalized))
357    }
358}
359
360#[cfg(target_os = "android")]
361fn guess_mime_type(path: &str) -> &'static str {
362    let extension = path
363        .rsplit_once('.')
364        .map(|(_, ext)| ext)
365        .unwrap_or_default()
366        .to_ascii_lowercase();
367
368    match extension.as_str() {
369        "txt" => "text/plain",
370        "md" => "text/markdown",
371        "json" => "application/json",
372        "csv" => "text/csv",
373        "html" | "htm" => "text/html",
374        "js" => "application/javascript",
375        "wasm" => "application/wasm",
376        "png" => "image/png",
377        "jpg" | "jpeg" => "image/jpeg",
378        "gif" => "image/gif",
379        "webp" => "image/webp",
380        "svg" => "image/svg+xml",
381        "pdf" => "application/pdf",
382        "zip" => "application/zip",
383        _ => "application/octet-stream",
384    }
385}
386
387#[cfg(not(target_arch = "wasm32"))]
388fn join_normalized_path(root: &Path, normalized: &str) -> PathBuf {
389    let mut path = root.to_path_buf();
390    for part in normalized.split('/') {
391        path.push(part);
392    }
393    path
394}
395
396fn normalize_relative_path(path: &str, what: &str) -> Result<String, String> {
397    let trimmed = path.trim();
398
399    if trimmed.is_empty() {
400        return Err(format!("{what} cannot be empty"));
401    }
402
403    if trimmed.starts_with('/') || trimmed.starts_with('\\') {
404        return Err(format!("{what} must be a relative path"));
405    }
406
407    let bytes = trimmed.as_bytes();
408    if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
409        return Err(format!("{what} must be a relative path"));
410    }
411
412    let mut parts: Vec<&str> = Vec::new();
413    for part in trimmed.split(|c| c == '/' || c == '\\') {
414        if part.is_empty() || part == "." {
415            continue;
416        }
417        if part == ".." {
418            return Err(format!("{what} cannot contain '..'"));
419        }
420        parts.push(part);
421    }
422
423    if parts.is_empty() {
424        return Err(format!("{what} is invalid"));
425    }
426
427    Ok(parts.join("/"))
428}
429
430#[cfg(not(target_arch = "wasm32"))]
431fn platform_app_data_dir() -> Result<PathBuf, String> {
432    #[cfg(target_os = "windows")]
433    {
434        if let Some(appdata) = std::env::var_os("APPDATA") {
435            return Ok(PathBuf::from(appdata));
436        }
437        if let Some(home) = std::env::var_os("USERPROFILE") {
438            return Ok(PathBuf::from(home).join("AppData").join("Roaming"));
439        }
440        return Err("Could not resolve %APPDATA% on Windows".to_owned());
441    }
442
443    #[cfg(target_os = "macos")]
444    {
445        if let Some(home) = std::env::var_os("HOME") {
446            return Ok(PathBuf::from(home)
447                .join("Library")
448                .join("Application Support"));
449        }
450        return Err("Could not resolve HOME on macOS".to_owned());
451    }
452
453    #[cfg(target_os = "linux")]
454    {
455        if let Some(xdg_data_home) = std::env::var_os("XDG_DATA_HOME") {
456            return Ok(PathBuf::from(xdg_data_home));
457        }
458        if let Some(home) = std::env::var_os("HOME") {
459            return Ok(PathBuf::from(home).join(".local").join("share"));
460        }
461        return Err("Could not resolve data directory on Linux".to_owned());
462    }
463
464    #[cfg(target_os = "android")]
465    {
466        unsafe {
467            let env = macroquad::miniquad::native::android::attach_jni_env();
468            let activity = macroquad::miniquad::native::android::ACTIVITY;
469
470            if activity.is_null() {
471                return Err("Android activity is not available".to_owned());
472            }
473
474            let get_object_class = (**env).GetObjectClass.unwrap();
475            let get_method_id = (**env).GetMethodID.unwrap();
476            let call_object_method = (**env).CallObjectMethod.unwrap();
477            let get_string_utf_chars = (**env).GetStringUTFChars.unwrap();
478            let release_string_utf_chars = (**env).ReleaseStringUTFChars.unwrap();
479            let delete_local_ref = (**env).DeleteLocalRef.unwrap();
480            let exception_check = (**env).ExceptionCheck.unwrap();
481            let exception_describe = (**env).ExceptionDescribe.unwrap();
482            let exception_clear = (**env).ExceptionClear.unwrap();
483
484            let class = get_object_class(env, activity);
485            if class.is_null() {
486                return Err("Failed to access Android activity class".to_owned());
487            }
488
489            let get_files_dir_name = std::ffi::CString::new("getFilesDir")
490                .map_err(|_| "Invalid Android method name".to_owned())?;
491            let get_files_dir_sig = std::ffi::CString::new("()Ljava/io/File;")
492                .map_err(|_| "Invalid Android method signature".to_owned())?;
493            let get_files_dir = get_method_id(
494                env,
495                class,
496                get_files_dir_name.as_ptr(),
497                get_files_dir_sig.as_ptr(),
498            );
499
500            if get_files_dir.is_null() {
501                delete_local_ref(env, class as _);
502                return Err("Failed to resolve Activity.getFilesDir()".to_owned());
503            }
504
505            let file_obj = call_object_method(env, activity, get_files_dir);
506            if exception_check(env) != 0 || file_obj.is_null() {
507                if exception_check(env) != 0 {
508                    exception_describe(env);
509                    exception_clear(env);
510                }
511                delete_local_ref(env, class as _);
512                return Err("Failed to call Activity.getFilesDir()".to_owned());
513            }
514
515            let file_class = get_object_class(env, file_obj);
516            if file_class.is_null() {
517                delete_local_ref(env, file_obj as _);
518                delete_local_ref(env, class as _);
519                return Err("Failed to access java.io.File class".to_owned());
520            }
521
522            let get_abs_name = std::ffi::CString::new("getAbsolutePath")
523                .map_err(|_| "Invalid Android method name".to_owned())?;
524            let get_abs_sig = std::ffi::CString::new("()Ljava/lang/String;")
525                .map_err(|_| "Invalid Android method signature".to_owned())?;
526            let get_abs = get_method_id(
527                env,
528                file_class,
529                get_abs_name.as_ptr(),
530                get_abs_sig.as_ptr(),
531            );
532
533            if get_abs.is_null() {
534                delete_local_ref(env, file_class as _);
535                delete_local_ref(env, file_obj as _);
536                delete_local_ref(env, class as _);
537                return Err("Failed to resolve File.getAbsolutePath()".to_owned());
538            }
539
540            let path_obj = call_object_method(env, file_obj, get_abs);
541            if exception_check(env) != 0 || path_obj.is_null() {
542                if exception_check(env) != 0 {
543                    exception_describe(env);
544                    exception_clear(env);
545                }
546                delete_local_ref(env, file_class as _);
547                delete_local_ref(env, file_obj as _);
548                delete_local_ref(env, class as _);
549                return Err("Failed to call File.getAbsolutePath()".to_owned());
550            }
551
552            let path_chars = get_string_utf_chars(env, path_obj as _, std::ptr::null_mut());
553            if path_chars.is_null() {
554                delete_local_ref(env, path_obj as _);
555                delete_local_ref(env, file_class as _);
556                delete_local_ref(env, file_obj as _);
557                delete_local_ref(env, class as _);
558                return Err("Failed to read app files directory string".to_owned());
559            }
560
561            let path = std::ffi::CStr::from_ptr(path_chars)
562                .to_string_lossy()
563                .into_owned();
564
565            release_string_utf_chars(env, path_obj as _, path_chars);
566            delete_local_ref(env, path_obj as _);
567            delete_local_ref(env, file_class as _);
568            delete_local_ref(env, file_obj as _);
569            delete_local_ref(env, class as _);
570
571            return Ok(PathBuf::from(path));
572        }
573    }
574
575    #[cfg(target_os = "ios")]
576    {
577        if let Some(home) = std::env::var_os("HOME") {
578            return Ok(PathBuf::from(home).join("Documents"));
579        }
580        if let Some(tmpdir) = std::env::var_os("TMPDIR") {
581            return Ok(PathBuf::from(tmpdir));
582        }
583        return Err("Could not resolve app data directory on iOS".to_owned());
584    }
585}
586
587#[cfg(target_arch = "wasm32")]
588fn ensure_success(response: &JsObject) -> Result<(), String> {
589    if response.field_u32("status") == 1 {
590        return Ok(());
591    }
592
593    let mut error_message = String::new();
594    if response.have_field("error") {
595        response.field("error").to_string(&mut error_message);
596    }
597    if error_message.is_empty() {
598        error_message = "Storage operation failed".to_owned();
599    }
600
601    Err(error_message)
602}
603
604#[cfg(target_arch = "wasm32")]
605async fn wait_for_response(op_id: i32) -> Result<JsObject, String> {
606    loop {
607        let result = unsafe { ply_storage_try_recv(op_id) };
608        if !result.is_nil() {
609            return Ok(result);
610        }
611        next_frame().await;
612    }
613}
614
615#[cfg(target_arch = "wasm32")]
616extern "C" {
617    fn ply_storage_new(path: JsObject) -> i32;
618    fn ply_storage_save_bytes(storage_id: i32, path: JsObject, data: JsObject) -> i32;
619    fn ply_storage_load_bytes(storage_id: i32, path: JsObject) -> i32;
620    fn ply_storage_remove(storage_id: i32, path: JsObject) -> i32;
621    fn ply_storage_export(storage_id: i32, path: JsObject) -> i32;
622    fn ply_storage_try_recv(op_id: i32) -> JsObject;
623}