diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..d74745f --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +rust/**/* \ No newline at end of file diff --git a/README.md b/README.md index 9750166..ba0935c 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ Arbitrary scripts are not supported by design and that will probably never chang - CHUNITHM support - segatools as a special package - Progress bars and other GUI sugar -- Rebuilding the profile only when necessary +- Search bar +- Start check ### Endgame diff --git a/bun.lockb b/bun.lockb index 5341a6b..fedd93f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 080e986..eee4311 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", + "@tauri-apps/plugin-fs": "~2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-shell": "~2", "@trivago/prettier-plugin-sort-imports": "^5.2.2", diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 59a451a..34ac599 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -4341,6 +4341,7 @@ dependencies = [ "tauri-build", "tauri-plugin-deep-link", "tauri-plugin-dialog", + "tauri-plugin-fs", "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-single-instance", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index eefb433..56ea852 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -39,6 +39,7 @@ async-std = "1.13.0" closure = "0.3.0" derive_more = { version = "2.0.1", features = ["display"] } junction = "1.2.0" +tauri-plugin-fs = "2" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } diff --git a/rust/capabilities/default.json b/rust/capabilities/default.json index 845bdc1..e4502f7 100644 --- a/rust/capabilities/default.json +++ b/rust/capabilities/default.json @@ -1,20 +1,18 @@ { - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "Capability for the main window", - "windows": [ - "main" - ], - "permissions": [ - "core:default", - "opener:default", - "core:window:allow-close", - "core:app:allow-app-hide", - "shell:default", - "shell:default", - "opener:default", - "dialog:default", - "dialog:default", - "deep-link:default" - ] -} \ No newline at end of file + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default", + "core:window:allow-close", + "core:app:allow-app-hide", + "shell:default", + "dialog:default", + "deep-link:default", + "fs:default", + "fs:allow-data-read-recursive", + "fs:allow-data-write-recursive" + ] +} diff --git a/rust/src/appdata.rs b/rust/src/appdata.rs index 616dfea..ab5471a 100644 --- a/rust/src/appdata.rs +++ b/rust/src/appdata.rs @@ -1,11 +1,13 @@ -use anyhow::{anyhow, Result}; +use std::hash::{DefaultHasher, Hash, Hasher}; + use crate::pkg::PkgKey; -use crate::Profile; use crate::pkg_store::PackageStore; +use crate::Profile; +use anyhow::{anyhow, Result}; pub struct AppData { pub profile: Option, - pub pkgs: PackageStore + pub pkgs: PackageStore, } impl AppData { @@ -37,4 +39,14 @@ impl AppData { Ok(()) } -} \ No newline at end of file + + pub fn sum_packages(&self, p: &Profile) -> String { + let mut hasher = DefaultHasher::new(); + for pkg in &p.mods { + let x = self.pkgs.get(pkg).unwrap().loc.as_ref().unwrap(); + pkg.hash(&mut hasher); + x.version.hash(&mut hasher); + } + hasher.finish().to_string() + } +} diff --git a/rust/src/cmd.rs b/rust/src/cmd.rs index a24abcf..9816309 100644 --- a/rust/src/cmd.rs +++ b/rust/src/cmd.rs @@ -20,16 +20,24 @@ pub async fn startline(app: AppHandle) -> Result<(), String> { let appd = state.lock().await; if let Some(p) = &appd.profile { - // TODO if p.needsUpdate - liner::line_up(p).await.expect("Line-up failed"); + let hash = appd.sum_packages(p); + liner::line_up(p, hash).await + .map_err(|e| e.to_string())?; start::start(p, app_copy) - .map_err(|e| { log::error!("Error launching: {}", e.to_string()); e.to_string() }) - //Ok(()) + .map_err(|e| e.to_string()) } else { Err("No profile".to_owned()) } } +#[tauri::command] +pub async fn kill() -> Result<(), String> { + start::pkill("amdaemon.exe").await; + // The start routine will kill the other process + + Ok(()) +} + #[tauri::command] pub async fn install_package(state: State<'_, tokio::sync::Mutex>, key: PkgKey) -> Result { log::debug!("invoke: install_package({})", key); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 783b3a5..9965a77 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -107,7 +107,8 @@ pub async fn run(_args: Vec) { cmd::init_profile, cmd::save_profile, cmd::startline, - cmd::set_cfg + cmd::kill, + cmd::set_cfg, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/rust/src/liner.rs b/rust/src/liner.rs index e23d5fd..991add5 100644 --- a/rust/src/liner.rs +++ b/rust/src/liner.rs @@ -17,9 +17,23 @@ async fn symlink(src: impl AsRef, dst: impl AsRef) -> std::io::Resul junction::create(src, dst) } -pub async fn line_up(p: &Profile) -> Result<()> { - let dir_out = util::profile_dir(&p); - log::info!("Preparing {}", dir_out.to_string_lossy()); +pub async fn line_up(p: &Profile, pkg_hash: String) -> Result<()> { + let hash_path = p.dir().join(".sl-state"); + let prev_hash = fs::read_to_string(&hash_path).await.unwrap_or_default(); + if prev_hash != pkg_hash { + log::debug!("state {} -> {}", prev_hash, pkg_hash); + fs::write(hash_path, pkg_hash).await + .map_err(|e| anyhow!("Unable to write the state file: {}", e))?; + prepare_packages(p).await?; + } + + prepare_config(p).await?; + + Ok(()) +} + +async fn prepare_packages(p: &Profile) -> Result<()> { + let dir_out = p.dir(); let mut futures = JoinSet::new(); if dir_out.join("BepInEx").exists() { @@ -34,25 +48,29 @@ pub async fn line_up(p: &Profile) -> Result<()> { for m in &p.mods { log::debug!("Preparing {}", m); - let dir_out = util::profile_dir(&p); let (namespace, name) = m.0.split_at(m.0.find("-").expect("Invalid mod definition")); - let bpx = util::pkg_dir_of(namespace, &name[1..]) + let bpx_dir = util::pkg_dir_of(namespace, &name[1..]) .join("app") .join("BepInEx"); - if bpx.exists() { - util::copy_recursive(&bpx, &dir_out.join("BepInEx"))?; + if bpx_dir.exists() { + util::copy_recursive(&bpx_dir, &dir_out.join("BepInEx"))?; } - let opt = util::pkg_dir_of(namespace, &name[1..]).join("option"); - if opt.exists() { - let x = opt.read_dir().unwrap().next().unwrap()?; + let opt_dir = util::pkg_dir_of(namespace, &name[1..]).join("option"); + if opt_dir.exists() { + let x = opt_dir.read_dir().unwrap().next().unwrap()?; if x.metadata()?.is_dir() { symlink(&x.path(), &dir_out.join("option").join(x.file_name())).await?; } } } - // Todo temporary + Ok(()) +} + +pub async fn prepare_config(p: &Profile) -> Result<()> { + let dir_out = p.dir(); + let ini_in_raw = fs::read_to_string(p.exe_dir.join("segatools.ini")).await?; let ini_in = Ini::load_from_str(&ini_in_raw)?; let mut opt_dir_in = PathBuf::from( @@ -78,7 +96,7 @@ pub async fn line_up(p: &Profile) -> Result<()> { util::path_to_str(dir_out.join("BepInEx").join("core").join("BepInEx.Preloader.dll"))? ); - if prepare_aime(p).await.unwrap_or(false) { + if p.get_bool("aime", false) { ini_out.with_section(Some("aime")) .set("enable", "1") .set("aimePath", util::path_to_str(dir_out.join("aime.txt"))?); @@ -93,16 +111,4 @@ pub async fn line_up(p: &Profile) -> Result<()> { } Ok(()) -} - -// Todo multiple codes -async fn prepare_aime(p: &Profile) -> Result { - if p.get_bool("aime", true) { - if let Some(code) = p.cfg.get("aime-code") { - let code = code.as_str().expect("Invalid config"); - fs::write(util::profile_dir(&p).join("aime.txt"), code).await?; - return Ok(true); - } - } - Ok(false) } \ No newline at end of file diff --git a/rust/src/pkg.rs b/rust/src/pkg.rs index 7d72cde..0c78af5 100644 --- a/rust/src/pkg.rs +++ b/rust/src/pkg.rs @@ -6,7 +6,7 @@ use tokio::fs; use crate::{model::{local, rainy}, util}; // {namespace}-{name} -#[derive(Eq, Hash, PartialEq, Clone, Serialize, Deserialize, Display)] +#[derive(Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Serialize, Deserialize, Display)] pub struct PkgKey(pub String); // {namespace}-{name}-{version} diff --git a/rust/src/profile.rs b/rust/src/profile.rs index a55d3c2..8318220 100644 --- a/rust/src/profile.rs +++ b/rust/src/profile.rs @@ -1,4 +1,5 @@ -use std::{collections::{HashMap, HashSet}, path::PathBuf}; +use anyhow::Result; +use std::{collections::{BTreeSet, HashMap}, path::PathBuf}; use crate::{model::misc, pkg::PkgKey, util}; use serde::{Deserialize, Serialize}; use tokio::fs; @@ -11,7 +12,7 @@ pub struct Profile { pub game: misc::Game, pub exe_dir: PathBuf, pub name: String, - pub mods: HashSet, + pub mods: BTreeSet, pub wine_runtime: Option, pub wine_prefix: Option, // cfg is temporarily just a map to make iteration easier @@ -25,7 +26,7 @@ impl Profile { game: misc::Game::Ongeki, exe_dir: exe_path.parent().unwrap().to_owned(), name: "ongeki-default".to_owned(), - mods: HashSet::new(), + mods: BTreeSet::new(), #[cfg(target_os = "linux")] wine_runtime: Some(std::path::Path::new("/usr/bin/wine").to_path_buf()), @@ -45,6 +46,13 @@ impl Profile { } } + pub fn dir(&self) -> PathBuf { + util::get_dirs() + .data_dir() + .join("profile-".to_owned() + &self.name) + .to_owned() + } + pub fn load() -> Option { let path = util::get_dirs() .config_dir() @@ -65,6 +73,11 @@ impl Profile { log::info!("Written to {}", path.to_string_lossy()); } + pub fn get_cfg(&self, key: &str) -> Result<&serde_json::Value> { + self.cfg.get(key) + .ok_or_else(|| anyhow::anyhow!("Invalid config entry {}", key)) + } + pub fn get_bool(&self, key: &str, default: bool) -> bool { self.cfg.get(key) .and_then(|c| c.as_bool()) diff --git a/rust/src/start.rs b/rust/src/start.rs index f9f1665..65be205 100644 --- a/rust/src/start.rs +++ b/rust/src/start.rs @@ -1,50 +1,40 @@ use anyhow::Result; +use std::fs::File; use tokio::process::Command; use tauri::{AppHandle, Emitter}; +use std::process::Stdio; use crate::profile::Profile; use crate::util; -#[cfg(target_os = "linux")] -pub fn start(p: &Profile, app: AppHandle) -> Result<()> { - let p = p.clone(); - tauri::async_runtime::spawn(async move { - let rv = Command::new(p.wine_runtime.as_ref().unwrap()) - .env( - "SEGATOOLS_CONFIG_PATH", - util::profile_dir(&p).join("segatools.ini"), - ) - .env("WINEPREFIX", p.wine_prefix.as_ref().unwrap()) - .arg(p.exe_dir.join("start.bat")) - .spawn(); - match rv { - Ok(mut child) => { - _ = child.wait().await; - log::debug!("Fin"); - }, - Err(e) => { - log::error!("Fail: {}", e); - } - } - _ = app.emit("launch-end", ""); - }); - - Ok(()) -} - #[cfg(target_os = "windows")] +static CREATE_NO_WINDOW: i32 = 0x08000000; + pub fn start(p: &Profile, app: AppHandle) -> Result<()> { - use std::process::Stdio; use tokio::task::JoinSet; - let create_no_window = 0x08000000; - - let ini_path = util::profile_dir(&p).join("segatools.ini"); + let ini_path = p.dir().join("segatools.ini"); log::debug!("With path {}", ini_path.to_string_lossy()); - log::info!("Launching amdaemon"); - let mut amd_builder = Command::new("cmd.exe"); - let mut game_builder = Command::new(p.exe_dir.join("inject.exe")); + let mut game_builder; + let mut amd_builder; + + #[cfg(target_os = "windows")] + { + game_builder = Command::new(p.exe_dir.join("inject.exe")); + amd_builder = Command::new("cmd.exe"); + } + #[cfg(target_os = "linux")] + { + let wine = p.wine_runtime.as_ref() + .expect("No wine path specified"); + + game_builder = Command::new(wine); + amd_builder = Command::new(wine); + + game_builder.arg(p.exe_dir.join("inject.exe")); + amd_builder.arg("cmd.exe"); + } let display_mode = p.get_str("display-mode", "borderless"); @@ -76,24 +66,38 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> { game_builder.arg("-popupwindow"); } - if !cfg!(debug_assertions) { - amd_builder - .creation_flags(create_no_window) - // Obviously, this is a meme - // Output will be handled properly at a later time - .stdout(Stdio::null()) - .stderr(Stdio::null()); + #[cfg(target_os = "linux")] + { + let wineprefix = p.wine_prefix.as_ref() + .expect("No wineprefix specified"); + amd_builder.env("WINEPREFIX", wineprefix); + game_builder.env("WINEPREFIX", wineprefix); + } - game_builder - .creation_flags(create_no_window) - .stdout(Stdio::null()) - .stderr(Stdio::null()); + + let amd_log = File::create(p.dir().join("amdaemon.log"))?; + let game_log = File::create(p.dir().join("mu3.log"))?; + + amd_builder + .stdout(Stdio::from(amd_log)); + // do they use stderr? + + game_builder + .stdout(Stdio::from(game_log)); + + #[cfg(target_os = "windows")] + { + amd_builder.creation_flags(CREATE_NO_WINDOW); + game_builder.creation_flags(CREATE_NO_WINDOW); } if p.get_bool("intel", false) == true { amd_builder.env("OPENSSL_ia32cap", ":~0x20000000"); } + log::info!("Launching amdaemon: {:?}", amd_builder); + log::info!("Launching mu3: {:?}", game_builder); + let mut amd = amd_builder.spawn()?; let mut game = game_builder.spawn()?; @@ -101,19 +105,24 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> { let mut set = JoinSet::new(); set.spawn(async move { - amd.wait().await.expect("amdaemon failed to run") + (amd.wait().await.expect("amdaemon failed to run"), "amdaemon.exe") }); set.spawn(async move { - game.wait().await.expect("mu3 failed to run") + (game.wait().await.expect("mu3 failed to run"), "mu3.exe") }); - let res = set.join_next().await.expect("No spawn").expect("No result"); + _ = app.emit("launch-start", ""); - log::info!("One of the processes died with return code {}", res); + let (rc, process_name) = set.join_next().await.expect("No spawn").expect("No result"); - _ = Command::new("taskkill.exe").arg("/f").arg("/im").arg("amdaemon.exe").creation_flags(create_no_window).output().await; - _ = Command::new("taskkill.exe").arg("/f").arg("/im").arg("mu3.exe").creation_flags(create_no_window).output().await; + log::info!("{} died with return code {}", process_name, rc); + + if process_name == "amdaemon.exe" { + pkill("mu3.exe").await; + } else { + pkill("amdaemon.exe").await; + } set.join_next().await.expect("No spawn").expect("No result"); @@ -123,4 +132,16 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> { }); Ok(()) +} + +#[cfg(target_os = "windows")] +pub async fn pkill(process_name: &str) { + _ = Command::new("taskkill.exe").arg("/f").arg("/im").arg(process_name) + .creation_flags(CREATE_NO_WINDOW).output().await; +} + +#[cfg(target_os = "linux")] +pub async fn pkill(process_name: &str) { + _ = Command::new("pkill").arg(process_name) + .output().await; } \ No newline at end of file diff --git a/rust/src/util.rs b/rust/src/util.rs index 9a5b341..1dae268 100644 --- a/rust/src/util.rs +++ b/rust/src/util.rs @@ -2,8 +2,6 @@ use anyhow::{anyhow, Result}; use directories::ProjectDirs; use std::path::{Path, PathBuf}; -use crate::profile::Profile; - pub fn get_dirs() -> ProjectDirs { ProjectDirs::from("org", "7EVENDAYSHOLIDAYS", "STARTLINER") .expect("Unable to set up config directories") @@ -21,13 +19,6 @@ pub fn pkg_dir_of(namespace: &str, name: &str) -> PathBuf { pkg_dir().join(format!("{}-{}", namespace, name)).to_owned() } -pub fn profile_dir(p: &Profile) -> PathBuf { - get_dirs() - .data_dir() - .join("profile-".to_owned() + &p.name) - .to_owned() -} - pub fn cache_dir() -> PathBuf { get_dirs().cache_dir().to_owned() } diff --git a/src/components/App.vue b/src/components/App.vue index 13486b7..f1e0143 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -6,13 +6,12 @@ import TabList from 'primevue/tablist'; import TabPanel from 'primevue/tabpanel'; import TabPanels from 'primevue/tabpanels'; import Tabs from 'primevue/tabs'; -import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; import { onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { open } from '@tauri-apps/plugin-dialog'; import ModList from './ModList.vue'; import ModStore from './ModStore.vue'; import Options from './Options.vue'; +import StartButton from './StartButton.vue'; import { usePkgStore } from '../stores'; import { changePrimaryColor } from '../util'; @@ -20,7 +19,6 @@ const store = usePkgStore(); store.setupListeners(); const currentTab = ref('3'); -const startEnabled = ref(false); const loadProfile = async (openWindow: boolean) => { await store.reloadProfile(); @@ -42,7 +40,6 @@ const loadProfile = async (openWindow: boolean) => { } if (store.profile !== null) { changePrimaryColor(store.profile.game); - startEnabled.value = true; currentTab.value = '0'; } @@ -51,11 +48,6 @@ const loadProfile = async (openWindow: boolean) => { const isProfileDisabled = computed(() => store.profile === null); -const startline = async () => { - startEnabled.value = false; - await invoke('startline'); -}; - onOpenUrl((urls) => { console.log('deep link:', urls); }); @@ -63,10 +55,6 @@ onOpenUrl((urls) => { onMounted(async () => { await loadProfile(false); }); - -listen('launch-end', () => { - startEnabled.value = true; -});