From 6410ca2721b55343553afbd426693137d83f28d7 Mon Sep 17 00:00:00 2001 From: akanyan Date: Mon, 3 Mar 2025 02:07:15 +0100 Subject: [PATCH] feat: groundwork for multi-profile support --- public/sticker-chunithm.svg | 298 ++++++++++++++++++++++++++++++++ rust/src/appdata.rs | 60 ++++++- rust/src/cmd.rs | 43 +++-- rust/src/lib.rs | 13 +- rust/src/liner.rs | 12 +- rust/src/model/misc.rs | 23 ++- rust/src/profile.rs | 131 +++++++++----- rust/src/start.rs | 29 ++-- src/components/App.vue | 71 +++----- src/components/ModList.vue | 14 +- src/components/ModListEntry.vue | 8 +- src/components/Options.vue | 13 +- src/components/ProfileList.vue | 73 ++++++++ src/stores.ts | 121 ++++++++++--- src/types.ts | 15 +- src/util.ts | 4 +- 16 files changed, 744 insertions(+), 184 deletions(-) create mode 100644 public/sticker-chunithm.svg create mode 100644 src/components/ProfileList.vue diff --git a/public/sticker-chunithm.svg b/public/sticker-chunithm.svg new file mode 100644 index 0000000..566b0c9 --- /dev/null +++ b/public/sticker-chunithm.svg @@ -0,0 +1,298 @@ + + + + diff --git a/rust/src/appdata.rs b/rust/src/appdata.rs index ab5471a..85cc279 100644 --- a/rust/src/appdata.rs +++ b/rust/src/appdata.rs @@ -1,16 +1,64 @@ use std::hash::{DefaultHasher, Hash, Hasher}; - -use crate::pkg::PkgKey; +use crate::{model::misc::Game, pkg::PkgKey}; use crate::pkg_store::PackageStore; -use crate::Profile; +use crate::{util, Profile}; use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +#[derive(Serialize, Deserialize, Clone, Default)] +pub struct GlobalConfig { + pub recent_profile: Option<(Game, String)> +} pub struct AppData { pub profile: Option, pub pkgs: PackageStore, + pub cfg: GlobalConfig } impl AppData { + pub fn new(app: AppHandle) -> AppData { + let path = util::get_dirs() + .config_dir() + .join("config.json"); + + let cfg = std::fs::read_to_string(&path) + .and_then(|s| Ok(serde_json::from_str::(&s)?)) + .unwrap_or_default(); + + let profile = match cfg.recent_profile { + Some((ref game, ref name)) => Profile::load(game, name).ok(), + None => None + }; + + AppData { + profile, + pkgs: PackageStore::new(app), + cfg + } + } + + pub fn write(&self) -> Result<(), std::io::Error> { + let path = util::get_dirs() + .config_dir() + .join("config.json"); + + std::fs::write(&path, serde_json::to_string(&self.cfg)?) + } + + pub fn switch_profile(&mut self, game: &Game, name: &str) -> Result<()> { + self.profile = Profile::load(game, name).ok(); + if self.profile.is_some() { + self.cfg.recent_profile = Some((game.to_owned(), name.to_owned())); + } else { + self.cfg.recent_profile = None; + } + self.write()?; + + Ok(()) + } + pub fn toggle_package(&mut self, key: PkgKey, enable: bool) -> Result<()> { log::debug!("toggle: {} {}", key, enable); @@ -22,12 +70,12 @@ impl AppData { let loc = pkg.loc .clone() .ok_or_else(|| anyhow!("Attempted to enable a non-existent package"))?; - profile.mods.insert(key); + profile.data.mods.insert(key); for d in &loc.dependencies { _ = self.toggle_package(d.clone(), true); } } else { - profile.mods.remove(&key); + profile.data.mods.remove(&key); for (ckey, pkg) in self.pkgs.get_all() { if let Some(loc) = pkg.loc { if loc.dependencies.contains(&key) { @@ -42,7 +90,7 @@ impl AppData { pub fn sum_packages(&self, p: &Profile) -> String { let mut hasher = DefaultHasher::new(); - for pkg in &p.mods { + for pkg in &p.data.mods { let x = self.pkgs.get(pkg).unwrap().loc.as_ref().unwrap(); pkg.hash(&mut hasher); x.version.hash(&mut hasher); diff --git a/rust/src/cmd.rs b/rust/src/cmd.rs index 3796d54..c757246 100644 --- a/rust/src/cmd.rs +++ b/rust/src/cmd.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use tokio::sync::Mutex; use tokio::fs; +use crate::model::misc::Game; use crate::pkg::{Package, PkgKey}; use crate::pkg_store::InstallResult; use crate::profile::Profile; @@ -106,6 +107,23 @@ pub async fn fetch_listings(state: State<'_, Mutex>) -> Result<(), Stri .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn list_profiles() -> Result, String> { + log::debug!("invoke: list_profiles"); + + let list = Profile::list().await.map_err(|e| e.to_string())?; + Ok(list) +} + +#[tauri::command] +pub async fn load_profile(state: State<'_, Mutex>, game: Game, name: String) -> Result<(), String> { + log::debug!("invoke: load_profile({} {:?})", game, name); + + let mut appd = state.lock().await; + appd.switch_profile(&game, &name).map_err(|e| e.to_string())?; + Ok(()) +} + #[tauri::command] pub async fn get_current_profile(state: State<'_, Mutex>) -> Result, ()> { log::debug!("invoke: get_current_profile"); @@ -115,8 +133,8 @@ pub async fn get_current_profile(state: State<'_, Mutex>) -> Result>) -> Result<(), ()> { - log::debug!("invoke: save_profile"); +pub async fn save_current_profile(state: State<'_, Mutex>) -> Result<(), ()> { + log::debug!("invoke: save_current_profile"); let appd = state.lock().await; if let Some(p) = &appd.profile { @@ -133,18 +151,17 @@ pub async fn init_profile( state: State<'_, Mutex>, exe_path: PathBuf ) -> Result { - log::debug!("invoke: init_profile({})", exe_path.to_string_lossy()); + log::debug!("invoke: init_profile({:?})", exe_path); let mut appd = state.lock().await; - let new_profile = Profile::new(exe_path); + if let Some(new_profile) = Profile::new(exe_path) { + new_profile.save().await; + appd.profile = Some(new_profile.clone()); - new_profile.save().await; - appd.profile = Some(new_profile.clone()); - - fs::create_dir(new_profile.dir()).await - .map_err(|e| format!("Unable to create profile directory: {}", e))?; - - Ok(new_profile) + Ok(new_profile) + } else { + Err("Unrecognized game".to_owned()) + } } // #[tauri::command] @@ -184,7 +201,7 @@ pub async fn write_profile_data( content: String ) -> Result<(), String> { let appd = state.lock().await; - + if let Some(p) = &appd.profile { fs::write(p.dir().join(&path), content).await .map_err(|e| format!("Unable to write to {:?}: {}", path, e))?; @@ -204,7 +221,7 @@ pub async fn set_cfg( let mut appd = state.lock().await; if let Some(p) = &mut appd.profile { - p.cfg.insert(key, value); + p.data.cfg.insert(key, value); } Ok(()) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 61074cc..7f19d50 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -12,7 +12,6 @@ mod appdata; use closure::closure; use appdata::AppData; use pkg::PkgKey; -use pkg_store::PackageStore; use profile::Profile; use tauri::{Listener, Manager}; use tauri_plugin_deep_link::DeepLinkExt; @@ -47,6 +46,7 @@ pub async fn run(_args: Vec) { // Todo deindent this chimera let url = &args[1]; if &url[..13] == "rainycolor://" { + log::info!("Deep link: {}", url); let regex = regex::Regex::new( r"rainycolor://v1/install/rainy\.patafour\.zip/([^/]+)/([^/]+)/[0-9]+\.[0-9]+\.[0-9]+/" ).expect("Invalid regex"); @@ -72,10 +72,7 @@ pub async fn run(_args: Vec) { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .setup(|app| { - let app_data = AppData { - profile: Profile::load(), - pkgs: PackageStore::new(app.handle().clone()) - }; + let app_data = AppData::new(app.handle().clone()); app.manage(Mutex::new(app_data)); app.deep_link().register_all()?; @@ -103,9 +100,11 @@ pub async fn run(_args: Vec) { cmd::install_package, cmd::delete_package, cmd::toggle_package, - cmd::get_current_profile, + cmd::list_profiles, cmd::init_profile, - cmd::save_profile, + cmd::load_profile, + cmd::get_current_profile, + cmd::save_current_profile, cmd::read_profile_data, cmd::write_profile_data, cmd::startline, diff --git a/rust/src/liner.rs b/rust/src/liner.rs index 1258c61..dd1d19e 100644 --- a/rust/src/liner.rs +++ b/rust/src/liner.rs @@ -46,7 +46,7 @@ async fn prepare_packages(p: &Profile) -> Result<()> { fs::remove_dir_all(dir_out.join("BepInEx")).await?; } - for m in &p.mods { + for m in &p.data.mods { log::debug!("Preparing {}", m); let (namespace, name) = m.0.split_at(m.0.find("-").expect("Invalid mod definition")); let bpx_dir = util::pkg_dir_of(namespace, &name[1..]) // cut the hyphen @@ -65,13 +65,15 @@ async fn prepare_packages(p: &Profile) -> Result<()> { } } + log::debug!("prepare packages: done"); + 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_raw = fs::read_to_string(p.data.exe_dir.join("segatools.ini")).await?; let ini_in = Ini::load_from_str(&ini_in_raw)?; let mut opt_dir_in = PathBuf::from( ini_in.section(Some("vfs")) @@ -80,7 +82,7 @@ pub async fn prepare_config(p: &Profile) -> Result<()> { .ok_or_else(|| anyhow!("No option specified in segatools.ini"))? ); if opt_dir_in.is_relative() { - opt_dir_in = p.exe_dir.join(opt_dir_in); + opt_dir_in = p.data.exe_dir.join(opt_dir_in); } let opt_dir_out = &dir_out.join("option"); @@ -104,11 +106,13 @@ pub async fn prepare_config(p: &Profile) -> Result<()> { ini_out.write_to_file(dir_out.join("segatools.ini"))?; - log::debug!("Option dir: {} -> {}", opt_dir_in.to_string_lossy(), opt_dir_out.to_string_lossy()); + log::debug!("Option dir: {:?} -> {:?}", opt_dir_in, opt_dir_out); for opt in opt_dir_in.read_dir()? { let opt = opt?; symlink(&opt.path(), opt_dir_out.join(opt.file_name())).await?; } + log::debug!("prepare config: done"); + Ok(()) } \ No newline at end of file diff --git a/rust/src/model/misc.rs b/rust/src/model/misc.rs index cf772ef..772a2d0 100644 --- a/rust/src/model/misc.rs +++ b/rust/src/model/misc.rs @@ -2,6 +2,27 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Serialize, Deserialize)] pub enum Game { + #[serde(rename = "ongeki")] Ongeki, + #[serde(rename = "chunithm")] Chunithm, -} \ No newline at end of file +} + +impl Game { + pub fn from_str(s: &str) -> Option { + match s { + "ongeki" => Some(Game::Ongeki), + "chunithm" => Some(Game::Chunithm), + _ => None + } + } +} + +impl std::fmt::Display for Game { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Game::Ongeki => write!(f, "ongeki"), + Game::Chunithm => write!(f, "chunithm") + } + } +} diff --git a/rust/src/profile.rs b/rust/src/profile.rs index 1ed7abf..ccf8f32 100644 --- a/rust/src/profile.rs +++ b/rust/src/profile.rs @@ -1,17 +1,20 @@ -use anyhow::Result; -use std::{collections::{BTreeSet, HashMap}, path::PathBuf}; -use crate::{model::misc, pkg::PkgKey, util}; +use anyhow::{Result, anyhow}; +use std::{collections::{BTreeSet, HashMap}, path::{Path, PathBuf}}; +use crate::{model::misc::{self, Game}, pkg::PkgKey, util}; use serde::{Deserialize, Serialize}; use tokio::fs; -// {game}-profile-{name}.json - #[derive(Deserialize, Serialize, Clone)] -#[allow(dead_code)] pub struct Profile { pub game: misc::Game, - pub exe_dir: PathBuf, pub name: String, + pub data: ProfileData +} + +// The contents of profile-{game}-{name}.json +#[derive(Deserialize, Serialize, Clone)] +pub struct ProfileData { + pub exe_dir: PathBuf, pub mods: BTreeSet, pub wine_runtime: Option, pub wine_prefix: Option, @@ -21,80 +24,126 @@ pub struct Profile { } impl Profile { - pub fn new(exe_path: PathBuf) -> Profile { - Profile { - game: misc::Game::Ongeki, - exe_dir: exe_path.parent().unwrap().to_owned(), - name: "ongeki-default".to_owned(), - mods: BTreeSet::new(), - - #[cfg(target_os = "linux")] - wine_runtime: Some(std::path::Path::new("/usr/bin/wine").to_path_buf()), - #[cfg(target_os = "windows")] - wine_runtime: None, - - #[cfg(target_os = "linux")] - wine_prefix: Some( - directories::UserDirs::new() - .expect("No home directory") - .home_dir() - .join(".wine"), - ), - #[cfg(target_os = "windows")] - wine_prefix: None, - cfg: HashMap::new() + pub fn new(exe_path: PathBuf) -> Option { + let game; + if exe_path.ends_with("mu3.exe") { + game = misc::Game::Ongeki + } else if exe_path.ends_with("chusanApp.exe") { + // game = misc::Game::Chunithm; + return None; + } else { + return None; } + + Some(Profile { + name: format!("{}", "default"), + game, + data: ProfileData { + exe_dir: exe_path.parent().unwrap().to_owned(), + mods: BTreeSet::new(), + wine_runtime: None, + wine_prefix: None, + cfg: HashMap::new() + } + }) } pub fn dir(&self) -> PathBuf { util::get_dirs() .data_dir() - .join("profile-".to_owned() + &self.name) + .join(format!("profile-{}-{}", self.game, self.name)) .to_owned() } - pub fn load() -> Option { + pub async fn list() -> Result> { + let path = std::fs::read_dir( + util::get_dirs().config_dir() + )?; + + let mut res = Vec::new(); + + for f in path { + let f = f?; + + if let Some(pair) = Self::name_from_path(f.path()) { + res.push(pair); + } + } + + Ok(res) + } + + pub fn load(game: &Game, name: &str) -> Result { let path = util::get_dirs() .config_dir() - .join("profile-ongeki-default.json"); - if let Ok(s) = std::fs::read_to_string(path) { - Some(serde_json::from_str(&s).expect("Invalid profile json")) + .join(format!("profile-{}-{}.json", game, name)); + if let Ok(s) = std::fs::read_to_string(&path) { + let (game, name) = Self::name_from_path(&path) + .ok_or_else(|| anyhow!("Invalid filename: {:?}", path.file_name()))?; + + let data = serde_json::from_str::(&s) + .map_err(|e| anyhow!("Unable to parse {:?}: {:?}", path, e))?; + + Ok(Profile { + game, + name, + data + }) } else { - None + Err(anyhow!("Unable to open {:?}", path)) } } pub async fn save(&self) { let path = util::get_dirs() .config_dir() - .join("profile-ongeki-default.json"); - let s = serde_json::to_string_pretty(self).unwrap(); + .join(format!("profile-{}-{}.json", self.game, self.name)); + + let s = serde_json::to_string_pretty(&self.data).unwrap(); fs::write(&path, s).await.unwrap(); log::info!("Written to {}", path.to_string_lossy()); } #[allow(dead_code)] pub fn get_cfg(&self, key: &str) -> Result<&serde_json::Value> { - self.cfg.get(key) + self.data.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) + self.data.cfg.get(key) .and_then(|c| c.as_bool()) .unwrap_or(default) } pub fn get_int(&self, key: &str, default: i64) -> i64 { - self.cfg.get(key) + self.data.cfg.get(key) .and_then(|c| c.as_i64()) .unwrap_or(default) } pub fn get_str(&self, key: &str, default: &str) -> String { - self.cfg.get(key) + self.data.cfg.get(key) .and_then(|c| c.as_str()) .unwrap_or(default) .to_owned() } + + fn name_from_path(path: impl AsRef) -> Option<(Game, String)> { + let regex = regex::Regex::new( + r"profile-([^\-]+)-([^\-]+)\.json" + ).expect("Invalid regex"); + + let fname = path.as_ref().file_name().unwrap_or_default().to_string_lossy(); + + if let Some(caps) = regex.captures(&fname) { + let game = caps.get(1).unwrap().as_str(); + let name = caps.get(2).unwrap().as_str().to_owned(); + if let Some(game) = Game::from_str(game) { + return Some((game, name)); + } + } + + None + } } diff --git a/rust/src/start.rs b/rust/src/start.rs index b5afb27..7160cde 100644 --- a/rust/src/start.rs +++ b/rust/src/start.rs @@ -1,5 +1,6 @@ use anyhow::Result; use std::fs::File; +use std::path::PathBuf; use tokio::process::Command; use tauri::{AppHandle, Emitter}; use std::process::Stdio; @@ -26,13 +27,13 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> { } #[cfg(target_os = "linux")] { - let wine = p.wine_runtime.as_ref() - .expect("No wine path specified"); + let wine = p.data.wine_runtime.clone() + .unwrap_or_else(|| PathBuf::from("/usr/bin/wine")); - game_builder = Command::new(wine); - amd_builder = Command::new(wine); + game_builder = Command::new(&wine); + amd_builder = Command::new(&wine); - game_builder.arg(p.exe_dir.join("inject.exe")); + game_builder.arg(p.data.exe_dir.join("inject.exe")); amd_builder.arg("cmd.exe"); } @@ -42,10 +43,10 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> { "SEGATOOLS_CONFIG_PATH", &ini_path, ) - .current_dir(&p.exe_dir) + .current_dir(&p.data.exe_dir) .args([ "/C", - &util::path_to_str(p.exe_dir.join("inject.exe"))?, "-d", "-k", "mu3hook.dll", + &util::path_to_str(p.data.exe_dir.join("inject.exe"))?, "-d", "-k", "mu3hook.dll", "amdaemon.exe", "-f", "-c", "config_common.json", "config_server.json", "config_client.json" ]); game_builder @@ -53,7 +54,7 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> { "SEGATOOLS_CONFIG_PATH", ini_path, ) - .current_dir(&p.exe_dir) + .current_dir(&p.data.exe_dir) .args([ "-d", "-k", "mu3hook.dll", "mu3.exe", "-monitor 1", @@ -68,10 +69,14 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> { #[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); + let wineprefix = p.data.wine_prefix.clone().unwrap_or_else(|| + directories::UserDirs::new() + .expect("No home directory") + .home_dir() + .join(".wine") + ); + amd_builder.env("WINEPREFIX", &wineprefix); + game_builder.env("WINEPREFIX", &wineprefix); } diff --git a/src/components/App.vue b/src/components/App.vue index f1e0143..10fe93c 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -7,53 +7,31 @@ import TabPanel from 'primevue/tabpanel'; import TabPanels from 'primevue/tabpanels'; import Tabs from 'primevue/tabs'; 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 ProfileList from './ProfileList.vue'; import StartButton from './StartButton.vue'; -import { usePkgStore } from '../stores'; -import { changePrimaryColor } from '../util'; +import { usePkgStore, usePrfStore } from '../stores'; -const store = usePkgStore(); -store.setupListeners(); +const pkg = usePkgStore(); +const prf = usePrfStore(); + +pkg.setupListeners(); +prf.setupListeners(); const currentTab = ref('3'); -const loadProfile = async (openWindow: boolean) => { - await store.reloadProfile(); - - if (store.profile === null && openWindow) { - const exePath = await open({ - multiple: false, - directory: false, - filters: [ - { - name: 'mu3.exe' /* or chusanApp.exe'*/, - extensions: ['exe'], - }, - ], - }); - if (exePath !== null) { - await store.initProfile(exePath); - } - } - if (store.profile !== null) { - changePrimaryColor(store.profile.game); - currentTab.value = '0'; - } - - await store.reloadAll(); -}; - -const isProfileDisabled = computed(() => store.profile === null); - -onOpenUrl((urls) => { - console.log('deep link:', urls); -}); +const isProfileDisabled = computed(() => prf.current === null); onMounted(async () => { - await loadProfile(false); + await prf.reloadList(); + await prf.reload(); + + if (prf.current !== null) { + await pkg.reloadAll(); + currentTab.value = '0'; + } }); @@ -93,14 +71,10 @@ onMounted(async () => { missing.
Existing features are expected to break any time.
-
Select mu3.exe to create a - profile:
-
+ +
+