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:
1 parent
1df97b4af2
commit
db521ed2ee
4 files changed
+413
-18
No files matched your search
+7
-3
@@ -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
@@ -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(""), "_");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user