From 67b6f2aa2e3947a0fc627b772b14ce28c7fbfb6e Mon Sep 17 00:00:00 2001 From: quexeky Date: Tue, 13 May 2025 06:53:00 +1000 Subject: [PATCH] chore: Initial path normalisation & parsing with backup generation Signed-off-by: quexeky --- src-tauri/Cargo.lock | 134 +++++++++++++--- src-tauri/Cargo.toml | 19 ++- src-tauri/src/cloud_saves/backup_manager.rs | 97 ++++++++++++ src-tauri/src/cloud_saves/conditions.rs | 6 + src-tauri/src/cloud_saves/metadata.rs | 35 +++++ src-tauri/src/cloud_saves/mod.rs | 8 + src-tauri/src/cloud_saves/normalise.rs | 162 ++++++++++++++++++++ src-tauri/src/cloud_saves/parse.rs | 0 src-tauri/src/cloud_saves/path.rs | 48 ++++++ src-tauri/src/cloud_saves/placeholder.rs | 54 +++++++ src-tauri/src/cloud_saves/resolver.rs | 125 +++++++++++++++ src-tauri/src/cloud_saves/strict_path.rs | 0 src-tauri/src/database/db.rs | 4 +- src-tauri/src/error/backup_error.rs | 21 +++ src-tauri/src/error/mod.rs | 1 + src-tauri/src/process/process_manager.rs | 45 +++++- 16 files changed, 728 insertions(+), 31 deletions(-) create mode 100644 src-tauri/src/cloud_saves/backup_manager.rs create mode 100644 src-tauri/src/cloud_saves/conditions.rs create mode 100644 src-tauri/src/cloud_saves/metadata.rs create mode 100644 src-tauri/src/cloud_saves/mod.rs create mode 100644 src-tauri/src/cloud_saves/normalise.rs create mode 100644 src-tauri/src/cloud_saves/parse.rs create mode 100644 src-tauri/src/cloud_saves/path.rs create mode 100644 src-tauri/src/cloud_saves/placeholder.rs create mode 100644 src-tauri/src/cloud_saves/resolver.rs create mode 100644 src-tauri/src/cloud_saves/strict_path.rs create mode 100644 src-tauri/src/error/backup_error.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fa5e2f2..5b24471 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -712,6 +712,8 @@ version = "1.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16595d3be041c03b09d08d0858631facccee9221e579704070e6e9e4915d3bc7" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -1116,15 +1118,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "directories" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" -dependencies = [ - "dirs-sys 0.4.1", -] - [[package]] name = "dirs" version = "4.0.0" @@ -1154,18 +1147,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", -] - [[package]] name = "dirs-sys" version = "0.5.0" @@ -1273,30 +1254,37 @@ dependencies = [ "cacache 13.1.0", "chrono", "deranged", - "directories", + "dirs 6.0.0", "droplet-rs", "dynfmt", + "filetime", "gethostname", "hex 0.4.3", "http 1.3.1", "http-serde 2.1.1", + "known-folders", "log", "log4rs", "md5", "native_model", "parking_lot 0.12.3", + "rand 0.9.1", "rayon", + "regex", "reqwest 0.12.16", "reqwest-middleware 0.4.2", "reqwest-middleware-cache", "rustbreak", "rustix 0.38.44", + "schemars", "serde", "serde-binary", "serde_json", "serde_with", + "sha1", "shared_child", "slice-deque", + "tar", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -1305,13 +1293,17 @@ dependencies = [ "tauri-plugin-os", "tauri-plugin-shell", "tauri-plugin-single-instance", + "tempfile", "throttle_my_fn", "tokio", "umu-wrapper-lib", "url", "urlencoding", "uuid", + "walkdir", "webbrowser", + "whoami", + "zstd", ] [[package]] @@ -1525,6 +1517,18 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "flate2" version = "1.1.1" @@ -2610,6 +2614,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -2653,6 +2667,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "known-folders" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d9a1740cc8b46e259a0eb787d79d855e79ff10b9855a5eba58868d5da7927c" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "kuchikiki" version = "0.8.2" @@ -2729,6 +2752,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.9.1", "libc", + "redox_syscall 0.5.12", ] [[package]] @@ -5180,6 +5204,17 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -6204,6 +6239,12 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -6424,6 +6465,17 @@ dependencies = [ "windows-core", ] +[[package]] +name = "whoami" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +dependencies = [ + "redox_syscall 0.5.12", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6978,6 +7030,16 @@ dependencies = [ "time", ] +[[package]] +name = "xattr" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" +dependencies = [ + "libc", + "rustix 1.0.7", +] + [[package]] name = "xxhash-rust" version = "0.8.15" @@ -7158,6 +7220,34 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.15+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zvariant" version = "5.5.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b398aa6..41eaabb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,7 +27,6 @@ tauri-plugin-shell = "2.2.1" serde_json = "1" serde-binary = "0.5.0" rayon = "1.10.0" -directories = "5.0.1" webbrowser = "1.0.2" url = "2.5.2" tauri-plugin-deep-link = "2" @@ -55,6 +54,18 @@ reqwest-middleware-cache = "0.1.1" deranged = "=0.4.0" droplet-rs = "0.7.3" gethostname = "1.0.1" +zstd = "0.13.3" +tar = "0.4.44" +rand = "0.9.1" +regex = "1.11.1" +tempfile = "3.19.1" +schemars = "0.8.22" +sha1 = "0.10.6" +dirs = "6.0.0" +whoami = "1.6.0" +filetime = "0.2.25" +walkdir = "2.5.0" +known-folders = "1.2.0" native_model = { version = "0.6.1", features = ["rmp_serde_1_3"] } # tailscale = { path = "./tailscale" } @@ -80,11 +91,7 @@ features = ["fs"] [dependencies.uuid] version = "1.10.0" -features = [ - "v4", # Lets you generate random UUIDs - "fast-rng", # Use a faster (but still sufficiently random) RNG - "macro-diagnostics", # Enable better diagnostics for compile-time UUIDs -] +features = ["v4", "fast-rng", "macro-diagnostics"] [dependencies.rustbreak] version = "2" diff --git a/src-tauri/src/cloud_saves/backup_manager.rs b/src-tauri/src/cloud_saves/backup_manager.rs new file mode 100644 index 0000000..8c9ceb6 --- /dev/null +++ b/src-tauri/src/cloud_saves/backup_manager.rs @@ -0,0 +1,97 @@ +use std::{collections::HashMap, path::PathBuf, str::FromStr}; + +use log::warn; + +use crate::{database::db::GameVersion, error::backup_error::BackupError, process::process_manager::Platform}; + +use super::path::CommonPath; + +pub struct BackupManager<'a> { + pub current_platform: Platform, + pub sources: HashMap<(Platform, Platform), &'a (dyn BackupHandler + Sync + Send)>, +} + +impl BackupManager<'_> { + pub fn new() -> Self { + BackupManager { + #[cfg(target_os = "windows")] + current_platform: Platform::Windows, + + #[cfg(target_os = "macos")] + current_platform: Platform::MacOs, + + #[cfg(target_os = "linux")] + current_platform: Platform::Linux, + + sources: HashMap::from([ + // Current platform to target platform + ( + (Platform::Windows, Platform::Windows), + &WindowsBackupManager {} as &(dyn BackupHandler + Sync + Send), + ), + ( + (Platform::Linux, Platform::Linux), + &LinuxBackupManager {} as &(dyn BackupHandler + Sync + Send), + ), + ( + (Platform::MacOs, Platform::MacOs), + &MacBackupManager {} as &(dyn BackupHandler + Sync + Send), + ), + + ]), + } + } + +} + +pub trait BackupHandler: Send + Sync { + fn root_translate(&self, path: &PathBuf, game: &GameVersion) -> Result; + fn game_translate(&self, _path: &PathBuf, game: &GameVersion) -> Result { Ok(PathBuf::from_str(&game.game_id).unwrap()) } + fn base_translate(&self, path: &PathBuf, game: &GameVersion) -> Result { Ok(self.root_translate(path, game)?.join(self.game_translate(path, game)?)) } + fn home_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result { let c = CommonPath::Home.get().ok_or(BackupError::NotFound); println!("{:?}", c); c } + fn store_user_id_translate(&self, path: &PathBuf, game: &GameVersion) -> Result; + fn os_user_name_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result { Ok(PathBuf::from_str(&whoami::username()).unwrap()) } + fn win_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result { warn!("Unexpected Windows Reference in Backup "); Err(BackupError::InvalidSystem) } + fn win_local_app_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result { warn!("Unexpected Windows Reference in Backup "); Err(BackupError::InvalidSystem) } + fn win_local_app_data_low_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result { warn!("Unexpected Windows Reference in Backup "); Err(BackupError::InvalidSystem) } + fn win_documents_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result { warn!("Unexpected Windows Reference in Backup "); Err(BackupError::InvalidSystem) } + fn win_public_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result { warn!("Unexpected Windows Reference in Backup "); Err(BackupError::InvalidSystem) } + fn win_program_data_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result { warn!("Unexpected Windows Reference in Backup "); Err(BackupError::InvalidSystem) } + fn win_dir_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result { warn!("Unexpected Windows Reference in Backup "); Err(BackupError::InvalidSystem) } + fn xdg_data_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result { warn!("Unexpected XDG Reference in Backup "); Err(BackupError::InvalidSystem) } + fn xdg_config_translate(&self, _path: &PathBuf,_game: &GameVersion) -> Result { warn!("Unexpected XDG Reference in Backup "); Err(BackupError::InvalidSystem) } + fn skip_translate(&self, _path: &PathBuf, _game: &GameVersion) -> Result { Ok(PathBuf::new()) } +} + +pub struct LinuxBackupManager {} +impl BackupHandler for LinuxBackupManager { + fn root_translate(&self, path: &PathBuf, game: &GameVersion) -> Result { + println!("Root translate"); + PathBuf::from_str("~").map_err(|_| BackupError::ParseError) + } + + fn store_user_id_translate(&self, path: &PathBuf, game: &GameVersion) -> Result { + println!("Store user id translate"); + PathBuf::from_str("ID").map_err(|_| BackupError::ParseError) + } +} +pub struct WindowsBackupManager {} +impl BackupHandler for WindowsBackupManager { + fn root_translate(&self, path: &PathBuf, game: &GameVersion) -> Result { + todo!() + } + + fn store_user_id_translate(&self, path: &PathBuf, game: &GameVersion) -> Result { + todo!() + } +} +pub struct MacBackupManager {} +impl BackupHandler for MacBackupManager { + fn root_translate(&self, path: &PathBuf, game: &GameVersion) -> Result { + todo!() + } + + fn store_user_id_translate(&self, path: &PathBuf, game: &GameVersion) -> Result { + todo!() + } +} \ No newline at end of file diff --git a/src-tauri/src/cloud_saves/conditions.rs b/src-tauri/src/cloud_saves/conditions.rs new file mode 100644 index 0000000..1b3af87 --- /dev/null +++ b/src-tauri/src/cloud_saves/conditions.rs @@ -0,0 +1,6 @@ +use crate::process::process_manager::Platform; + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Condition { + Os(Platform) +} diff --git a/src-tauri/src/cloud_saves/metadata.rs b/src-tauri/src/cloud_saves/metadata.rs new file mode 100644 index 0000000..eefa587 --- /dev/null +++ b/src-tauri/src/cloud_saves/metadata.rs @@ -0,0 +1,35 @@ +use crate::database::db::GameVersion; + +use super::conditions::{Condition}; + + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CloudSaveMetadata { + pub files: Vec, + pub game_version: GameVersion, + pub save_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct GameFile { + pub path: String, + pub id: Option, + pub data_type: DataType, + pub tags: Vec, + pub conditions: Vec +} +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +pub enum DataType { + Registry, + File, + Other +} +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Tag { + Config, + Save, + #[default] + #[serde(other)] + Other, +} \ No newline at end of file diff --git a/src-tauri/src/cloud_saves/mod.rs b/src-tauri/src/cloud_saves/mod.rs new file mode 100644 index 0000000..0051a40 --- /dev/null +++ b/src-tauri/src/cloud_saves/mod.rs @@ -0,0 +1,8 @@ +pub mod conditions; +pub mod metadata; +pub mod resolver; +pub mod placeholder; +pub mod normalise; +pub mod parse; +pub mod path; +pub mod backup_manager; \ No newline at end of file diff --git a/src-tauri/src/cloud_saves/normalise.rs b/src-tauri/src/cloud_saves/normalise.rs new file mode 100644 index 0000000..67da752 --- /dev/null +++ b/src-tauri/src/cloud_saves/normalise.rs @@ -0,0 +1,162 @@ +use std::sync::LazyLock; + +use regex::Regex; +use crate::process::process_manager::Platform; + +use super::placeholder::*; + + +pub fn normalize(path: &str, os: Platform) -> String { + let mut path = path.trim().trim_end_matches(['/', '\\']).replace('\\', "/"); + + if path == "~" || path.starts_with("~/") { + path = path.replacen('~', HOME, 1); + } + + static CONSECUTIVE_SLASHES: LazyLock = LazyLock::new(|| Regex::new(r"/{2,}").unwrap()); + static UNNECESSARY_DOUBLE_STAR_1: LazyLock = LazyLock::new(|| Regex::new(r"([^/*])\*{2,}").unwrap()); + static UNNECESSARY_DOUBLE_STAR_2: LazyLock = LazyLock::new(|| Regex::new(r"\*{2,}([^/*])").unwrap()); + static ENDING_WILDCARD: LazyLock = LazyLock::new(|| Regex::new(r"(/\*)+$").unwrap()); + static ENDING_DOT: LazyLock = LazyLock::new(|| Regex::new(r"(/\.)$").unwrap()); + static INTERMEDIATE_DOT: LazyLock = LazyLock::new(|| Regex::new(r"(/\./)").unwrap()); + static BLANK_SEGMENT: LazyLock = LazyLock::new(|| Regex::new(r"(/\s+/)").unwrap()); + static APP_DATA: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%appdata%").unwrap()); + static APP_DATA_ROAMING: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Roaming").unwrap()); + static APP_DATA_LOCAL: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%localappdata%").unwrap()); + static APP_DATA_LOCAL_2: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/AppData/Local/").unwrap()); + static USER_PROFILE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%userprofile%").unwrap()); + static DOCUMENTS: LazyLock = LazyLock::new(|| Regex::new(r"(?i)%userprofile%/Documents").unwrap()); + + for (pattern, replacement) in [ + (&CONSECUTIVE_SLASHES, "/"), + (&UNNECESSARY_DOUBLE_STAR_1, "${1}*"), + (&UNNECESSARY_DOUBLE_STAR_2, "*${1}"), + (&ENDING_WILDCARD, ""), + (&ENDING_DOT, ""), + (&INTERMEDIATE_DOT, "/"), + (&BLANK_SEGMENT, "/"), + (&APP_DATA, WIN_APP_DATA), + (&APP_DATA_ROAMING, WIN_APP_DATA), + (&APP_DATA_LOCAL, WIN_LOCAL_APP_DATA), + (&APP_DATA_LOCAL_2, &format!("{}/", WIN_LOCAL_APP_DATA)), + (&USER_PROFILE, HOME), + (&DOCUMENTS, WIN_DOCUMENTS), + ] { + path = pattern.replace_all(&path, replacement).to_string(); + } + + if os == Platform::Windows { + let documents_2: Regex = Regex::new(r"(?i)/Documents").unwrap(); + + #[allow(clippy::single_element_loop)] + for (pattern, replacement) in [(&documents_2, WIN_DOCUMENTS)] { + path = pattern.replace_all(&path, replacement).to_string(); + } + } + + for (pattern, replacement) in [ + ("{64BitSteamID}", STORE_USER_ID), + ("{Steam3AccountID}", STORE_USER_ID), + ] { + path = path.replace(pattern, replacement); + } + + path +} + +fn too_broad(path: &str) -> bool { + println!("Path: {}", path); + use {BASE, HOME, ROOT, STORE_USER_ID, WIN_APP_DATA, WIN_DIR, WIN_DOCUMENTS, XDG_CONFIG, XDG_DATA}; + + let path_lower = path.to_lowercase(); + + for item in ALL { + if path == *item { + return true; + } + } + + for item in AVOID_WILDCARDS { + if path.starts_with(&format!("{}/*", item)) || path.starts_with(&format!("{}/{}", item, STORE_USER_ID)) { + return true; + } + } + + // These paths are present whether or not the game is installed. + // If possible, they should be narrowed down on the wiki. + for item in [ + format!("{}/{}", BASE, STORE_USER_ID), // because `` is handled as `*` + format!("{}/Documents", HOME), + format!("{}/Saved Games", HOME), + format!("{}/AppData", HOME), + format!("{}/AppData/Local", HOME), + format!("{}/AppData/Local/Packages", HOME), + format!("{}/AppData/LocalLow", HOME), + format!("{}/AppData/Roaming", HOME), + format!("{}/Documents/My Games", HOME), + format!("{}/Library/Application Support", HOME), + format!("{}/Library/Application Support/UserData", HOME), + format!("{}/Library/Preferences", HOME), + format!("{}/.renpy", HOME), + format!("{}/.renpy/persistent", HOME), + format!("{}/Library", HOME), + format!("{}/Library/RenPy", HOME), + format!("{}/Telltale Games", HOME), + format!("{}/config", ROOT), + format!("{}/MMFApplications", WIN_APP_DATA), + format!("{}/RenPy", WIN_APP_DATA), + format!("{}/RenPy/persistent", WIN_APP_DATA), + format!("{}/win.ini", WIN_DIR), + format!("{}/SysWOW64", WIN_DIR), + format!("{}/My Games", WIN_DOCUMENTS), + format!("{}/Telltale Games", WIN_DOCUMENTS), + format!("{}/unity3d", XDG_CONFIG), + format!("{}/unity3d", XDG_DATA), + "C:/Program Files".to_string(), + "C:/Program Files (x86)".to_string(), + ] { + let item = item.to_lowercase(); + if path_lower == item + || path_lower.starts_with(&format!("{}/*", item)) + || path_lower.starts_with(&format!("{}/{}", item, STORE_USER_ID.to_lowercase())) + || path_lower.starts_with(&format!("{}/savesdir", item)) + { + return true; + } + } + + + // Drive letters: + let drives: Regex = Regex::new(r"^[a-zA-Z]:$").unwrap(); + if drives.is_match(path) { + return true; + } + + // Colon not for a drive letter + if path.get(2..).is_some_and(|path| path.contains(':')) { + return true; + } + + // Root: + if path == "/" { + return true; + } + + // Relative path wildcard: + if path.starts_with('*') { + return true; + } + + false +} + +pub fn usable(path: &str) -> bool { + let unprintable: Regex = Regex::new(r"(\p{Cc}|\p{Cf})").unwrap(); + + !path.is_empty() + && !path.contains("{{") + && !path.starts_with("./") + && !path.starts_with("../") + && !too_broad(path) + && !unprintable.is_match(path) +} \ No newline at end of file diff --git a/src-tauri/src/cloud_saves/parse.rs b/src-tauri/src/cloud_saves/parse.rs new file mode 100644 index 0000000..e69de29 diff --git a/src-tauri/src/cloud_saves/path.rs b/src-tauri/src/cloud_saves/path.rs new file mode 100644 index 0000000..92aaaf9 --- /dev/null +++ b/src-tauri/src/cloud_saves/path.rs @@ -0,0 +1,48 @@ +use std::{path::PathBuf, sync::LazyLock}; + +pub enum CommonPath { + Config, + Data, + DataLocal, + DataLocalLow, + Document, + Home, + Public, + SavedGames, +} + +impl CommonPath { + pub fn get(&self) -> Option { + static CONFIG: LazyLock> = LazyLock::new(|| dirs::config_dir()); + static DATA: LazyLock> = LazyLock::new(|| dirs::data_dir()); + static DATA_LOCAL: LazyLock> = LazyLock::new(|| dirs::data_local_dir()); + static DOCUMENT: LazyLock> = LazyLock::new(|| dirs::document_dir()); + static HOME: LazyLock> = LazyLock::new(|| dirs::home_dir()); + static PUBLIC: LazyLock> = LazyLock::new(|| dirs::public_dir()); + + #[cfg(windows)] + static DATA_LOCAL_LOW: LazyLock> = LazyLock::new(|| { + known_folders::get_known_folder_path(known_folders::KnownFolder::LocalAppDataLow) + }); + #[cfg(not(windows))] + static DATA_LOCAL_LOW: Option = None; + + #[cfg(windows)] + static SAVED_GAMES: LazyLock> = LazyLock::new(|| { + known_folders::get_known_folder_path(known_folders::KnownFolder::SavedGames) + }); + #[cfg(not(windows))] + static SAVED_GAMES: Option = None; + + match self { + Self::Config => CONFIG.clone(), + Self::Data => DATA.clone(), + Self::DataLocal => DATA_LOCAL.clone(), + Self::DataLocalLow => DATA_LOCAL_LOW.clone(), + Self::Document => DOCUMENT.clone(), + Self::Home => HOME.clone(), + Self::Public => PUBLIC.clone(), + Self::SavedGames => SAVED_GAMES.clone(), + } + } +} diff --git a/src-tauri/src/cloud_saves/placeholder.rs b/src-tauri/src/cloud_saves/placeholder.rs new file mode 100644 index 0000000..2d8c7ab --- /dev/null +++ b/src-tauri/src/cloud_saves/placeholder.rs @@ -0,0 +1,54 @@ +use std::sync::LazyLock; + +use crate::process::process_manager::Platform; + +pub const ALL: &[&str] = &[ + ROOT, + GAME, + BASE, + HOME, + STORE_USER_ID, + OS_USER_NAME, + WIN_APP_DATA, + WIN_LOCAL_APP_DATA, + WIN_DOCUMENTS, + WIN_PUBLIC, + WIN_PROGRAM_DATA, + WIN_DIR, + XDG_DATA, + XDG_CONFIG, +]; + +/// These are paths where `/*/` is suspicious. +pub const AVOID_WILDCARDS: &[&str] = &[ + ROOT, + HOME, + WIN_APP_DATA, + WIN_LOCAL_APP_DATA, + WIN_DOCUMENTS, + WIN_PUBLIC, + WIN_PROGRAM_DATA, + WIN_DIR, + XDG_DATA, + XDG_CONFIG, +]; + +pub const ROOT: &str = ""; +pub const GAME: &str = ""; +pub const BASE: &str = ""; +pub const HOME: &str = ""; +pub const STORE_USER_ID: &str = ""; +pub const OS_USER_NAME: &str = ""; +pub const WIN_APP_DATA: &str = ""; +pub const WIN_LOCAL_APP_DATA: &str = ""; +pub const WIN_LOCAL_APP_DATA_LOW: &str = ""; +pub const WIN_DOCUMENTS: &str = ""; +pub const WIN_PUBLIC: &str = ""; +pub const WIN_PROGRAM_DATA: &str = ""; +pub const WIN_DIR: &str = ""; +pub const XDG_DATA: &str = ""; +pub const XDG_CONFIG: &str = ""; +pub const SKIP: &str = ""; + +pub static OS_USERNAME: LazyLock = LazyLock::new(|| whoami::username()); + diff --git a/src-tauri/src/cloud_saves/resolver.rs b/src-tauri/src/cloud_saves/resolver.rs new file mode 100644 index 0000000..3f27975 --- /dev/null +++ b/src-tauri/src/cloud_saves/resolver.rs @@ -0,0 +1,125 @@ +use std::{fs::File, io::Write, path::{Component, PathBuf}}; + +use super::{backup_manager::BackupHandler, conditions::Condition, metadata::GameFile, placeholder::*}; +use log::warn; +use rustix::path::Arg; +use tempfile::tempfile; + +use crate::{ + database::db::GameVersion, error::backup_error::BackupError, process::process_manager::Platform, +}; + +use super::{backup_manager::BackupManager, metadata::CloudSaveMetadata, normalise::normalize}; + +pub fn resolve(meta: &mut CloudSaveMetadata) -> File { + let f = File::create_new("save").unwrap(); + let compressor = zstd::Encoder::new(f, 22).unwrap(); + let mut tarball = tar::Builder::new(compressor); + let manager = BackupManager::new(); + for file in meta.files.iter_mut() { + let id = uuid::Uuid::new_v4().to_string(); + let os = match file + .conditions + .iter() + .find_map(|p| match p { + super::conditions::Condition::Os(os) => Some(os), + _ => None, + }) + .cloned() + { + Some(os) => os, + None => { + warn!( + "File {:?} could not be backed up because it did not provide an OS", + &file + ); + continue; + } + }; + let handler = match manager.sources.get(&(manager.current_platform, os)) { + Some(h) => *h, + None => continue, + }; + let t_path = PathBuf::from(normalize(&file.path, os)); + println!("{:?}", &t_path); + let path = parse_path(t_path, handler, &meta.game_version).unwrap(); + let f = std::fs::metadata(&path).unwrap(); // TODO: Fix unwrap here + if f.is_dir() { + tarball.append_dir(&id, path).unwrap(); + } else if f.is_file() { + tarball + .append_file(&id, &mut File::open(path).unwrap()) + .unwrap(); + } + file.id = Some(id); + } + let binding = serde_json::to_string(meta).unwrap(); + println!("Binding: {}", &binding); + let serialized = binding.as_bytes(); + let mut file = tempfile().unwrap(); + file.write(serialized).unwrap(); + tarball.append_file("metadata", &mut file).unwrap(); + tarball.into_inner().unwrap().finish().unwrap() +} + +pub fn parse_path( + path: PathBuf, + backup_handler: &dyn BackupHandler, + game: &GameVersion, +) -> Result { + println!("Parsing: {:?}", &path); + let mut s = PathBuf::new(); + for component in path.components() { + match component.as_str().unwrap() { + ROOT => { s.push(backup_handler.root_translate(&path, game)?)}, + GAME => { s.push(backup_handler.game_translate(&path, game)?)}, + BASE => { s.push(backup_handler.base_translate(&path, game)?)}, + HOME => { s.push(backup_handler.home_translate(&path, game)?)}, + STORE_USER_ID => { s.push(backup_handler.store_user_id_translate(&path, game)?)}, + OS_USER_NAME => { s.push(backup_handler.os_user_name_translate(&path, game)?)}, + WIN_APP_DATA => { s.push(backup_handler.win_app_data_translate(&path, game)?)}, + WIN_LOCAL_APP_DATA => { s.push(backup_handler.win_local_app_data_translate(&path, game)?)}, + WIN_LOCAL_APP_DATA_LOW => { s.push(backup_handler.win_local_app_data_low_translate(&path, game)?)}, + WIN_DOCUMENTS => { s.push(backup_handler.win_documents_translate(&path, game)?)}, + WIN_PUBLIC => { s.push(backup_handler.win_public_translate(&path, game)?)}, + WIN_PROGRAM_DATA => { s.push(backup_handler.win_program_data_translate(&path, game)?)}, + WIN_DIR => { s.push(backup_handler.win_dir_translate(&path, game)?)}, + XDG_DATA => { s.push(backup_handler.xdg_data_translate(&path, game)?)}, + XDG_CONFIG => { s.push(backup_handler.xdg_config_translate(&path, game)?)}, + SKIP => { }, + _ => s.push(PathBuf::from(component.as_os_str())) + } + } + + println!("Final line: {:?}", &s); + Ok(s) +} + +pub fn test() { + let mut meta = CloudSaveMetadata { + files: vec![GameFile { + path: String::from("/favicon.png"), + id: None, + data_type: super::metadata::DataType::File, + tags: Vec::new(), + conditions: vec![Condition::Os(Platform::Linux)], + }], + game_version: GameVersion { + game_id: String::new(), + version_name: String::new(), + platform: Platform::Linux, + launch_command: String::new(), + launch_args: Vec::new(), + launch_command_template: String::new(), + setup_command: String::new(), + setup_args: Vec::new(), + setup_command_template: String::new(), + only_setup: true, + version_index: 0, + delta: false, + umu_id_override: None, + }, + save_id: String::from("aaaaaaa"), + }; + let file = resolve(&mut meta); +} diff --git a/src-tauri/src/cloud_saves/strict_path.rs b/src-tauri/src/cloud_saves/strict_path.rs new file mode 100644 index 0000000..e69de29 diff --git a/src-tauri/src/database/db.rs b/src-tauri/src/database/db.rs index 37ee852..d305e56 100644 --- a/src-tauri/src/database/db.rs +++ b/src-tauri/src/database/db.rs @@ -5,7 +5,6 @@ use std::{ }; use chrono::Utc; -use directories::BaseDirs; use log::{debug, error, info}; use rustbreak::{DeSerError, DeSerializer, PathDatabase, RustbreakError}; use serde::{de::DeserializeOwned, Serialize}; @@ -16,7 +15,8 @@ use crate::DB; use super::models::data::Database; pub static DATA_ROOT_DIR: LazyLock> = - LazyLock::new(|| Mutex::new(BaseDirs::new().unwrap().data_dir().join("drop"))); + LazyLock::new(|| Mutex::new(dirs::data_dir().unwrap().join("drop"))); + // Custom JSON serializer to support everything we need #[derive(Debug, Default, Clone)] diff --git a/src-tauri/src/error/backup_error.rs b/src-tauri/src/error/backup_error.rs new file mode 100644 index 0000000..17a76c5 --- /dev/null +++ b/src-tauri/src/error/backup_error.rs @@ -0,0 +1,21 @@ +use std::fmt::Display; + +use serde_with::SerializeDisplay; + +#[derive(Debug, SerializeDisplay, Clone, Copy)] +pub enum BackupError { + InvalidSystem, + NotFound, + ParseError +} + +impl Display for BackupError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + BackupError::InvalidSystem => "Attempted to generate path for invalid system", + BackupError::NotFound => "Could not generate or find path", + BackupError::ParseError => "Failed to parse path", + }; + write!(f, "{}", s) + } +} \ No newline at end of file diff --git a/src-tauri/src/error/mod.rs b/src-tauri/src/error/mod.rs index 6800740..734e537 100644 --- a/src-tauri/src/error/mod.rs +++ b/src-tauri/src/error/mod.rs @@ -5,3 +5,4 @@ pub mod library_error; pub mod process_error; pub mod remote_access_error; pub mod setup_error; +pub mod backup_error; \ No newline at end of file diff --git a/src-tauri/src/process/process_manager.rs b/src-tauri/src/process/process_manager.rs index ba0cf08..c28ae1b 100644 --- a/src-tauri/src/process/process_manager.rs +++ b/src-tauri/src/process/process_manager.rs @@ -328,13 +328,56 @@ impl ProcessManager<'_> { } } -#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone, Debug)] +#[derive(Eq, Hash, PartialEq, Serialize, Deserialize, Clone, Copy, Debug)] pub enum Platform { Windows, Linux, MacOs, } +impl Platform { + const WINDOWS: bool = cfg!(target_os = "windows"); + const MAC: bool = cfg!(target_os = "macos"); + const LINUX: bool = cfg!(target_os = "linux"); + #[cfg(target_os = "windows")] + pub const HOST: Platform = Self::Windows; + #[cfg(target_os = "macos")] + pub const HOST: Platform = Self::MacOs; + #[cfg(target_os = "linux")] + pub const HOST: Platform = Self::Linux; + + + + pub fn is_case_sensitive(&self) -> bool { + match self { + Self::Windows | Self::MacOs => false, + Self::Linux => true, + } + } +} + +impl From<&str> for Platform { + fn from(value: &str) -> Self { + match value.to_lowercase().trim() { + "windows" => Self::Windows, + "linux" => Self::Linux, + "mac" | "macos" => Self::MacOs, + _ => unimplemented!(), + } + } +} + +impl From for Platform { + fn from(value: whoami::Platform) -> Self { + match value { + whoami::Platform::Windows => Platform::Windows, + whoami::Platform::Linux => Platform::Linux, + whoami::Platform::MacOS => Platform::MacOs, + _ => unimplemented!() + } + } +} + pub trait ProcessHandler: Send + 'static { fn create_launch_process( &self,