feat: groundwork for multi-profile support

This commit is contained in:
2025-03-03 02:07:15 +01:00
parent d25841853c
commit 6410ca2721
16 changed files with 744 additions and 184 deletions

View File

@ -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<Profile>,
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::<GlobalConfig>(&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);

View File

@ -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<AppData>>) -> Result<(), Stri
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn list_profiles() -> Result<Vec<(Game, String)>, 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<AppData>>, 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<AppData>>) -> Result<Option<Profile>, ()> {
log::debug!("invoke: get_current_profile");
@ -115,8 +133,8 @@ pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Opt
}
#[tauri::command]
pub async fn save_profile(state: State<'_, Mutex<AppData>>) -> Result<(), ()> {
log::debug!("invoke: save_profile");
pub async fn save_current_profile(state: State<'_, Mutex<AppData>>) -> 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<AppData>>,
exe_path: PathBuf
) -> Result<Profile, String> {
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(())

View File

@ -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<String>) {
// 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<String>) {
.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<String>) {
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,

View File

@ -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(())
}

View File

@ -2,6 +2,27 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub enum Game {
#[serde(rename = "ongeki")]
Ongeki,
#[serde(rename = "chunithm")]
Chunithm,
}
}
impl Game {
pub fn from_str(s: &str) -> Option<Game> {
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")
}
}
}

View File

@ -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<PkgKey>,
pub wine_runtime: Option<PathBuf>,
pub wine_prefix: Option<PathBuf>,
@ -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<Profile> {
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<Profile> {
pub async fn list() -> Result<Vec<(Game, String)>> {
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<Profile> {
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::<ProfileData>(&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<Path>) -> 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
}
}

View File

@ -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);
}