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