From db521ed2eeab14ce263f12ee60c18edaa19ea7f1 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Fri, 6 Nov 2026 23:46:11 +0100 Subject: [PATCH] Desktop build: settings and progress kept as files, savegames in a saves folder, system file dialog for loading Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 275 +++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 4 + src/main.rs | 10 +- src/platform.rs | 142 ++++++++++++++++++++++--- 4 files changed, 413 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a422ae2..1fb5b7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,27 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytemuck" version = "1.25.2" @@ -68,11 +89,24 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + [[package]] name = "dungeoncraft" version = "0.1.0" dependencies = [ "macroquad", + "rfd", "serde", "serde_json", ] @@ -119,6 +153,30 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "glam" version = "0.27.0" @@ -155,6 +213,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.191" @@ -167,6 +236,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + [[package]] name = "macroquad" version = "0.4.16" @@ -258,19 +333,86 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "png" version = "0.17.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [ - "bitflags", + "bitflags 1.3.2", "crc32fast", "fdeflate", "flate2", "miniz_oxide 0.8.9", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -295,6 +437,42 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "serde" version = "1.0.229" @@ -344,6 +522,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "syn" version = "3.0.9" @@ -355,6 +539,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -370,6 +563,71 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -392,6 +650,21 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 9e8e3f8..e0ae63a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,7 @@ codegen-units = 1 [profile.dev] opt-level = 1 + +# desktop builds: system file dialog (xdg portal on Linux) and sound output +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] } diff --git a/src/main.rs b/src/main.rs index 432b092..a1e169a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1497,7 +1497,7 @@ async fn main() { MenuAction::Save | MenuAction::FileSave => { let name = save_file_name(); platform::download(&name, app.autosave_json().as_bytes()); - app.set_status(i18n::trf("save.saved", &[name]), now); + app.set_status(i18n::trf("save.saved", &[platform::saved_as(&name)]), now); } MenuAction::CloudSave(id, name) => { if cloud.save(id.as_deref(), &name, &app.autosave_json()) { @@ -1544,7 +1544,7 @@ async fn main() { MenuAction::CampaignSave => { let name = menu.campaign_doc.file_name(); platform::download(&name, menu.campaign_doc.save().as_bytes()); - app.set_status(i18n::trf("campaign.saved", &[name]), now); + app.set_status(i18n::trf("campaign.saved", &[platform::saved_as(&name)]), now); } MenuAction::CampaignEditName => { if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_name"), &menu.campaign_doc.name, 60) { @@ -2349,7 +2349,11 @@ app.pending_briefing = briefing_of(&ed.doc, i); ui.show_help = false; r3d.invalidate(); } - editor::EdAction::SaveFile => platform::download(&ed.doc.file_name(), ed.doc.save().as_bytes()), + editor::EdAction::SaveFile => { + let name = ed.doc.file_name(); + platform::download(&name, ed.doc.save().as_bytes()); + ed.set_status(i18n::trf("campaign.saved", &[platform::saved_as(&name)])); + } editor::EdAction::OpenFile => platform::open_file(), editor::EdAction::ServerSave => { if !cloud.available() { diff --git a/src/platform.rs b/src/platform.rs index 53a1df8..decf2da 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -1,7 +1,7 @@ // Browser integration via our own miniquad JS plugin (www/dc_plugin.js): // persistent key/value storage (localStorage), a cache that is written to storage when the // page is hidden or closed, file download, file upload and HTTP requests. -// Native builds use simple fallbacks. +// Native builds keep the storage as files in the data directory (see imp below). // Result of an HTTP request: status 0 means the service could not be reached pub struct HttpResult { @@ -113,6 +113,11 @@ mod imp { unsafe { dc_cache_set(key.as_ptr(), key.len() as u32, val.as_ptr(), val.len() as u32) } } + // where download() put a file, for the status line: the browser's download folder + pub fn saved_as(name: &str) -> String { + name.to_string() + } + pub fn download(name: &str, data: &[u8]) { unsafe { dc_download(name.as_ptr(), name.len() as u32, data.as_ptr(), data.len() as u32) } } @@ -188,14 +193,84 @@ mod imp { #[cfg(not(target_arch = "wasm32"))] mod imp { - use std::cell::RefCell; + // Desktop builds: the storage keys are files in the data directory, savegames and + // campaigns are written to its "saves" folder, the system file dialog opens files. use std::collections::HashMap; - thread_local! { - static STORE: RefCell> = RefCell::new(HashMap::new()); + use std::path::PathBuf; + use std::sync::Mutex; + + static STORE: Mutex>> = Mutex::new(None); + static PICKED: Mutex>> = Mutex::new(None); + static PICKING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + + // DUNGEONCRAFT_DATA, else the usual per-user application data folder + pub fn data_dir() -> PathBuf { + let env = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from); + if let Some(d) = env("DUNGEONCRAFT_DATA") { + return d; + } + if cfg!(target_os = "windows") { + if let Some(d) = env("APPDATA") { + return d.join("DungeonCraft"); + } + } else if cfg!(target_os = "macos") { + if let Some(h) = env("HOME") { + return h.join("Library/Application Support/DungeonCraft"); + } + } else if let Some(d) = env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share"))) { + return d.join("dungeoncraft"); + } + PathBuf::from("dungeoncraft-data") } + + pub fn saves_dir() -> PathBuf { + data_dir().join("saves") + } + + // a storage key as a file name: only harmless characters + pub(super) fn key_file(key: &str) -> String { + let name: String = key.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' }).collect(); + if name.is_empty() || name.starts_with('.') { + format!("_{}", name) + } else { + name + } + } + + // writes through a temporary file, so a crash never leaves half a file + fn write_file(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, data)?; + std::fs::rename(&tmp, path) + } + pub fn storage_get(key: &str) -> Option { - STORE.with(|s| s.borrow().get(key).cloned()) + let mut store = STORE.lock().unwrap_or_else(|e| e.into_inner()); + let store = store.get_or_insert_with(HashMap::new); + if let Some(v) = store.get(key) { + return Some(v.clone()); + } + let v = std::fs::read_to_string(data_dir().join("store").join(key_file(key))).ok()?; + store.insert(key.to_string(), v.clone()); + Some(v) } + + pub fn storage_set(key: &str, val: &str) { + STORE.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(HashMap::new).insert(key.to_string(), val.to_string()); + let path = data_dir().join("store").join(key_file(key)); + if let Err(e) = write_file(&path, val.as_bytes()) { + eprintln!("DungeonCraft: cannot write {}: {}", path.display(), e); + } + } + + // there is no "page left" moment on the desktop: write at once + pub fn cache_on_leave(key: &str, val: &str) { + storage_set(key, val); + } + pub fn prompt(_title: &str, _default: &str) -> Option { None } @@ -208,19 +283,47 @@ mod imp { pub fn fetch_download_state() -> i32 { 0 } - pub fn storage_set(key: &str, val: &str) { - STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string())); - } - pub fn cache_on_leave(key: &str, val: &str) { - storage_set(key, val); - } + + // a "download" lands in the saves folder pub fn download(name: &str, data: &[u8]) { - let _ = std::fs::write(name, data); + let path = saves_dir().join(key_file(name)); + if let Err(e) = write_file(&path, data) { + eprintln!("DungeonCraft: cannot write {}: {}", path.display(), e); + } } - pub fn open_file() {} + + // where download() put a file, for the status line + pub fn saved_as(name: &str) -> String { + saves_dir().join(key_file(name)).display().to_string() + } + + fn pick() { + let dir = saves_dir(); + let _ = std::fs::create_dir_all(&dir); + let file = rfd::FileDialog::new().set_directory(&dir).pick_file(); + if let Some(bytes) = file.and_then(|f| std::fs::read(f).ok()) { + *PICKED.lock().unwrap_or_else(|e| e.into_inner()) = Some(bytes); + } + PICKING.store(false, std::sync::atomic::Ordering::SeqCst); + } + + // opens the system file dialog (one at a time); poll with take_file() + pub fn open_file() { + if PICKING.swap(true, std::sync::atomic::Ordering::SeqCst) { + return; + } + // macOS dialogs must run on the main thread; elsewhere the game keeps running + if cfg!(target_os = "macos") { + pick(); + } else if std::thread::Builder::new().name("file dialog".into()).spawn(pick).is_err() { + PICKING.store(false, std::sync::atomic::Ordering::SeqCst); + } + } + pub fn take_file() -> Option> { - None + PICKED.lock().unwrap_or_else(|e| e.into_inner()).take() } + // no savegame service in native builds: every request fails like an unreachable server pub fn http(_method: &str, _url: &str, _body: &[u8]) -> u32 { 0 @@ -270,3 +373,14 @@ pub use imp::*; pub extern "C" fn dungeoncraft_crate_version() -> u32 { 1 } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + #[test] + fn storage_keys_become_harmless_file_names() { + assert_eq!(super::imp::key_file("dc.settings"), "dc.settings"); + assert_eq!(super::imp::key_file("../etc/passwd"), "_.._etc_passwd"); + assert_eq!(super::imp::key_file("a b/c"), "a_b_c"); + assert_eq!(super::imp::key_file(""), "_"); + } +}