diff --git a/Cargo.toml b/Cargo.toml index 840e99f..307f33c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "anime-launcher-sdk" -version = "1.15.3" +version = "1.16.2" authors = ["Nikita Podvirnyi "] license = "GPL-3.0" readme = "README.md" @@ -9,7 +9,7 @@ edition = "2021" [dependencies.anime-game-core] git = "https://github.com/an-anime-team/anime-game-core" -tag = "1.20.1" +tag = "1.21.1" features = ["all"] # path = "../anime-game-core" # ! for dev purposes only @@ -21,17 +21,18 @@ tracing = "0.1" serde = { version = "1.0", features = ["derive"], optional = true } serde_json = { version = "1.0", optional = true } -cached = { version = "0.51", features = ["proc_macro"] } +cached = { version = "0.52", features = ["proc_macro"] } enum-ordinalize = { version = "4.3", optional = true } wincompatlib = { version = "0.7.4", features = ["all"], optional = true } -lazy_static = { version = "1.4.0", optional = true } +lazy_static = { version = "1.5.0", optional = true } md-5 = { version = "0.10", features = ["asm"], optional = true } discord-rich-presence = { version = "0.2.4", optional = true } [features] genshin = ["anime-game-core/genshin"] star-rail = ["anime-game-core/star-rail"] +zzz = ["anime-game-core/zzz"] honkai = ["anime-game-core/honkai"] pgr = ["anime-game-core/pgr"] wuwa = ["anime-game-core/wuwa"] diff --git a/README.md b/README.md index f09ded2..50b2ddc 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ SDK based on [anime-game-core](https://github.com/an-anime-team/anime-game-core) |----------------------------------------------------------------------------------|---------------------------| | [An Anime Game](https://github.com/an-anime-team/an-anime-game-launcher) | `gen-shin` (without dash) | | [Honkers Railway](https://github.com/an-anime-team/the-honkers-railway-launcher) | `star-rail` | +| [Sleepy](https://github.com/an-anime-team/sleepy-launcher) | `zzz` | | [Honkers](https://github.com/an-anime-team/honkers-launcher) | `hon-kai` (without dash) | | [An Anime Borb](https://github.com/an-anime-team/an-anime-borb-launcher) | `pgr` | | [Waves](https://github.com/an-anime-team/wavey-launcher) | `wuwa` | diff --git a/src/games/mod.rs b/src/games/mod.rs index 46973be..dd0d4fa 100644 --- a/src/games/mod.rs +++ b/src/games/mod.rs @@ -4,6 +4,9 @@ pub mod genshin; #[cfg(feature = "star-rail")] pub mod star_rail; +#[cfg(feature = "zzz")] +pub mod zzz; + #[cfg(feature = "honkai")] pub mod honkai; diff --git a/src/games/zzz/config/mod.rs b/src/games/zzz/config/mod.rs new file mode 100644 index 0000000..aba6afc --- /dev/null +++ b/src/games/zzz/config/mod.rs @@ -0,0 +1,58 @@ +use std::path::PathBuf; + +pub mod schema; + +pub use schema::Schema; + +use crate::config::ConfigExt; +use crate::zzz::consts::config_file; + +static mut CONFIG: Option = None; + +pub struct Config; + +impl ConfigExt for Config { + type Schema = schema::Schema; + + #[inline] + fn config_file() -> PathBuf { + config_file().expect("Failed to resolve config file path") + } + + #[inline] + fn default_schema() -> Self::Schema { + Self::Schema::default() + } + + #[inline] + fn serialize_schema(schema: Self::Schema) -> anyhow::Result { + Ok(serde_json::to_string_pretty(&schema)?) + } + + #[inline] + fn deserialize_schema>(schema: T) -> anyhow::Result { + Ok(Self::Schema::from(&serde_json::from_str(schema.as_ref())?)) + } + + #[inline] + fn clone_schema(schema: &Self::Schema) -> Self::Schema { + schema.clone() + } + + #[inline] + fn get() -> anyhow::Result { + unsafe { + match CONFIG.as_ref() { + Some(config) => Ok(config.clone()), + None => Self::get_raw() + } + } + } + + #[inline] + fn update(schema: Self::Schema) { + unsafe { + CONFIG = Some(schema); + } + } +} diff --git a/src/games/zzz/config/schema/components.rs b/src/games/zzz/config/schema/components.rs new file mode 100644 index 0000000..1b500b5 --- /dev/null +++ b/src/games/zzz/config/schema/components.rs @@ -0,0 +1,60 @@ +use std::path::PathBuf; + +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use crate::zzz::consts::launcher_dir; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Components { + pub path: PathBuf, + pub servers: Vec +} + +impl Default for Components { + #[inline] + fn default() -> Self { + let launcher_dir = launcher_dir().expect("Failed to get launcher dir"); + + Self { + path: launcher_dir.join("components"), + servers: vec![ + String::from("https://github.com/an-anime-team/components") + ] + } + } +} + +impl From<&JsonValue> for Components { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + path: match value.get("path") { + Some(value) => match value.as_str() { + Some(value) => PathBuf::from(value), + None => default.path + }, + None => default.path + }, + + servers: match value.get("servers") { + Some(value) => match value.as_array() { + Some(values) => { + let mut servers = Vec::new(); + + for value in values { + if let Some(server) = value.as_str() { + servers.push(server.to_string()); + } + } + + servers + }, + None => default.servers + }, + None => default.servers + } + } + } +} diff --git a/src/games/zzz/config/schema/game/enhancements.rs b/src/games/zzz/config/schema/game/enhancements.rs new file mode 100644 index 0000000..ac2d90c --- /dev/null +++ b/src/games/zzz/config/schema/game/enhancements.rs @@ -0,0 +1,37 @@ +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use crate::config::schema_blanks::prelude::*; + +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Enhancements { + pub fsr: Fsr, + pub gamemode: bool, + pub hud: HUD, + + pub gamescope: Gamescope +} + +impl From<&JsonValue> for Enhancements { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + fsr: value.get("fsr") + .map(Fsr::from) + .unwrap_or(default.fsr), + + gamemode: value.get("gamemode") + .and_then(JsonValue::as_bool) + .unwrap_or(default.gamemode), + + hud: value.get("hud") + .map(HUD::from) + .unwrap_or(default.hud), + + gamescope: value.get("gamescope") + .map(Gamescope::from) + .unwrap_or(default.gamescope) + } + } +} diff --git a/src/games/zzz/config/schema/game/mod.rs b/src/games/zzz/config/schema/game/mod.rs new file mode 100644 index 0000000..57cab61 --- /dev/null +++ b/src/games/zzz/config/schema/game/mod.rs @@ -0,0 +1,104 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use crate::config::schema_blanks::prelude::*; +use crate::zzz::consts::launcher_dir; + +crate::config_impl_wine_schema!(launcher_dir); +crate::config_impl_dxvk_schema!(launcher_dir); + +pub mod paths; +pub mod enhancements; + +pub mod prelude { + pub use super::Wine; + pub use super::Dxvk; + + pub use super::paths::Paths; + pub use super::enhancements::Enhancements; +} + +use prelude::*; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Game { + pub path: Paths, + pub wine: Wine, + pub dxvk: Dxvk, + pub enhancements: Enhancements, + pub environment: HashMap, + pub command: Option +} + +impl Default for Game { + #[inline] + fn default() -> Self { + Self { + path: Paths::default(), + wine: Wine::default(), + dxvk: Dxvk::default(), + enhancements: Enhancements::default(), + environment: HashMap::new(), + command: None + } + } +} + +impl From<&JsonValue> for Game { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + path: value.get("path") + .map(Paths::from) + .unwrap_or(default.path), + + wine: value.get("wine") + .map(Wine::from) + .unwrap_or(default.wine), + + dxvk: value.get("dxvk") + .map(Dxvk::from) + .unwrap_or(default.dxvk), + + enhancements: value.get("enhancements") + .map(Enhancements::from) + .unwrap_or(default.enhancements), + + environment: match value.get("environment") { + Some(value) => match value.as_object() { + Some(values) => { + let mut vars = HashMap::new(); + + for (name, value) in values { + if let Some(value) = value.as_str() { + vars.insert(name.clone(), value.to_string()); + } + } + + vars + }, + None => default.environment + }, + None => default.environment + }, + + command: match value.get("command") { + Some(value) => { + if value.is_null() { + None + } else { + match value.as_str() { + Some(value) => Some(value.to_string()), + None => default.command + } + } + }, + None => default.command + } + } + } +} diff --git a/src/games/zzz/config/schema/game/paths.rs b/src/games/zzz/config/schema/game/paths.rs new file mode 100644 index 0000000..3712270 --- /dev/null +++ b/src/games/zzz/config/schema/game/paths.rs @@ -0,0 +1,54 @@ +use std::path::{Path, PathBuf}; + +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use anime_game_core::zzz::consts::GameEdition; + +use crate::zzz::consts::launcher_dir; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Paths { + pub global: PathBuf, + pub china: PathBuf +} + +impl Paths { + #[inline] + /// Get game path for given edition + pub fn for_edition(&self, edition: impl Into) -> &Path { + match edition.into() { + GameEdition::Global => self.global.as_path(), + GameEdition::China => self.china.as_path() + } + } +} + +impl Default for Paths { + fn default() -> Self { + let launcher_dir = launcher_dir().expect("Failed to get launcher dir"); + + Self { + global: launcher_dir.join(concat!("Zen", "less Z", "one Zero")), + china: launcher_dir.join(concat!("Zen", "less Z", "one Zero")) + } + } +} + +impl From<&JsonValue> for Paths { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + global: value.get("global") + .and_then(JsonValue::as_str) + .map(PathBuf::from) + .unwrap_or(default.global), + + china: value.get("china") + .and_then(JsonValue::as_str) + .map(PathBuf::from) + .unwrap_or(default.china), + } + } +} diff --git a/src/games/zzz/config/schema/launcher/discord_rpc.rs b/src/games/zzz/config/schema/launcher/discord_rpc.rs new file mode 100644 index 0000000..6673d6a --- /dev/null +++ b/src/games/zzz/config/schema/launcher/discord_rpc.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; + +use crate::discord_rpc::DiscordRpcParams; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiscordRpc { + pub app_id: u64, + pub enabled: bool, + pub title: String, + pub subtitle: String, + pub icon: String +} + +impl From for DiscordRpcParams { + #[inline] + fn from(config: DiscordRpc) -> Self { + Self { + app_id: config.app_id, + enabled: config.enabled, + title: config.title, + subtitle: config.subtitle, + icon: config.icon + } + } +} + +impl Default for DiscordRpc { + #[inline] + fn default() -> Self { + Self { + app_id: 1258318006392590336, + enabled: false, + + title: String::from("Exploring"), + subtitle: String::from("New Eridu"), + icon: String::from("launcher") + } + } +} + +impl From<&JsonValue> for DiscordRpc { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + app_id: match value.get("app_id") { + Some(value) => value.as_u64().unwrap_or(default.app_id), + None => default.app_id + }, + + enabled: match value.get("enabled") { + Some(value) => value.as_bool().unwrap_or(default.enabled), + None => default.enabled + }, + + title: match value.get("title") { + Some(value) => value.as_str().unwrap_or(&default.title).to_string(), + None => default.title + }, + + subtitle: match value.get("subtitle") { + Some(value) => value.as_str().unwrap_or(&default.subtitle).to_string(), + None => default.subtitle + }, + + icon: match value.get("icon") { + Some(value) => value.as_str().unwrap_or(&default.icon).to_string(), + None => default.icon + } + } + } +} diff --git a/src/games/zzz/config/schema/launcher/mod.rs b/src/games/zzz/config/schema/launcher/mod.rs new file mode 100644 index 0000000..6eefe7f --- /dev/null +++ b/src/games/zzz/config/schema/launcher/mod.rs @@ -0,0 +1,154 @@ +use std::path::PathBuf; + +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use enum_ordinalize::Ordinalize; + +use anime_game_core::zzz::consts::GameEdition; + +use crate::config::schema_blanks::prelude::*; +use crate::zzz::consts::launcher_dir; + +#[cfg(feature = "environment-emulation")] +use crate::zzz::env_emulation::Environment; + +#[cfg(feature = "discord-rpc")] +pub mod discord_rpc; + +pub mod prelude { + pub use super::{ + Launcher, + LauncherStyle, + LauncherBehavior + }; + + #[cfg(feature = "discord-rpc")] + pub use super::discord_rpc::DiscordRpc; +} + +use prelude::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ordinalize, Serialize, Deserialize)] +pub enum LauncherStyle { + Modern, + Classic +} + +impl Default for LauncherStyle { + #[inline] + fn default() -> Self { + Self::Modern + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Ordinalize, Serialize, Deserialize)] +pub enum LauncherBehavior { + Nothing, + Hide, + Close +} + +impl Default for LauncherBehavior { + #[inline] + fn default() -> Self { + Self::Hide + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Launcher { + pub language: String, + pub edition: GameEdition, + pub style: LauncherStyle, + pub temp: Option, + pub repairer: Repairer, + + #[cfg(feature = "discord-rpc")] + pub discord_rpc: DiscordRpc, + + #[cfg(feature = "environment-emulation")] + pub environment: Environment, + + pub behavior: LauncherBehavior +} + +impl Default for Launcher { + #[inline] + fn default() -> Self { + Self { + language: String::from("en-us"), + edition: GameEdition::from_system_lang(), + style: LauncherStyle::default(), + temp: launcher_dir().ok(), + repairer: Repairer::default(), + + #[cfg(feature = "discord-rpc")] + discord_rpc: DiscordRpc::default(), + + #[cfg(feature = "environment-emulation")] + environment: Environment::default(), + + behavior: LauncherBehavior::default() + } + } +} + +impl From<&JsonValue> for Launcher { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + language: match value.get("language") { + Some(value) => value.as_str().unwrap_or(&default.language).to_string(), + None => default.language + }, + + edition: match value.get("edition") { + Some(value) => serde_json::from_value(value.clone()).unwrap_or(default.edition), + None => default.edition + }, + + style: match value.get("style") { + Some(value) => serde_json::from_value(value.to_owned()).unwrap_or_default(), + None => default.style + }, + + temp: match value.get("temp") { + Some(value) => { + if value.is_null() { + None + } else { + match value.as_str() { + Some(value) => Some(PathBuf::from(value)), + None => default.temp + } + } + }, + None => default.temp + }, + + repairer: match value.get("repairer") { + Some(value) => Repairer::from(value), + None => default.repairer + }, + + #[cfg(feature = "discord-rpc")] + discord_rpc: match value.get("discord_rpc") { + Some(value) => DiscordRpc::from(value), + None => default.discord_rpc + }, + + #[cfg(feature = "environment-emulation")] + environment: match value.get("environment") { + Some(value) => serde_json::from_value(value.clone()).unwrap_or(default.environment), + None => default.environment + }, + + behavior: match value.get("behavior") { + Some(value) => serde_json::from_value(value.clone()).unwrap_or(default.behavior), + None => default.behavior + } + } + } +} diff --git a/src/games/zzz/config/schema/mod.rs b/src/games/zzz/config/schema/mod.rs new file mode 100644 index 0000000..85a4585 --- /dev/null +++ b/src/games/zzz/config/schema/mod.rs @@ -0,0 +1,120 @@ +use std::path::PathBuf; + +use serde::{Serialize, Deserialize}; +use serde_json::Value as JsonValue; + +use wincompatlib::prelude::*; + +#[cfg(feature = "sandbox")] +use crate::config::schema_blanks::sandbox::Sandbox; + +#[cfg(feature = "components")] +use crate::components::{ + wine::{ + UnifiedWine, + Version as WineVersion + }, + dxvk::Version as DxvkVersion +}; + +pub mod launcher; +pub mod game; + +#[cfg(feature = "components")] +pub mod components; + +pub mod prelude { + pub use super::launcher::prelude::*; + pub use super::game::prelude::*; + + pub use super::game::*; + + #[cfg(feature = "components")] + pub use super::components::*; +} + +use prelude::*; + +#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Schema { + pub launcher: Launcher, + pub game: Game, + + #[cfg(feature = "sandbox")] + pub sandbox: Sandbox, + + #[cfg(feature = "components")] + pub components: Components +} + +impl From<&JsonValue> for Schema { + fn from(value: &JsonValue) -> Self { + let default = Self::default(); + + Self { + launcher: match value.get("launcher") { + Some(value) => Launcher::from(value), + None => default.launcher + }, + + game: match value.get("game") { + Some(value) => Game::from(value), + None => default.game + }, + + #[cfg(feature = "sandbox")] + sandbox: match value.get("sandbox") { + Some(value) => Sandbox::from(value), + None => default.sandbox + }, + + #[cfg(feature = "components")] + components: match value.get("components") { + Some(value) => Components::from(value), + None => default.components + } + } + } +} + +impl Schema { + #[cfg(feature = "components")] + /// Get selected wine version + pub fn get_selected_wine(&self) -> anyhow::Result> { + match &self.game.wine.selected { + Some(selected) => WineVersion::find_in(&self.components.path, selected), + None => Ok(None) + } + } + + #[cfg(feature = "components")] + /// Get selected dxvk version + pub fn get_selected_dxvk(&self) -> anyhow::Result> { + match wincompatlib::dxvk::Dxvk::get_version(&self.game.wine.prefix)? { + Some(version) => DxvkVersion::find_in(&self.components.path, version), + None => Ok(None) + } + } + + #[cfg(feature = "components")] + /// Resolve real wine prefix path using wincompatlib + /// + /// - For general wine build returns `game.wine.prefix` + /// - For proton-like builds return `game.wine.prefix`/`pfx` + pub fn get_wine_prefix_path(&self) -> PathBuf { + if let Ok(Some(wine)) = self.get_selected_wine() { + let wine = wine + .to_wine(&self.components.path, Some(&self.game.wine.builds.join(&wine.name))) + .with_prefix(&self.game.wine.prefix); + + let prefix = match wine { + UnifiedWine::Default(wine) => wine.prefix, + UnifiedWine::Proton(proton) => proton.wine().prefix.clone() + }; + + return prefix; + } + + self.game.wine.prefix.clone() + } +} diff --git a/src/games/zzz/consts.rs b/src/games/zzz/consts.rs new file mode 100644 index 0000000..636309d --- /dev/null +++ b/src/games/zzz/consts.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; + +pub const FOLDER_NAME: &str = "sleepy-launcher"; + +/// Get default launcher dir path +/// +/// If `LAUNCHER_FOLDER` variable is set, then its value will be returned. Otherwise return `$HOME/.local/share/sleepy-launcher` +pub fn launcher_dir() -> anyhow::Result { + if let Ok(folder) = std::env::var("LAUNCHER_FOLDER") { + return Ok(folder.into()); + } + + Ok(std::env::var("XDG_DATA_HOME") + .or_else(|_| std::env::var("HOME").map(|home| home + "/.local/share")) + .map(|home| PathBuf::from(home).join(FOLDER_NAME))?) +} + +/// Get launcher's cache dir path +/// +/// If `CACHE_FOLDER` variable is set, then its value will be returned. Otherwise return `$HOME/.cache/sleepy-launcher` +pub fn cache_dir() -> anyhow::Result { + if let Ok(folder) = std::env::var("CACHE_FOLDER") { + return Ok(folder.into()); + } + + Ok(std::env::var("XDG_CACHE_HOME") + .or_else(|_| std::env::var("HOME").map(|home| home + "/.cache")) + .map(|home| PathBuf::from(home).join(FOLDER_NAME))?) +} + +/// Get config file path +/// +/// Default is `$HOME/.local/share/sleepy-launcher/config.json` +pub fn config_file() -> anyhow::Result { + launcher_dir().map(|dir| dir.join("config.json")) +} diff --git a/src/games/zzz/env_emulation.rs b/src/games/zzz/env_emulation.rs new file mode 100644 index 0000000..f355ad3 --- /dev/null +++ b/src/games/zzz/env_emulation.rs @@ -0,0 +1,77 @@ +use serde::{Serialize, Deserialize}; +use enum_ordinalize::Ordinalize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Ordinalize)] +pub enum Environment { + /// `config.ini` format: + /// + /// ```ini + /// [General] + /// channel=1 + /// cps=mihoyo + /// game_version=[game version] + /// sub_channel=0 + /// ``` + PC, + + /// `config.ini` format: + /// + /// ```ini + /// [General] + /// channel=1 + /// cps=pcseaepic + /// game_version=[game version] + /// # plugin_sdk_version=2.14.2 (??? not used now) + /// sub_channel=3 + /// ``` + Epic, + + /// `config.ini` format: + /// + /// ```ini + /// [General] + /// channel=1 + /// cps=pcgoogle + /// game_version=[game version] + /// sub_channel=6 + /// ``` + Android +} + +impl Default for Environment { + #[inline] + fn default() -> Self { + Self::PC + } +} + +impl Environment { + /// Generate `config.ini`'s content + pub fn generate_config(&self, game_version: impl AsRef) -> String { + match self { + Self::PC => [ + "[General]", + "channel=1", + "cps=mihoyo", + &format!("game_version={}", game_version.as_ref()), + "sub_channel=0" + ].join("\n"), + + Self::Epic => [ + "[General]", + "channel=1", + "cps=pcseaepic", + &format!("game_version={}", game_version.as_ref()), + "sub_channel=3" + ].join("\n"), + + Self::Android => [ + "[General]", + "channel=1", + "cps=pcgoogle", + &format!("game_version={}", game_version.as_ref()), + "sub_channel=6" + ].join("\n") + } + } +} diff --git a/src/games/zzz/game.rs b/src/games/zzz/game.rs new file mode 100644 index 0000000..afe2145 --- /dev/null +++ b/src/games/zzz/game.rs @@ -0,0 +1,287 @@ +use std::process::{Command, Stdio}; +use std::path::PathBuf; + +use anime_game_core::prelude::*; +use anime_game_core::zzz::telemetry; +use anime_game_core::zzz::game::Game; + +use crate::components::wine::Bundle as WineBundle; + +use crate::config::ConfigExt; +use crate::zzz::config::Config; + +use crate::config::schema_blanks::prelude::{ + WineDrives, + AllowedDrives +}; + +use crate::zzz::consts; + +#[cfg(feature = "discord-rpc")] +use crate::discord_rpc::*; + +#[cfg(feature = "sessions")] +use crate::{ + sessions::SessionsExt, + zzz::sessions::Sessions +}; + +#[derive(Debug, Clone)] +struct Folders { + pub wine: PathBuf, + pub prefix: PathBuf, + pub game: PathBuf, + pub temp: PathBuf +} + +fn replace_keywords(command: impl ToString, folders: &Folders) -> String { + command.to_string() + .replace("%build%", folders.wine.to_str().unwrap()) + .replace("%prefix%", folders.prefix.to_str().unwrap()) + .replace("%temp%", folders.game.to_str().unwrap()) + .replace("%launcher%", &consts::launcher_dir().unwrap().to_string_lossy()) + .replace("%game%", folders.temp.to_str().unwrap()) +} + +/// Try to run the game +/// +/// This function will freeze thread it was called from while the game is running +#[tracing::instrument(level = "info", ret)] +pub fn run() -> anyhow::Result<()> { + tracing::info!("Preparing to run the game"); + + let config = Config::get()?; + + let game_path = config.game.path.for_edition(config.launcher.edition); + + if !game_path.exists() { + return Err(anyhow::anyhow!("Game is not installed")); + } + + let Some(wine) = config.get_selected_wine()? else { + anyhow::bail!("Couldn't find wine executable"); + }; + + let features = wine.features(&config.components.path)?.unwrap_or_default(); + + let mut folders = Folders { + wine: config.game.wine.builds.join(&wine.name), + prefix: config.game.wine.prefix.clone(), + game: config.game.path.for_edition(config.launcher.edition).to_path_buf(), + temp: config.launcher.temp.clone().unwrap_or(std::env::temp_dir()) + }; + + // Check telemetry servers + + tracing::info!("Checking telemetry"); + + if let Ok(Some(server)) = telemetry::is_disabled(config.launcher.edition) { + return Err(anyhow::anyhow!("Telemetry server is not disabled: {server}")); + } + + // Generate `config.ini` if environment emulation feature is presented + + #[cfg(feature = "environment-emulation")] { + let game = Game::new(game_path, config.launcher.edition); + + std::fs::write( + game_path.join("config.ini"), + config.launcher.environment.generate_config(game.get_version()?.to_string()) + )?; + } + + // Prepare wine prefix drives + + config.game.wine.drives.map_folders(&folders.game, &folders.prefix)?; + + // Workaround for sandboxing feature + if config.sandbox.enabled { + WineDrives::map_folder(&folders.prefix, AllowedDrives::C, "../drive_c")?; + WineDrives::map_folder(&folders.prefix, AllowedDrives::Z, "/")?; + } + + // Prepare bash -c '' + // %command% = %bash_command% %windows_command% %launch_args% + + let mut bash_command = String::new(); + let mut windows_command = String::new(); + let mut launch_args = String::new(); + + if config.game.enhancements.gamemode { + bash_command += "gamemoderun "; + } + + let run_command = features.command + .map(|command| replace_keywords(command, &folders)) + .unwrap_or(format!("'{}'", folders.wine.join(wine.files.wine64.unwrap_or(wine.files.wine)).to_string_lossy())); + + bash_command += &run_command; + bash_command += " "; + + if let Some(virtual_desktop) = config.game.wine.virtual_desktop.get_command("an_anime_game") { + windows_command += &virtual_desktop; + windows_command += " "; + } + + windows_command += "ZenlessZoneZero.exe "; + + if config.game.wine.borderless { + launch_args += "-screen-fullscreen 0 -popupwindow "; + } + + // https://notabug.org/Krock/dawn/src/master/TWEAKS.md + if config.game.enhancements.fsr.enabled { + launch_args += "-window-mode exclusive "; + } + + // gamescope -- + if let Some(gamescope) = config.game.enhancements.gamescope.get_command() { + bash_command = format!("{gamescope} -- {bash_command}"); + } + + // Bundle all windows arguments used to run the game into a single file + if features.compact_launch { + std::fs::write(folders.game.join("compact_launch.bat"), format!("start {windows_command} {launch_args}\nexit"))?; + + windows_command = String::from("compact_launch.bat"); + launch_args = String::new(); + } + + // bwrap -- + #[cfg(feature = "sandbox")] + if config.sandbox.enabled { + let bwrap = config.sandbox.get_command( + folders.wine.to_str().unwrap(), + folders.prefix.to_str().unwrap(), + folders.game.to_str().unwrap() + ); + + let sandboxed_folders = Folders { + wine: PathBuf::from("/tmp/sandbox/wine"), + prefix: PathBuf::from("/tmp/sandbox/prefix"), + game: PathBuf::from("/tmp/sandbox/game"), + temp: PathBuf::from("/tmp") + }; + + bash_command = bash_command + .replace(folders.wine.to_str().unwrap(), sandboxed_folders.wine.to_str().unwrap()) + .replace(folders.prefix.to_str().unwrap(), sandboxed_folders.prefix.to_str().unwrap()) + .replace(folders.game.to_str().unwrap(), sandboxed_folders.game.to_str().unwrap()) + .replace(folders.temp.to_str().unwrap(), sandboxed_folders.temp.to_str().unwrap()); + + bash_command = format!("{bwrap} --chdir /tmp/sandbox/game -- {bash_command}"); + folders = sandboxed_folders; + } + + // Finalize launching command + bash_command = match &config.game.command { + // Use user-given launch command + Some(command) => replace_keywords(command, &folders) + .replace("%command%", &format!("{bash_command} {windows_command} {launch_args}")) + .replace("%bash_command%", &bash_command) + .replace("%windows_command%", &windows_command) + .replace("%launch_args%", &launch_args), + + // Combine bash and windows parts of the command + None => format!("{bash_command} {windows_command} {launch_args}") + }; + + let mut command = Command::new("bash"); + + command.arg("-c"); + command.arg(&bash_command); + + // Setup environment + + command.env("WINEARCH", "win64"); + command.env("WINEPREFIX", &folders.prefix); + + // Add environment flags for selected wine + for (key, value) in features.env.into_iter() { + command.env(key, replace_keywords(value, &folders)); + } + + // Add environment flags for selected dxvk + if let Ok(Some(dxvk )) = config.get_selected_dxvk() { + if let Ok(Some(features)) = dxvk.features(&config.components.path) { + for (key, value) in features.env.iter() { + command.env(key, replace_keywords(value, &folders)); + } + } + } + + let mut wine_folder = folders.wine.clone(); + + if features.bundle == Some(WineBundle::Proton) { + wine_folder.push("files"); + } + + command.envs(config.game.enhancements.hud.get_env_vars(config.game.enhancements.gamescope.enabled)); + command.envs(config.game.enhancements.fsr.get_env_vars()); + + command.envs(config.game.wine.sync.get_env_vars()); + command.envs(config.game.wine.language.get_env_vars()); + command.envs(config.game.wine.shared_libraries.get_env_vars(wine_folder)); + + command.envs(&config.game.environment); + + #[cfg(feature = "sessions")] + if let Some(current) = Sessions::get_current()? { + Sessions::apply(current, config.get_wine_prefix_path())?; + } + + // Start Discord RPC just before the game + #[cfg(feature = "discord-rpc")] + let rpc = if config.launcher.discord_rpc.enabled { + Some(DiscordRpc::new(config.launcher.discord_rpc.clone().into())) + } else { + None + }; + + #[cfg(feature = "discord-rpc")] + if let Some(rpc) = &rpc { + rpc.update(RpcUpdates::Connect)?; + } + + // Run command + + let variables = command + .get_envs() + .map(|(key, value)| format!("{}=\"{}\"", key.to_string_lossy(), value.unwrap_or_default().to_string_lossy())) + .fold(String::new(), |acc, env| acc + " " + &env); + + tracing::info!("Running the game with command: {variables} bash -c \"{bash_command}\""); + + // We use real current dir here because sandboxed one + // obviously doesn't exist + command.current_dir(config.game.path.for_edition(config.launcher.edition)) + .spawn()?.wait_with_output()?; + + loop { + std::thread::sleep(std::time::Duration::from_secs(3)); + + let output = Command::new("ps").arg("-A").stdout(Stdio::piped()).output()?; + let output = String::from_utf8_lossy(&output.stdout); + + if !output.contains("ZenlessZoneZero") { + break; + } + + #[cfg(feature = "discord-rpc")] + if let Some(rpc) = &rpc { + rpc.update(RpcUpdates::Update)?; + } + } + + #[cfg(feature = "discord-rpc")] + if let Some(rpc) = &rpc { + rpc.update(RpcUpdates::Disconnect)?; + } + + #[cfg(feature = "sessions")] + if let Some(current) = Sessions::get_current()? { + Sessions::update(current, config.get_wine_prefix_path())?; + } + + Ok(()) +} diff --git a/src/games/zzz/mod.rs b/src/games/zzz/mod.rs new file mode 100644 index 0000000..59f65d7 --- /dev/null +++ b/src/games/zzz/mod.rs @@ -0,0 +1,16 @@ +pub mod consts; + +#[cfg(feature = "config")] +pub mod config; + +#[cfg(feature = "states")] +pub mod states; + +#[cfg(feature = "environment-emulation")] +pub mod env_emulation; + +#[cfg(feature = "game")] +pub mod game; + +#[cfg(feature = "sessions")] +pub mod sessions; diff --git a/src/games/zzz/sessions.rs b/src/games/zzz/sessions.rs new file mode 100644 index 0000000..3c5ddf5 --- /dev/null +++ b/src/games/zzz/sessions.rs @@ -0,0 +1,105 @@ +use std::path::{Path, PathBuf}; + +use serde::{Serialize, Deserialize}; + +use crate::sessions::{ + SessionsExt, + Sessions as SessionsDescriptor +}; + +use super::consts::launcher_dir; + +/// Get default sessions file path +/// +/// `$HOME/.local/share/sleepy-launcher/sessions.json` +#[inline] +pub fn sessions_file() -> anyhow::Result { + launcher_dir().map(|dir| dir.join("sessions.json")) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionData { + // [Software\\miHoYo\\ZenlessZoneZero] + pub game_reg: String, + + // [Software\\miHoYoSDK] + pub sdk_reg: String +} + +pub struct Sessions; + +impl SessionsExt for Sessions { + type SessionData = SessionData; + + fn get_sessions() -> anyhow::Result> { + let path = sessions_file()?; + + if !path.exists() { + tracing::warn!("Session file doesn't exist. Returning default value"); + + return Ok(SessionsDescriptor::default()); + } + + Ok(serde_json::from_slice(&std::fs::read(path)?)?) + } + + fn set_sessions(sessions: SessionsDescriptor) -> anyhow::Result<()> { + Ok(std::fs::write(sessions_file()?, serde_json::to_string_pretty(&sessions)?)?) + } + + fn update(name: String, prefix: impl AsRef) -> anyhow::Result<()> { + let mut sessions = Self::get_sessions()?; + + tracing::info!("Updating session '{name}' from prefix: {:?}", prefix.as_ref()); + + let mut new_session = Self::SessionData { + game_reg: String::new(), + sdk_reg: String::new() + }; + + for entry in std::fs::read_to_string(prefix.as_ref().join("user.reg"))?.split("\n\n") { + if entry.starts_with("[Software\\\\miHoYo\\\\ZenlessZoneZero]") { + new_session.game_reg = entry.to_owned(); + } + + else if entry.starts_with("[Software\\\\miHoYoSDK]") { + new_session.sdk_reg = entry.to_owned(); + } + } + + sessions.sessions.insert(name, new_session); + + Self::set_sessions(sessions) + } + + fn apply(name: String, prefix: impl AsRef) -> anyhow::Result<()> { + let sessions = Self::get_sessions()?; + + let Some(session) = sessions.sessions.get(&name) else { + anyhow::bail!("Session with given name doesn't exist"); + }; + + tracing::info!("Applying session '{name}' to prefix: {:?}", prefix.as_ref()); + + let entries: String = std::fs::read_to_string(prefix.as_ref().join("user.reg"))? + .split("\n\n") + .map(|entry| { + let new_entry = if entry.starts_with("[Software\\\\miHoYo\\\\ZenlessZoneZero]") { + session.game_reg.clone() + } + + else if entry.starts_with("[Software\\\\miHoYoSDK]") { + session.sdk_reg.clone() + } + + else { + entry.to_owned() + }; + + new_entry + "\n\n" + }) + .collect(); + + Ok(std::fs::write(prefix.as_ref().join("user.reg"), format!("{}\n", entries.trim_end()))?) + } +} diff --git a/src/games/zzz/states.rs b/src/games/zzz/states.rs new file mode 100644 index 0000000..8378746 --- /dev/null +++ b/src/games/zzz/states.rs @@ -0,0 +1,143 @@ +use std::path::PathBuf; + +use anime_game_core::prelude::*; +use anime_game_core::zzz::prelude::*; + +use crate::config::ConfigExt; +use crate::zzz::config::Config; + +#[derive(Debug, Clone)] +pub enum LauncherState { + Launch, + + /// Always contains `VersionDiff::Predownload` + PredownloadAvailable { + game: VersionDiff + }, + + FolderMigrationRequired { + from: PathBuf, + to: PathBuf, + cleanup_folder: Option + }, + + TelemetryNotDisabled, + + #[cfg(feature = "components")] + WineNotInstalled, + + PrefixNotExists, + + // Always contains `VersionDiff::Diff` + VoiceUpdateAvailable(VersionDiff), + + /// Always contains `VersionDiff::Outdated` + VoiceOutdated(VersionDiff), + + /// Always contains `VersionDiff::NotInstalled` + VoiceNotInstalled(VersionDiff), + + // Always contains `VersionDiff::Diff` + GameUpdateAvailable(VersionDiff), + + /// Always contains `VersionDiff::Outdated` + GameOutdated(VersionDiff), + + /// Always contains `VersionDiff::NotInstalled` + GameNotInstalled(VersionDiff) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StateUpdating { + Game +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LauncherStateParams { + pub game_path: PathBuf, + pub game_edition: GameEdition, + pub wine_prefix: PathBuf, + + pub status_updater: F +} + +impl LauncherState { + pub fn get(params: LauncherStateParams) -> anyhow::Result { + tracing::debug!("Trying to get launcher state"); + + // Check prefix existence + if !params.wine_prefix.join("drive_c").exists() { + return Ok(Self::PrefixNotExists); + } + + // Check game installation status + (params.status_updater)(StateUpdating::Game); + + let game = Game::new(¶ms.game_path, params.game_edition); + + let diff = game.try_get_diff()?; + + match diff { + VersionDiff::Latest { .. } | VersionDiff::Predownload { .. } => { + // Check telemetry servers + let disabled = telemetry::is_disabled(params.game_edition) + + // Return true if there's no domain name resolved, or false otherwise + .map(|result| result.is_none()) + + // And return true if there's an error happened during domain name resolving + // FIXME: might not be a good idea? Idk + .unwrap_or_else(|err| { + tracing::warn!("Failed to check telemetry servers: {err}. Assuming they're disabled"); + + true + }); + + if !disabled { + return Ok(Self::TelemetryNotDisabled); + } + + // Check if update predownload available + if let VersionDiff::Predownload { .. } = diff { + Ok(Self::PredownloadAvailable { + game: diff + }) + } + + // Otherwise we can launch the game + else { + Ok(Self::Launch) + } + } + + VersionDiff::Diff { .. } => Ok(Self::GameUpdateAvailable(diff)), + VersionDiff::Outdated { .. } => Ok(Self::GameOutdated(diff)), + VersionDiff::NotInstalled { .. } => Ok(Self::GameNotInstalled(diff)) + } + } + + #[cfg(feature = "config")] + #[tracing::instrument(level = "debug", skip(status_updater), ret)] + pub fn get_from_config(status_updater: T) -> anyhow::Result { + tracing::debug!("Trying to get launcher state"); + + let config = Config::get()?; + + match &config.game.wine.selected { + #[cfg(feature = "components")] + Some(selected) if !config.game.wine.builds.join(selected).exists() => return Ok(Self::WineNotInstalled), + + None => return Ok(Self::WineNotInstalled), + + _ => () + } + + Self::get(LauncherStateParams { + game_path: config.game.path.for_edition(config.launcher.edition).to_path_buf(), + game_edition: config.launcher.edition, + wine_prefix: config.get_wine_prefix_path(), + + status_updater + }) + } +} diff --git a/src/lib.rs b/src/lib.rs index c60ac16..850618d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,9 @@ pub use games::genshin; #[cfg(feature = "star-rail")] pub use games::star_rail; +#[cfg(feature = "zzz")] +pub use games::zzz; + #[cfg(feature = "honkai")] pub use games::honkai;