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 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-06 23:46:11 +01:00
1 parent 1df97b4af2
commit db521ed2ee
4 files changed
+413 -18

No files matched your search

+7 -3
View File
@@ -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() {
+128 -14
View File
@@ -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<HashMap<String, String>> = RefCell::new(HashMap::new());
use std::path::PathBuf;
use std::sync::Mutex;
static STORE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
static PICKED: Mutex<Option<Vec<u8>>> = 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<String> {
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<String> {
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<Vec<u8>> {
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(""), "_");
}
}