feat: new config format

This commit is contained in:
2025-03-13 23:26:00 +00:00
parent 48dc9ec4df
commit fd27000c05
30 changed files with 1447 additions and 833 deletions

36
rust/Cargo.lock generated
View File

@ -147,6 +147,12 @@ dependencies = [
"derive_arbitrary", "derive_arbitrary",
] ]
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]] [[package]]
name = "ashpd" name = "ashpd"
version = "0.10.2" version = "0.10.2"
@ -1480,6 +1486,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.3.2" version = "0.3.2"
@ -1999,6 +2011,18 @@ name = "hashbrown"
version = "0.15.2" version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.2",
]
[[package]] [[package]]
name = "heck" name = "heck"
@ -4566,6 +4590,7 @@ dependencies = [
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tokio", "tokio",
"winsafe 0.0.23", "winsafe 0.0.23",
"yaml-rust2",
"zip", "zip",
] ]
@ -6606,6 +6631,17 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "yaml-rust2"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "232bdb534d65520716bef0bbb205ff8f2db72d807b19c0bc3020853b92a0cd4b"
dependencies = [
"arraydeque",
"encoding_rs",
"hashlink",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.7.5" version = "0.7.5"

View File

@ -40,6 +40,7 @@ closure = "0.3.0"
derive_more = { version = "2.0.1", features = ["display"] } derive_more = { version = "2.0.1", features = ["display"] }
junction = "1.2.0" junction = "1.2.0"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
yaml-rust2 = "0.10.0"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-cli = "2" tauri-plugin-cli = "2"

View File

@ -1,7 +1,8 @@
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use crate::profiles::AnyProfile;
use crate::{model::misc::Game, pkg::PkgKey}; use crate::{model::misc::Game, pkg::PkgKey};
use crate::pkg_store::PackageStore; use crate::pkg_store::PackageStore;
use crate::{util, Profile}; use crate::util;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::AppHandle; use tauri::AppHandle;
@ -11,11 +12,15 @@ pub struct GlobalConfig {
pub recent_profile: Option<(Game, String)> pub recent_profile: Option<(Game, String)>
} }
pub struct GlobalState {
pub remain_open: bool
}
pub struct AppData { pub struct AppData {
pub profile: Option<Profile>, pub profile: Option<AnyProfile>,
pub pkgs: PackageStore, pub pkgs: PackageStore,
pub cfg: GlobalConfig, pub cfg: GlobalConfig,
pub remain_open: bool, pub state: GlobalState,
} }
impl AppData { impl AppData {
@ -25,15 +30,15 @@ impl AppData {
.unwrap_or_default(); .unwrap_or_default();
let profile = match cfg.recent_profile { let profile = match cfg.recent_profile {
Some((ref game, ref name)) => Profile::load(game.clone(), name.clone()).ok(), Some((ref game, ref name)) => AnyProfile::load(game.clone(), name.clone()).ok(),
None => None None => None
}; };
AppData { AppData {
profile, profile: profile,
pkgs: PackageStore::new(apph.clone()), pkgs: PackageStore::new(apph.clone()),
cfg, cfg,
remain_open: true state: GlobalState { remain_open: true }
} }
} }
@ -42,7 +47,7 @@ impl AppData {
} }
pub fn switch_profile(&mut self, game: Game, name: String) -> Result<()> { pub fn switch_profile(&mut self, game: Game, name: String) -> Result<()> {
match Profile::load(game.clone(), name.clone()) { match AnyProfile::load(game.clone(), name.clone()) {
Ok(profile) => { Ok(profile) => {
self.profile = Some(profile); self.profile = Some(profile);
self.cfg.recent_profile = Some((game, name)); self.cfg.recent_profile = Some((game, name));
@ -67,12 +72,12 @@ impl AppData {
let loc = pkg.loc let loc = pkg.loc
.clone() .clone()
.ok_or_else(|| anyhow!("Attempted to enable a non-existent package"))?; .ok_or_else(|| anyhow!("Attempted to enable a non-existent package"))?;
profile.data.mods.insert(key); profile.pkgs_mut().insert(key);
for d in &loc.dependencies { for d in &loc.dependencies {
_ = self.toggle_package(d.clone(), true); _ = self.toggle_package(d.clone(), true);
} }
} else { } else {
profile.data.mods.remove(&key); profile.pkgs_mut().remove(&key);
for (ckey, pkg) in self.pkgs.get_all() { for (ckey, pkg) in self.pkgs.get_all() {
if let Some(loc) = pkg.loc { if let Some(loc) = pkg.loc {
if loc.dependencies.contains(&key) { if loc.dependencies.contains(&key) {
@ -85,10 +90,10 @@ impl AppData {
Ok(()) Ok(())
} }
pub fn sum_packages(&self, p: &Profile) -> String { pub fn sum_packages(&self, p: &AnyProfile) -> String {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
for pkg in &p.data.mods { for pkg in p.pkgs().into_iter() {
let x = self.pkgs.get(pkg).unwrap().loc.as_ref().unwrap(); let x = self.pkgs.get(&pkg).unwrap().loc.as_ref().unwrap();
pkg.hash(&mut hasher); pkg.hash(&mut hasher);
x.version.hash(&mut hasher); x.version.hash(&mut hasher);
} }

View File

@ -2,39 +2,42 @@ use log;
use std::collections::HashMap; use std::collections::HashMap;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::fs; use tokio::fs;
use tauri::{AppHandle, Manager, State};
use crate::model::misc::Game; use crate::model::misc::Game;
use crate::pkg::{Package, PkgKey}; use crate::pkg::{Package, PkgKey};
use crate::pkg_store::InstallResult; use crate::pkg_store::InstallResult;
use crate::profile::Profile; use crate::profiles::ongeki::OngekiProfile;
use crate::profiles::{AnyProfile, Profile, ProfileMeta, ProfilePaths};
use crate::appdata::AppData; use crate::appdata::AppData;
use crate::{liner, start, util}; use crate::util;
use tauri::{AppHandle, Manager, State};
#[tauri::command] #[tauri::command]
pub async fn startline(app: AppHandle) -> Result<(), String> { pub async fn startline(app: AppHandle) -> Result<(), String> {
log::debug!("invoke: startline"); log::debug!("invoke: startline");
let app_copy = app.clone();
let state = app.state::<Mutex<AppData>>(); let state = app.state::<Mutex<AppData>>();
let appd = state.lock().await; let mut hash = "".to_owned();
let mut appd = state.lock().await;
if let Some(p) = &appd.profile { if let Some(p) = &appd.profile {
let hash = appd.sum_packages(p); hash = appd.sum_packages(p);
liner::line_up(p, hash).await }
.map_err(|e| e.to_string())?; if let Some(p) = &mut appd.profile {
p.line_up(app.clone(), hash).await
.map_err(|e| format!("Lineup failed:\n{}", e))?;
p.start(app.clone()).await
.map_err(|e| format!("Startup failed:\n{}", e))?;
start::start(p, app_copy).await Ok(())
.map_err(|e| e.to_string())
} else { } else {
Err("No profile".to_owned()) Err("No profile".to_owned())
} }
} }
#[tauri::command] #[tauri::command]
pub async fn kill() -> Result<(), String> { pub async fn kill() -> Result<(), String> {
start::pkill("amdaemon.exe").await; util::pkill("amdaemon.exe").await;
// The start routine will kill the other process // The start routine will kill the other process
Ok(()) Ok(())
@ -108,13 +111,35 @@ pub async fn fetch_listings(state: State<'_, Mutex<AppData>>) -> Result<(), Stri
} }
#[tauri::command] #[tauri::command]
pub async fn list_profiles() -> Result<Vec<(Game, String)>, String> { pub async fn list_profiles() -> Result<Vec<ProfileMeta>, String> {
log::debug!("invoke: list_profiles"); log::debug!("invoke: list_profiles");
let list = Profile::list().await.map_err(|e| e.to_string())?; let list = crate::profiles::list_profiles().await.map_err(|e| e.to_string())?;
Ok(list) Ok(list)
} }
#[tauri::command]
pub async fn init_profile(
state: State<'_, Mutex<AppData>>,
game: Game,
name: String
) -> Result<(), String> {
log::debug!("invoke: init_profile({}, {})", game, name);
let mut appd = state.lock().await;
let new_profile = OngekiProfile::new(name)
.map_err(|e| format!("Unable to create profile: {}", e))?;
fs::create_dir_all(new_profile.config_dir()).await
.map_err(|e| format!("Unable to create the profile config directory: {}", e))?;
fs::create_dir_all(new_profile.data_dir()).await
.map_err(|e| format!("Unable to create the profile data directory: {}", e))?;
appd.profile = Some(AnyProfile::OngekiProfile(new_profile.clone()));
Ok(())
}
#[tauri::command] #[tauri::command]
pub async fn load_profile(state: State<'_, Mutex<AppData>>, game: Game, name: String) -> Result<(), String> { pub async fn load_profile(state: State<'_, Mutex<AppData>>, game: Game, name: String) -> Result<(), String> {
log::debug!("invoke: load_profile({} {:?})", game, name); log::debug!("invoke: load_profile({} {:?})", game, name);
@ -125,7 +150,41 @@ pub async fn load_profile(state: State<'_, Mutex<AppData>>, game: Game, name: St
} }
#[tauri::command] #[tauri::command]
pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Option<Profile>, ()> { pub async fn rename_profile(
state: State<'_, Mutex<AppData>>,
profile: ProfileMeta,
name: String
) -> Result<(), String> {
log::debug!("invoke: rename_profile({:?} {:?})", profile, name);
let new_meta = ProfileMeta {
game: profile.game.clone(),
name: name.clone()
};
if new_meta.config_dir().exists() {
return Err(format!("Profile {} already exists", &name));
}
fs::rename(profile.config_dir(), new_meta.config_dir()).await
.map_err(|e| format!("Unable to rename: {}", e))?;
if let Err(e) = fs::rename(profile.data_dir(), new_meta.data_dir()).await {
log::warn!("Unable to move data dir {}->{}: {}", &profile.name, &name, e);
}
let mut appd = state.lock().await;
if let Some(current) = &mut appd.profile {
if current.meta() == profile {
current.rename(name);
}
}
Ok(())
}
#[tauri::command]
pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Option<AnyProfile>, ()> {
log::debug!("invoke: get_current_profile"); log::debug!("invoke: get_current_profile");
let appd = state.lock().await; let appd = state.lock().await;
@ -133,41 +192,16 @@ pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Opt
} }
#[tauri::command] #[tauri::command]
pub async fn save_current_profile(state: State<'_, Mutex<AppData>>) -> Result<(), ()> { pub async fn save_current_profile(state: State<'_, Mutex<AppData>>, profile: AnyProfile) -> Result<(), String> {
log::debug!("invoke: save_current_profile"); log::debug!("invoke: save_current_profile");
let appd = state.lock().await; let mut appd = state.lock().await;
if let Some(p) = &appd.profile { profile.save().map_err(|e| e.to_string())?;
p.save().await; appd.profile = Some(profile);
} else {
log::warn!("No profile to save");
}
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn init_profile(
state: State<'_, Mutex<AppData>>,
game: Game,
name: String
) -> Result<Profile, String> {
log::debug!("invoke: init_profile({}, {})", game, name);
let mut appd = state.lock().await;
let new_profile = Profile::new(game, name);
fs::create_dir_all(new_profile.config_dir()).await
.map_err(|e| format!("Unable to create the profile config directory: {}", e))?;
fs::create_dir_all(new_profile.data_dir()).await
.map_err(|e| format!("Unable to create the profile data directory: {}", e))?;
appd.profile = Some(new_profile.clone());
new_profile.save().await;
Ok(new_profile)
}
#[tauri::command] #[tauri::command]
pub async fn list_platform_capabilities() -> Result<Vec<String>, ()> { pub async fn list_platform_capabilities() -> Result<Vec<String>, ()> {
log::debug!("invoke: list_platform_capabilities"); log::debug!("invoke: list_platform_capabilities");
@ -179,22 +213,6 @@ pub async fn list_platform_capabilities() -> Result<Vec<String>, ()> {
return Ok(vec!["wine".to_owned()]); return Ok(vec!["wine".to_owned()]);
} }
#[tauri::command]
pub async fn set_cfg(
state: State<'_, Mutex<AppData>>,
key: String,
value: serde_json::Value
) -> Result<(), ()> {
log::debug!("invoke: set_cfg({}, {})", key, value);
let mut appd = state.lock().await;
if let Some(p) = &mut appd.profile {
p.data.cfg.insert(key, value);
}
Ok(())
}
#[tauri::command] #[tauri::command]
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub async fn list_displays() -> Result<Vec<(String, String)>, String> { pub async fn list_displays() -> Result<Vec<(String, String)>, String> {

View File

@ -1,116 +0,0 @@
use crate::profile::Profile;
use anyhow::Result;
#[cfg(target_os = "windows")]
pub struct DisplayInfo {
primary: String,
target: String,
target_settings: displayz::DisplaySettings
}
#[allow(dead_code)]
#[cfg(not(target_os = "windows"))]
pub async fn prepare_display(_: &Profile) -> Result<()> {
Ok(())
}
#[cfg(target_os = "windows")]
pub async fn prepare_display(p: &Profile) -> Result<Option<DisplayInfo>> {
use anyhow::anyhow;
use displayz::{query_displays, Orientation, Resolution, Frequency};
let display_name = p.get_str("display", "default");
let rotation = p.get_int("display-rotation", 0);
if display_name == "default" {
log::debug!("prepare display: skip");
return Ok(None);
}
let display_set = query_displays()?;
let primary = display_set
.displays()
.find(|display| display.is_primary())
.ok_or_else(|| anyhow!("Primary display not found"))?;
let target = display_set
.displays()
.find(|display| display.name() == display_name)
.ok_or_else(|| anyhow!("Display {} not found", display_name))?;
target.set_primary()?;
let settings = target.settings()
.as_ref()
.ok_or_else(|| anyhow!("Unable to query display settings"))?;
let res = DisplayInfo {
primary: primary.name().to_owned(),
target: target.name().to_owned(),
target_settings: settings.borrow().clone()
};
if rotation == 90 || rotation == 270 {
let rez = settings.borrow_mut().resolution;
settings.borrow_mut().orientation = if rotation == 90 { Orientation::PortraitFlipped } else { Orientation::Portrait };
if rez.height < rez.width {
settings.borrow_mut().resolution = Resolution::new(rez.height, rez.width);
}
}
let frequency: u32 = p.get_int("frequency", 60)
.try_into()
.map_err(|e| anyhow!("Invalid display frequency: {}", e))?;
let width: u32 = p.get_int("rez-w", 1080)
.try_into()
.map_err(|e| anyhow!("Invalid display width: {}", e))?;
let height: u32 = p.get_int("rez-h", 1080)
.try_into()
.map_err(|e| anyhow!("Invalid display height: {}", e))?;
settings.borrow_mut().frequency = Frequency::new(frequency);
if p.get_str("display-mode", "borderless") == "borderless" && p.get_bool("borderless-fullscreen", false) {
settings.borrow_mut().resolution = Resolution::new(width, height);
}
display_set.apply()?;
displayz::refresh()?;
log::debug!("prepare display: done");
Ok(Some(res))
}
#[cfg(target_os = "windows")]
pub async fn undo_display(info: DisplayInfo) -> Result<()> {
use anyhow::anyhow;
use displayz::query_displays;
let display_set = query_displays()?;
let primary = display_set
.displays()
.find(|display| display.name() == info.primary)
.ok_or_else(|| anyhow!("Display {} not found", info.primary))?;
let target = display_set
.displays()
.find(|display| display.name() == info.target)
.ok_or_else(|| anyhow!("Display {} not found", info.target))?;
primary.set_primary()?;
let settings = target.settings()
.as_ref()
.ok_or_else(|| anyhow!("Unable to query display settings"))?;
settings.replace_with(|_| info.target_settings);
display_set.apply()?;
displayz::refresh()?;
log::debug!("undo display: done");
Ok(())
}

View File

@ -2,21 +2,18 @@ mod cmd;
mod model; mod model;
mod pkg; mod pkg;
mod pkg_store; mod pkg_store;
mod profile;
mod util; mod util;
mod start;
mod liner;
mod download_handler; mod download_handler;
mod appdata; mod appdata;
mod display; mod modules;
mod profiles;
use anyhow::anyhow; use anyhow::anyhow;
use closure::closure; use closure::closure;
use appdata::AppData; use appdata::AppData;
use model::misc::Game; use model::misc::Game;
use pkg::PkgKey; use pkg::PkgKey;
use profile::Profile; use tauri::{AppHandle, Listener, Manager};
use tauri::{Listener, Manager};
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_cli::CliExt; use tauri_plugin_cli::CliExt;
use tokio::{sync::Mutex, fs, try_join}; use tokio::{sync::Mutex, fs, try_join};
@ -40,28 +37,7 @@ pub async fn run(_args: Vec<String>) {
.expect("No main window") .expect("No main window")
.set_focus(); .set_focus();
if args.len() == 2 { if args.len() == 2 {
// Todo deindent this chimera deep_link(app.clone(), args);
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");
if let Some(caps) = regex.captures(url) {
if caps.len() == 3 {
let apph = app.clone();
let key = PkgKey(format!("{}-{}", caps.get(1).unwrap().as_str(), caps.get(2).unwrap().as_str()));
tauri::async_runtime::spawn(async move {
let mutex = apph.state::<Mutex<AppData>>();
let mut appd = mutex.lock().await;
_ = appd.pkgs.fetch_listings().await;
if let Err(e) = appd.pkgs.install_package(&key, true, true).await {
log::warn!("Fail: {}", e.to_string());
}
});
}
}
}
} }
})) }))
.plugin(tauri_plugin_cli::init()) .plugin(tauri_plugin_cli::init())
@ -85,7 +61,7 @@ pub async fn run(_args: Vec<String>) {
log::debug!("{:?} {:?} {:?}", start_arg, game_arg, name_arg); log::debug!("{:?} {:?} {:?}", start_arg, game_arg, name_arg);
if start_arg.occurrences > 0 { if start_arg.occurrences > 0 {
start_immediately = true; start_immediately = true;
app_data.remain_open = false; app_data.state.remain_open = false;
} else { } else {
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into())) tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
.title("STARTLINER") .title("STARTLINER")
@ -125,7 +101,6 @@ pub async fn run(_args: Vec<String>) {
} }
}); });
app.listen("download-end", closure!(clone apph, |ev| { app.listen("download-end", closure!(clone apph, |ev| {
let raw = ev.payload(); let raw = ev.payload();
let key = PkgKey(raw[1..raw.len()-1].to_owned()); let key = PkgKey(raw[1..raw.len()-1].to_owned());
@ -142,27 +117,24 @@ pub async fn run(_args: Vec<String>) {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mutex = apph.state::<Mutex<AppData>>(); let mutex = apph.state::<Mutex<AppData>>();
let appd = mutex.lock().await; let appd = mutex.lock().await;
if !appd.remain_open { if !appd.state.remain_open {
apph.exit(0); apph.exit(0);
} }
}); });
})); }));
if start_immediately == true { if start_immediately == true {
let apph_clone = apph.clone(); let apph = apph.clone();
tauri::async_runtime::spawn(async { tauri::async_runtime::spawn(async move {
let apph_clone_clone = apph_clone.clone(); let mtx = apph.state::<Mutex<AppData>>();
{
let mtx = apph_clone.state::<Mutex<AppData>>();
let mut appd = mtx.lock().await; let mut appd = mtx.lock().await;
if let Err(e) = appd.pkgs.reload_all().await { if let Err(e) = appd.pkgs.reload_all().await {
log::error!("Unable to reload packages: {}", e); log::error!("Unable to reload packages: {}", e);
apph_clone.exit(1); apph.exit(1);
} }
} if let Err(e) = cmd::startline(apph.clone()).await {
if let Err(e) = cmd::startline(apph_clone).await {
log::error!("Unable to launch: {}", e); log::error!("Unable to launch: {}", e);
apph_clone_clone.exit(1); apph.exit(1);
} }
}); });
} }
@ -181,9 +153,9 @@ pub async fn run(_args: Vec<String>) {
cmd::list_profiles, cmd::list_profiles,
cmd::init_profile, cmd::init_profile,
cmd::load_profile, cmd::load_profile,
cmd::rename_profile,
cmd::get_current_profile, cmd::get_current_profile,
cmd::save_current_profile, cmd::save_current_profile,
cmd::set_cfg,
cmd::startline, cmd::startline,
cmd::kill, cmd::kill,
@ -195,3 +167,31 @@ pub async fn run(_args: Vec<String>) {
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
fn deep_link(app: AppHandle, args: Vec<String>) {
let url = &args[1];
let proto = "rainycolor://";
if &url[..proto.len()] == proto {
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");
if let Some(caps) = regex.captures(url) {
if caps.len() == 3 {
let app = app.clone();
let key = PkgKey(format!("{}-{}", caps.get(1).unwrap().as_str(), caps.get(2).unwrap().as_str()));
tauri::async_runtime::spawn(async move {
let mutex = app.state::<Mutex<AppData>>();
let mut appd = mutex.lock().await;
if let Err(e) = appd.pkgs.fetch_listings().await {
log::warn!("Deep link fetch failed: {:?}", e);
} else if let Err(e) = appd.pkgs.install_package(&key, true, true).await {
log::warn!("Deep link installation failed: {}", e.to_string());
}
});
}
}
}
}

View File

@ -1,121 +0,0 @@
use anyhow::{Result, anyhow};
use tokio::fs;
use std::path::{Path, PathBuf};
use ini::Ini;
use crate::util;
use crate::profile::Profile;
#[cfg(target_os = "linux")]
async fn symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
fs::symlink(src, dst).await
}
#[cfg(target_os = "windows")]
async fn symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
//std::os::windows::fs::junction_point(src, dst) // is unstable
junction::create(src, dst)
}
pub async fn line_up(p: &Profile, pkg_hash: String) -> Result<()> {
let dir_out = p.data_dir();
if dir_out.join("option").exists() {
fs::remove_dir_all(dir_out.join("option")).await?;
}
fs::create_dir_all(dir_out.join("option")).await?;
let hash_path = p.data_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.data_dir();
if dir_out.join("BepInEx").exists() {
fs::remove_dir_all(dir_out.join("BepInEx")).await?;
}
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
.join("app")
.join("BepInEx");
if bpx_dir.exists() {
util::copy_recursive(&bpx_dir, &dir_out.join("BepInEx"))?;
}
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?;
}
}
}
log::debug!("prepare packages: done");
Ok(())
}
async fn prepare_config(p: &Profile) -> Result<()> {
let dir_out = p.data_dir();
let target_path = PathBuf::from(p.get_str("target-path", ""));
let exe_dir = target_path.parent().ok_or_else(|| anyhow!("Invalid target path"))?;
let ini_in_raw = fs::read_to_string(p.config_dir().join("segatools-base.ini")).await?;
let ini_in = Ini::load_from_str(&ini_in_raw)?;
let mut opt_dir_in = PathBuf::from(p.get_str("option", ""));
if opt_dir_in.as_os_str().len() > 0 && opt_dir_in.is_relative() {
opt_dir_in = exe_dir.join(opt_dir_in);
}
let opt_dir_out = &dir_out.join("option");
let mut ini_out = ini_in.clone();
ini_out.with_section(Some("vfs"))
.set(
"option",
util::path_to_str(opt_dir_out)?
)
.set("amfs", p.get_str("amfs", ""))
.set("appdata", p.get_str("appdata", "appdata")
);
ini_out.with_section(Some("unity"))
.set("enable", "1")
.set(
"targetAssembly",
util::path_to_str(dir_out.join("BepInEx").join("core").join("BepInEx.Preloader.dll"))?
);
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"))?);
}
ini_out.write_to_file(dir_out.join("segatools.ini"))?;
log::debug!("Option dir: {:?} -> {:?}", opt_dir_in, opt_dir_out);
if opt_dir_in.as_os_str().len() > 0 {
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(())
}

54
rust/src/model/config.rs Normal file
View File

@ -0,0 +1,54 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Clone)]
pub struct Segatools {
pub target: PathBuf,
pub amfs: PathBuf,
pub option: PathBuf,
pub appdata: PathBuf,
pub enable_aime: bool,
pub intel: bool,
}
#[derive(Deserialize, Serialize, Clone, Default, PartialEq)]
pub enum DisplayMode {
Window,
#[default] Borderless,
Fullscreen
}
#[derive(Deserialize, Serialize, Clone)]
pub struct Display {
pub target: String,
pub rez: (i32, i32),
pub mode: DisplayMode,
pub rotation: i32,
pub frequency: i32,
pub borderless_fullscreen: bool,
}
#[derive(Deserialize, Serialize, Clone, Default, PartialEq)]
pub enum NetworkType {
#[default] Remote,
Artemis,
}
#[derive(Deserialize, Serialize, Clone, Default)]
pub struct Network {
pub network_type: NetworkType,
pub local_path: String,
pub local_console: bool,
pub remote_address: String,
pub keychip: String,
pub subnet: String,
pub suffix: Option<i32>,
}
#[derive(Deserialize, Serialize, Clone, Default)]
pub struct BepInEx {
pub console: bool
}

View File

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub enum Game { pub enum Game {
#[serde(rename = "ongeki")] #[serde(rename = "ongeki")]
Ongeki, Ongeki,

View File

@ -1,3 +1,5 @@
pub mod local; pub mod local;
pub mod misc; pub mod misc;
pub mod rainy; pub mod rainy;
pub mod config;
pub mod segatools_base;

View File

@ -0,0 +1,84 @@
pub fn segatools_base() -> String {
"; mu3io is TBD
[mu3io]
path=
[vfd]
; Enable VFD emulation. Disable to use a real VFD
; GP1232A02A FUTABA assembly.
enable=1
[system]
; Enable ALLS system settings.
enable=1
; Enable freeplay mode. This will disable the coin slot and set the game to
; freeplay. Keep in mind that some game modes (e.g. Freedom/Time Modes) will not
; allow you to start a game in freeplay mode.
freeplay=0
; LAN Install: Set this to 1 on all machines.
dipsw1=1
[gfx]
; Enables the graphics hook.
enable=1
[led15093]
; Enable emulation of the 15093-06 controlled lights, which handle the air tower
; RGBs and the rear LED panel (billboard) on the cabinet.
enable=1
[led]
; Output billboard LED strip data to a named pipe called \"\\\\.\\pipe\\ongeki_led\"
cabLedOutputPipe=1
; Output billboard LED strip data to serial
cabLedOutputSerial=0
; Output slider LED data to the named pipe
controllerLedOutputPipe=1
; Output slider LED data to the serial port
controllerLedOutputSerial=0
[io4]
; Test button virtual-key code. Default is the F1 key.
test=0x70
; Service button virtual-key code. Default is the F2 key.
service=0x71
; Keyboard button to increment coin counter. Default is the F3 key.
coin=0x72
; Set \"1\" to enable mouse lever emulation, \"0\" to use XInput
mouse=1
; XInput input bindings
;
; Left Stick Lever
; Left Trigger Lever (move to the left)
; Right Trigger Lever (move to the right)
; Left Left red button
; Up Left green button
; Right Left blue button
; Left Shoulder Left side button
; Right Shoulder Right side button
; X Right red button
; Y Right green button
; A Right blue button
; Back Left menu button
; Start Right menu button
; Keyboard input bindings
left1=0x41 ; A
left2=0x53 ; S
left3=0x44 ; D
leftSide=0x01 ; Mouse Left
rightSide=0x02 ; Mouse Right
right1=0x4A ; J
right2=0x4B ; K
right3=0x4C ; L
leftMenu=0x55 ; U
rightMenu=0x4F ; O".to_owned()
}

View File

@ -0,0 +1,22 @@
use anyhow::Result;
use ini::Ini;
use crate::{model::config::BepInEx, profiles::ProfilePaths};
impl BepInEx {
pub fn line_up(&self, p: &impl ProfilePaths) -> Result<()> {
let dir = p.data_dir().join("BepInEx");
if dir.exists() && dir.is_dir() {
let dir = dir.join("config");
std::fs::create_dir_all(&dir)?;
let mut ini = Ini::new();
ini.with_section(Some("Logging.Console"))
.set("Enabled", if self.console { "true" } else { "false" });
ini.write_to_file(dir.join("BepInEx.cfg"))?;
}
Ok(())
}
}

139
rust/src/modules/display.rs Normal file
View File

@ -0,0 +1,139 @@
use crate::model::config::{Display, DisplayMode};
use anyhow::Result;
use displayz::{query_displays, DisplaySet};
use tauri::{AppHandle, Listener};
#[cfg(target_os = "windows")]
#[derive(Clone)]
pub struct DisplayInfo {
pub primary: String,
pub set: Option<DisplaySet>
}
impl Default for DisplayInfo {
fn default() -> Self {
DisplayInfo {
primary: "default".to_owned(),
set: query_displays().ok()
}
}
}
impl Default for Display {
fn default() -> Self {
Display {
target: "default".to_owned(),
rez: (1080, 1920),
mode: DisplayMode::Borderless,
rotation: 0,
frequency: 60,
borderless_fullscreen: true,
}
}
}
#[cfg(target_os = "windows")]
impl Display {
pub fn activate(&self, app: AppHandle) {
let display = self.clone();
tauri::async_runtime::spawn(async move {
let info = display.line_up()?;
if let Some(info) = info {
app.listen("launch-end", move |_| {
if let Err(e) = Self::clean_up(&info) {
log::error!("Error cleaning up display: {:?}", e);
}
});
}
Ok::<(), anyhow::Error>(())
});
}
fn line_up(&self) -> Result<Option<DisplayInfo>> {
use anyhow::anyhow;
use displayz::{query_displays, Orientation, Resolution, Frequency};
if self.target == "default" {
log::debug!("prepare display: skip");
return Ok(None);
}
let display_set = query_displays()?;
let primary = display_set
.displays()
.find(|display| display.is_primary())
.ok_or_else(|| anyhow!("Primary display not found"))?;
let target = display_set
.displays()
.find(|display| display.name() == self.target)
.ok_or_else(|| anyhow!("Display {} not found", self.target))?;
target.set_primary()?;
let settings = target.settings()
.as_ref()
.ok_or_else(|| anyhow!("Unable to query display settings"))?;
let res = DisplayInfo {
primary: primary.name().to_owned(),
set: Some(display_set.clone())
};
if self.rotation == 90 || self.rotation == 270 {
let rez = settings.borrow_mut().resolution;
settings.borrow_mut().orientation = if self.rotation == 90 { Orientation::PortraitFlipped } else { Orientation::Portrait };
if rez.height < rez.width {
settings.borrow_mut().resolution = Resolution::new(rez.height, rez.width);
}
}
let frequency: u32 = self.frequency
.try_into()
.map_err(|e| anyhow!("Invalid display frequency: {}", e))?;
let width: u32 = self.rez.0
.try_into()
.map_err(|e| anyhow!("Invalid display width: {}", e))?;
let height: u32 = self.rez.1
.try_into()
.map_err(|e| anyhow!("Invalid display height: {}", e))?;
settings.borrow_mut().frequency = Frequency::new(frequency);
if self.borderless_fullscreen && self.mode == DisplayMode::Borderless {
settings.borrow_mut().resolution = Resolution::new(width, height);
}
display_set.apply()?;
displayz::refresh()?;
log::debug!("prepare display: done");
Ok(Some(res))
}
fn clean_up(info: &DisplayInfo) -> Result<()> {
use anyhow::anyhow;
let display_set = info.set.as_ref()
.ok_or_else(|| anyhow!("Unable to clean up displays: no display set"))?;
let primary = display_set
.displays()
.find(|display| display.name() == info.primary)
.ok_or_else(|| anyhow!("Display {} not found", info.primary))?;
primary.set_primary()?;
display_set.apply()?;
displayz::refresh()?;
log::debug!("undo display: done");
Ok(())
}
}

5
rust/src/modules/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod display;
pub mod package;
pub mod segatools;
pub mod network;
pub mod bepinex;

View File

@ -0,0 +1,67 @@
use std::{path::PathBuf, process::Command};
use yaml_rust2::YamlLoader;
use anyhow::{Result, anyhow};
use ini::Ini;
use crate::model::config::{Network, NetworkType};
impl Network {
pub fn line_up(&self, ini: &mut Ini) -> Result<()> {
log::debug!("begin line-up: network");
ini.with_section(Some("dns")).set("default", &self.remote_address);
let mut section_netenv = ini.with_section(Some("netenv"));
section_netenv.set("enable", "1");
if let Some(suffix) = self.suffix {
section_netenv.set("addrSuffix", suffix.to_string());
}
let mut section_keychip = ini.with_section(Some("keychip"));
if self.subnet.len() > 0 {
section_keychip.set("subnet", &self.subnet);
}
if self.network_type == NetworkType::Artemis {
let network_path = PathBuf::from(&self.local_path);
let artemis_dir = network_path.parent()
.ok_or_else(|| anyhow!("Invalid ARTEMiS path {}", &self.local_path))?;
let cfg_path = artemis_dir.join("config").join("core.yaml");
let cfg = std::fs::read_to_string(&cfg_path)
.map_err(|e| anyhow!("Unable to open core.yaml: {}", e))?;
let cfg = YamlLoader::load_from_str(&cfg)
.map_err(|e| anyhow!("Unable to read core.yaml: {}", e))?;
let cfg = &cfg[0];
log::debug!("{:?}", cfg);
let hostname = &cfg["server"]["hostname"];
let hostname = hostname.clone().into_string();
if let Some(hostname) = hostname {
ini.with_section(Some("dns")).set("default", hostname);
#[cfg(target_os = "windows")]
let mut cmd = Command::new("cmd.exe");
cmd.arg("/C");
if self.local_console == true {
cmd.arg("start");
}
cmd.args(["python", &self.local_path]);
cmd.current_dir(artemis_dir);
cmd.spawn()
.map_err(|e| anyhow!("Unable to spawn artemis: {}", e))?;
} else {
log::warn!("unable to parse the artemis hostname");
}
} else if self.keychip.len() > 0 {
section_keychip.set("id", &self.keychip);
}
log::debug!("end line-up: network");
Ok(())
}
}

View File

@ -0,0 +1,43 @@
use anyhow::Result;
use std::collections::BTreeSet;
use crate::pkg::PkgKey;
use crate::util;
use crate::profiles::ProfilePaths;
pub async fn prepare_packages<'a>(p: &'a impl ProfilePaths, pkgs: &BTreeSet<PkgKey>) -> Result<()> {
log::debug!("begin prepare packages");
let pfx_dir = p.data_dir();
let opt_dir = pfx_dir.join("option");
if pfx_dir.join("BepInEx").exists() {
tokio::fs::remove_dir_all(pfx_dir.join("BepInEx")).await?;
}
if !opt_dir.exists() {
tokio::fs::create_dir(opt_dir).await?;
}
for m in pkgs {
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
.join("app")
.join("BepInEx");
if bpx_dir.exists() {
util::copy_recursive(&bpx_dir, &pfx_dir.join("BepInEx"))?;
}
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() {
util::symlink(&x.path(), &pfx_dir.join("option").join(x.file_name())).await?;
}
}
}
log::debug!("end prepare packages");
Ok(())
}

View File

@ -0,0 +1,93 @@
use std::path::PathBuf;
use anyhow::{anyhow, Result};
use ini::Ini;
use crate::{model::{config::Segatools, segatools_base::segatools_base}, profiles::ProfilePaths, util::{self, PathStr}};
impl Default for Segatools {
fn default() -> Self {
Segatools {
target: PathBuf::default(),
amfs: PathBuf::default(),
option: PathBuf::default(),
appdata: PathBuf::from("appdata"),
enable_aime: false,
intel: false
}
}
}
impl Segatools {
pub async fn line_up(&self, p: &impl ProfilePaths) -> Result<Ini> {
log::debug!("begin line-up: segatools");
let pfx_dir = p.data_dir();
let exe_dir = self.target.parent().ok_or_else(|| anyhow!("Invalid target path"))?;
log::debug!("segatools: {:?} {:?}", pfx_dir, exe_dir);
let ini_path = p.config_dir().join("segatools-base.ini");
if !ini_path.exists() {
tokio::fs::write(&ini_path, segatools_base()).await
.map_err(|e| anyhow!("Error creating {:?}: {}", ini_path, e))?;
}
if !pfx_dir.exists() {
tokio::fs::create_dir(&pfx_dir).await
.map_err(|e| anyhow!("Error creating {:?}: {}", pfx_dir, e))?;
}
let ini_in = tokio::fs::read_to_string(&ini_path).await?;
let ini_in = Ini::load_from_str(&ini_in)?;
let opt_dir_out = &pfx_dir.join("option");
let opt_dir_in = if self.option.as_os_str().len() > 0 && self.option.is_relative() {
exe_dir.join(&self.option)
} else {
self.option.clone()
};
let mut ini_out = ini_in.clone();
ini_out.with_section(Some("vfs"))
.set(
"option",
opt_dir_out.stringify()?
)
.set("amfs", self.amfs.stringify()?)
.set("appdata", self.appdata.stringify()?);
ini_out.with_section(Some("unity"))
.set("enable", "1")
.set(
"targetAssembly",
pfx_dir.join("BepInEx").join("core").join("BepInEx.Preloader.dll").stringify()?
);
if self.enable_aime {
ini_out.with_section(Some("aime"))
.set("enable", "1")
.set("aimePath", p.config_dir().join("aime.txt").stringify()?);
} else {
ini_out.with_section(Some("aime"))
.set("enable", "0");
}
log::debug!("option dir: {:?} -> {:?}", opt_dir_in, opt_dir_out);
if !opt_dir_out.exists() {
tokio::fs::create_dir(opt_dir_out).await?;
}
if opt_dir_in.as_os_str().len() > 0 {
for opt in opt_dir_in.read_dir()? {
let opt = opt?;
util::symlink(&opt.path(), opt_dir_out.join(opt.file_name())).await?;
}
}
log::debug!("end line-up: segatools");
Ok(ini_out)
}
}

View File

@ -1,141 +0,0 @@
use anyhow::{Result, anyhow};
use std::{collections::{BTreeSet, BTreeMap}, path::{Path, PathBuf}};
use crate::{model::misc::{self, Game}, pkg::PkgKey, util};
use serde::{Deserialize, Serialize};
use tokio::fs;
#[derive(Deserialize, Serialize, Clone)]
pub struct Profile {
pub game: misc::Game,
pub name: String,
pub data: ProfileData
}
// The contents of profile-{game}-{name}.json
#[derive(Deserialize, Serialize, Clone)]
pub struct ProfileData {
pub mods: BTreeSet<PkgKey>,
// cfg is temporarily just a map to make iteration easier
// eventually it should become strict
pub cfg: BTreeMap<String, serde_json::Value>
}
impl Profile {
pub fn new(game: Game, mut name: String) -> Profile {
name = name.trim().replace(" ", "-");
while Self::config_dir_f(&game, &name).exists() {
name = format!("new-{}", name);
}
Profile {
name,
game,
data: ProfileData {
mods: BTreeSet::new(),
cfg: BTreeMap::new()
}
}
}
fn config_dir_f(game: &Game, name: &str) -> PathBuf {
util::config_dir().join(format!("profile-{}-{}", game, name))
}
pub fn config_dir(&self) -> PathBuf {
Self::config_dir_f(&self.game, &self.name)
}
pub fn data_dir(&self) -> PathBuf {
util::data_dir().join(format!("profile-{}-{}", self.game, self.name))
}
pub async fn list() -> Result<Vec<(Game, String)>> {
let path = std::fs::read_dir(util::config_dir())?;
let mut res = Vec::new();
for f in path {
let f = f?;
if let Ok(meta) = f.metadata() {
if !meta.is_dir() {
continue;
}
log::debug!("{:?}", f);
if let Some(pair) = Self::name_from_path(f.path()) {
res.push(pair);
}
}
}
Ok(res)
}
pub fn load(game: Game, name: String) -> Result<Profile> {
let path = Self::config_dir_f(&game, &name).join("profile.json");
if let Ok(s) = std::fs::read_to_string(&path) {
let data = serde_json::from_str::<ProfileData>(&s)
.map_err(|e| anyhow!("Unable to parse {:?}: {:?}", path, e))?;
Ok(Profile {
game,
name,
data
})
} else {
Err(anyhow!("Unable to open {:?}", path))
}
}
pub async fn save(&self) {
let path = self.config_dir().join("profile.json");
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.data.cfg.get(key)
.ok_or_else(|| anyhow::anyhow!("Invalid config entry {}", key))
}
pub fn get_bool(&self, key: &str, default: bool) -> bool {
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.data.cfg.get(key)
.and_then(|c| c.as_i64())
.unwrap_or(default)
}
pub fn get_str(&self, key: &str, default: &str) -> String {
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-([^\-]+)-(.+)$"
).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
}
}

162
rust/src/profiles/mod.rs Normal file
View File

@ -0,0 +1,162 @@
use anyhow::{Result, anyhow};
use ongeki::OngekiProfile;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use std::{collections::BTreeSet, path::{Path, PathBuf}};
use crate::{model::misc::Game, modules::package::prepare_packages, pkg::PkgKey, util};
pub mod ongeki;
#[derive(Deserialize, Serialize, Clone)]
pub enum AnyProfile {
OngekiProfile(OngekiProfile)
}
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)]
pub struct ProfileMeta {
pub game: Game,
pub name: String
}
pub trait Profile: Sized {
fn new(name: String) -> Result<Self>;
fn load(name: String) -> Result<Self>;
fn save(&self) -> Result<()>;
async fn start(&self, app: AppHandle) -> Result<()>;
}
pub trait ProfilePaths {
fn config_dir(&self) -> PathBuf;
fn data_dir(&self) -> PathBuf;
}
impl AnyProfile {
pub fn load(game: Game, name: String) -> Result<Self> {
Ok(match game {
Game::Ongeki => AnyProfile::OngekiProfile(OngekiProfile::load(name)?),
Game::Chunithm => panic!("Not implemented")
})
}
pub fn save(&self) -> Result<()> {
match self {
Self::OngekiProfile(p) => p.save()
}
}
pub fn meta(&self) -> ProfileMeta {
match self {
Self::OngekiProfile(p) => {
ProfileMeta {
game: Game::Ongeki,
name: p.name.as_ref().unwrap().clone()
}
}
}
}
pub fn rename(&mut self, name: String) {
match self {
Self::OngekiProfile(p) => {
p.name = Some(name);
}
}
}
pub fn pkgs(&self) -> &BTreeSet<PkgKey> {
match self {
Self::OngekiProfile(p) => &p.mods
}
}
pub fn pkgs_mut(&mut self) -> &mut BTreeSet<PkgKey> {
match self {
Self::OngekiProfile(p) => &mut p.mods
}
}
pub async fn line_up(&self, app: AppHandle, pkg_hash: String) -> Result<()> {
match self {
Self::OngekiProfile(p) => {
if !p.data_dir().exists() {
tokio::fs::create_dir(p.data_dir()).await?;
}
let hash_path = p.data_dir().join(".sl-state");
let meta = self.meta();
p.display.activate(app.clone());
util::clean_up_opts(p.data_dir().join("option"))?;
if Self::hash_check(&hash_path, &pkg_hash).await? == true {
prepare_packages(&meta, &p.mods).await
.map_err(|e| anyhow!("package configuration failed:\n{:?}", e))?;
}
let mut ini = p.sgt.line_up(&meta).await
.map_err(|e| anyhow!("segatools configuration failed:\n{:?}", e))?;
p.network.line_up(&mut ini)?;
ini.write_to_file(p.data_dir().join("segatools.ini"))
.map_err(|e| anyhow!("Error writing segatools.ini: {}", e))?;
p.bepinex.line_up(&meta)?;
Ok(())
}
}
}
pub async fn start(&self, app: AppHandle) -> Result<()> {
match self {
Self::OngekiProfile(p) => p.start(app).await
}
}
async fn hash_check(prev_hash_path: &impl AsRef<Path>, new_hash: &str) -> Result<bool> {
let prev_hash = tokio::fs::read_to_string(&prev_hash_path).await.unwrap_or_default();
if prev_hash != new_hash {
log::debug!("state {} -> {}", prev_hash, new_hash);
tokio::fs::write(prev_hash_path, new_hash).await
.map_err(|e| anyhow!("Unable to write the state file: {}", e))?;
Ok(true)
} else {
Ok(false)
}
}
}
pub async fn list_profiles() -> Result<Vec<ProfileMeta>> {
let path = std::fs::read_dir(util::config_dir())?;
let mut res = Vec::new();
for f in path {
let f = f?;
if let Ok(meta) = f.metadata() {
if !meta.is_dir() {
continue;
}
log::debug!("{:?}", f);
if let Some(meta) = meta_from_path(f.path()) {
res.push(meta);
}
}
}
Ok(res)
}
fn meta_from_path(path: impl AsRef<Path>) -> Option<ProfileMeta> {
let regex = regex::Regex::new(
r"^profile-([^\-]+)-(.+)$"
).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(ProfileMeta { game, name });
}
}
None
}

234
rust/src/profiles/ongeki.rs Normal file
View File

@ -0,0 +1,234 @@
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri::Emitter;
use std::{collections::BTreeSet, path::PathBuf, process::Stdio};
use crate::model::config::BepInEx;
use crate::util::PathStr;
use crate::{model::{config::{Display, DisplayMode, Network, Segatools}, misc::Game, segatools_base::segatools_base}, pkg::PkgKey, util};
use super::{Profile, ProfileMeta, ProfilePaths};
use anyhow::{anyhow, Result};
use std::fs::File;
use tokio::process::Command;
use tokio::task::JoinSet;
#[derive(Deserialize, Serialize, Clone)]
pub struct OngekiProfile {
// this is an Option only to deceive serde
// file serialization doesn't need the name, but IPC does
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub mods: BTreeSet<PkgKey>,
pub sgt: Segatools,
pub display: Display,
pub network: Network,
pub bepinex: BepInEx
}
impl Profile for OngekiProfile {
fn new(name: String) -> Result<Self> {
let mut fixed_name = name.trim().replace(" ", "-");
while util::profile_config_dir(&Game::Ongeki, &name).exists() {
fixed_name = format!("new-{}", name);
}
let p = OngekiProfile {
name: Some(fixed_name),
mods: BTreeSet::new(),
sgt: Segatools::default(),
display: Display::default(),
network: Network::default(),
bepinex: BepInEx::default()
};
p.save()?;
std::fs::write(p.config_dir().join("segatools-base.ini"), segatools_base())?;
log::debug!("created profile-ongeki-{}", name);
Ok(p)
}
fn load(name: String) -> Result<Self> {
let path = util::profile_config_dir(&Game::Ongeki, &name).join("profile.json");
if let Ok(s) = std::fs::read_to_string(&path) {
let mut data = serde_json::from_str::<OngekiProfile>(&s)
.map_err(|e| anyhow!("Unable to parse {:?}: {:?}", path, e))?;
data.name = Some(name);
Ok(data)
} else {
Err(anyhow!("Unable to open {:?}", path))
}
}
fn save(&self) -> Result<()> {
let path = self.config_dir().join("profile.json");
let mut cpy = self.clone();
cpy.name = None;
let s = serde_json::to_string_pretty(&cpy)?;
if !self.config_dir().exists() {
std::fs::create_dir(self.config_dir())
.map_err(|e| anyhow!("error when creating profile directory: {}", e))?;
}
std::fs::write(&path, s)
.map_err(|e| anyhow!("error when writing to {:?}: {}", path, e))?;
log::info!("Written to {}", path.to_string_lossy());
Ok(())
}
async fn start(&self, app: AppHandle) -> Result<()> {
let ini_path = self.data_dir().join("segatools.ini");
log::debug!("With path {}", ini_path.to_string_lossy());
let mut game_builder;
let mut amd_builder;
let target_path = PathBuf::from(&self.sgt.target);
let exe_dir = target_path.parent().ok_or_else(|| anyhow!("Invalid target path"))?;
#[cfg(target_os = "windows")]
{
game_builder = Command::new(exe_dir.join("inject.exe"));
amd_builder = Command::new("cmd.exe");
}
#[cfg(target_os = "linux")]
{
let wine = p.data.wine_runtime.clone()
.unwrap_or_else(|| std::path::PathBuf::from("/usr/bin/wine"));
game_builder = Command::new(&wine);
amd_builder = Command::new(&wine);
game_builder.arg(exe_dir.join("inject.exe"));
amd_builder.arg("cmd.exe");
}
amd_builder.env(
"SEGATOOLS_CONFIG_PATH",
&ini_path,
)
.current_dir(&exe_dir)
.args([
"/C",
&exe_dir.join("inject.exe").stringify()?, "-d", "-k", "mu3hook.dll",
"amdaemon.exe", "-f", "-c", "config_common.json", "config_server.json", "config_client.json"
]);
game_builder
.env(
"SEGATOOLS_CONFIG_PATH",
ini_path,
)
.env(
"INOHARA_CONFIG_PATH",
self.config_dir().join("inohara.cfg"),
)
.current_dir(&exe_dir)
.args([
"-d", "-k", "mu3hook.dll",
"mu3.exe", "-monitor 1",
"-screen-width", &self.display.rez.0.to_string(),
"-screen-height", &self.display.rez.1.to_string(),
"-screen-fullscreen", if self.display.mode == DisplayMode::Fullscreen { "1" } else { "0" }
]);
if self.display.mode == DisplayMode::Borderless {
game_builder.arg("-popupwindow");
}
#[cfg(target_os = "linux")]
{
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);
}
let amd_log = File::create(self.data_dir().join("amdaemon.log"))?;
let game_log = File::create(self.data_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(util::CREATE_NO_WINDOW);
game_builder.creation_flags(util::CREATE_NO_WINDOW);
}
if self.sgt.intel == true {
amd_builder.env("OPENSSL_ia32cap", ":~0x20000000");
}
util::pkill("amdaemon.exe").await;
log::info!("Launching amdaemon: {:?}", amd_builder);
log::info!("Launching mu3: {:?}", game_builder);
let mut amd = amd_builder.spawn()?;
let mut game = game_builder.spawn()?;
let mut set = JoinSet::new();
set.spawn(async move {
(amd.wait().await.expect("amdaemon failed to run"), "amdaemon.exe")
});
set.spawn(async move {
(game.wait().await.expect("mu3 failed to run"), "mu3.exe")
});
if let Err(e) = app.emit("launch-start", "") {
log::warn!("Unable to emit launch-start: {}", e);
}
let (rc, process_name) = set.join_next().await.expect("No spawn").expect("No result");
log::info!("{} died with return code {}", process_name, rc);
if process_name == "amdaemon.exe" {
util::pkill("mu3.exe").await;
} else {
util::pkill("amdaemon.exe").await;
}
set.join_next().await.expect("No spawn").expect("No result");
log::debug!("Fin");
if let Err(e) = app.emit("launch-end", "") {
log::warn!("Unable to emit launch-end: {}", e);
}
Ok(())
}
}
impl ProfilePaths for OngekiProfile {
fn config_dir(&self) -> PathBuf {
util::profile_config_dir(&Game::Ongeki, &self.name.as_ref().unwrap())
}
fn data_dir(&self) -> PathBuf {
util::data_dir().join(format!("profile-{}-{}", &Game::Ongeki, self.name.as_ref().unwrap()))
}
}
impl ProfilePaths for ProfileMeta {
fn config_dir(&self) -> PathBuf {
util::profile_config_dir(&self.game, &self.name)
}
fn data_dir(&self) -> PathBuf {
util::data_dir().join(format!("profile-{}-{}", &self.game, &self.name))
}
}

View File

@ -1,177 +0,0 @@
use anyhow::{anyhow, Result};
use std::fs::File;
use std::path::PathBuf;
use tokio::process::Command;
use tauri::{AppHandle, Emitter};
use std::process::Stdio;
use crate::profile::Profile;
use crate::util;
#[cfg(target_os = "windows")]
static CREATE_NO_WINDOW: u32 = 0x08000000;
pub async fn start(p: &Profile, app: AppHandle) -> Result<()> {
use tokio::task::JoinSet;
let ini_path = p.data_dir().join("segatools.ini");
log::debug!("With path {}", ini_path.to_string_lossy());
let mut game_builder;
let mut amd_builder;
let target_path = PathBuf::from(p.get_str("target-path", ""));
let exe_dir = target_path.parent().ok_or_else(|| anyhow!("Invalid target path"))?;
#[cfg(target_os = "windows")]
let display_info = crate::display::prepare_display(p).await?;
#[cfg(target_os = "windows")]
{
game_builder = Command::new(exe_dir.join("inject.exe"));
amd_builder = Command::new("cmd.exe");
}
#[cfg(target_os = "linux")]
{
let wine = p.data.wine_runtime.clone()
.unwrap_or_else(|| std::path::PathBuf::from("/usr/bin/wine"));
game_builder = Command::new(&wine);
amd_builder = Command::new(&wine);
game_builder.arg(exe_dir.join("inject.exe"));
amd_builder.arg("cmd.exe");
}
let display_mode = p.get_str("display-mode", "borderless");
amd_builder.env(
"SEGATOOLS_CONFIG_PATH",
&ini_path,
)
.current_dir(&exe_dir)
.args([
"/C",
&util::path_to_str(exe_dir.join("inject.exe"))?, "-d", "-k", "mu3hook.dll",
"amdaemon.exe", "-f", "-c", "config_common.json", "config_server.json", "config_client.json"
]);
game_builder
.env(
"SEGATOOLS_CONFIG_PATH",
ini_path,
)
.env(
"INOHARA_CONFIG_PATH",
p.config_dir().join("inohara.cfg"),
)
.current_dir(&exe_dir)
.args([
"-d", "-k", "mu3hook.dll",
"mu3.exe", "-monitor 1",
"-screen-width", &p.get_int("rez-w", 1080).to_string(),
"-screen-height", &p.get_int("rez-h", 1920).to_string(),
"-screen-fullscreen", if display_mode == "fullscreen" { "1" } else { "0" }
]);
if display_mode == "borderless" {
game_builder.arg("-popupwindow");
}
#[cfg(target_os = "linux")]
{
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);
}
let amd_log = File::create(p.data_dir().join("amdaemon.log"))?;
let game_log = File::create(p.data_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");
}
pkill("amdaemon.exe").await;
log::info!("Launching amdaemon: {:?}", amd_builder);
log::info!("Launching mu3: {:?}", game_builder);
let mut amd = amd_builder.spawn()?;
let mut game = game_builder.spawn()?;
tauri::async_runtime::spawn(async move {
let mut set = JoinSet::new();
set.spawn(async move {
(amd.wait().await.expect("amdaemon failed to run"), "amdaemon.exe")
});
set.spawn(async move {
(game.wait().await.expect("mu3 failed to run"), "mu3.exe")
});
if let Err(e) = app.emit("launch-start", "") {
log::warn!("Unable to emit launch-start: {}", e);
}
let (rc, process_name) = set.join_next().await.expect("No spawn").expect("No result");
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");
log::debug!("Fin");
if let Err(e) = app.emit("launch-start", "") {
log::warn!("Unable to emit launch-end: {}", e);
}
#[cfg(target_os = "windows")]
{
if let Some(display_info) = display_info {
if let Err(e) = crate::display::undo_display(display_info).await {
log::error!("undo display failed: {}", e);
}
}
}
});
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;
}

View File

@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use std::{path::{Path, PathBuf}, sync::OnceLock}; use std::{path::{Path, PathBuf}, sync::OnceLock};
use crate::model::misc::Game;
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
static NAME: &str = "startliner"; static NAME: &str = "startliner";
@ -22,9 +24,9 @@ pub fn init_dirs(apph: &AppHandle) {
DIRS.get_or_init(|| { DIRS.get_or_init(|| {
if cfg!(windows) { if cfg!(windows) {
Dirs { Dirs {
config_dir: apph.path().data_dir().expect("Unable to set project directories").join(NAME).join("cfg"), config_dir: apph.path().data_dir().expect("Unable to set project directories").join(NAME),
data_dir: apph.path().data_dir().expect("Unable to set project directories").join(NAME).join("data"), data_dir: apph.path().cache_dir().expect("Unable to set project directories").join(NAME).join("data"),
cache_dir: apph.path().cache_dir().expect("Unable to set project directories").join(NAME), cache_dir: apph.path().cache_dir().expect("Unable to set project directories").join(NAME).join("cache"),
} }
} else { } else {
Dirs { Dirs {
@ -44,6 +46,10 @@ pub fn config_dir() -> &'static Path {
&DIRS.get().expect("Directories uninitialized").config_dir &DIRS.get().expect("Directories uninitialized").config_dir
} }
pub fn profile_config_dir(game: &Game, name: &str) -> PathBuf {
config_dir().join(format!("profile-{}-{}", game, name))
}
pub fn data_dir() -> &'static Path { pub fn data_dir() -> &'static Path {
&DIRS.get().expect("Directories uninitialized").data_dir &DIRS.get().expect("Directories uninitialized").data_dir
} }
@ -60,11 +66,6 @@ pub fn pkg_dir_of(namespace: &str, name: &str) -> PathBuf {
pkg_dir().join(format!("{}-{}", namespace, name)) pkg_dir().join(format!("{}-{}", namespace, name))
} }
pub fn path_to_str(p: impl AsRef<Path>) -> Result<String> {
Ok(p.as_ref().to_str()
.ok_or_else(|| anyhow!("Invalid path: {}", p.as_ref().to_string_lossy()))?.to_owned())
}
pub fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> { pub fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(&dst).unwrap(); std::fs::create_dir_all(&dst).unwrap();
for entry in std::fs::read_dir(src)? { for entry in std::fs::read_dir(src)? {
@ -78,3 +79,69 @@ pub fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
} }
Ok(()) Ok(())
} }
#[cfg(target_os = "linux")]
pub async fn symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
fs::symlink(src, dst).await
}
#[cfg(target_os = "windows")]
pub async fn symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
//std::os::windows::fs::junction_point(src, dst) // is unstable
junction::create(src, dst)
}
#[cfg(target_os = "windows")]
pub static CREATE_NO_WINDOW: u32 = 0x08000000;
#[cfg(target_os = "windows")]
pub async fn pkill(process_name: &str) {
use tokio::process::Command;
_ = 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;
}
pub fn clean_up_opts(dir: impl AsRef<Path>) -> Result<()> {
log::debug!("begin clean_up_opts");
if dir.as_ref().is_dir() {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
log::debug!("{:?}", path);
if path.is_symlink() {
std::fs::remove_dir(path)?;
} else {
log::error!("Not a symlink: {:?}", path);
}
}
}
log::debug!("end clean_up_opts");
Ok(())
}
pub trait PathStr {
fn stringify(&self) -> Result<String>;
}
fn path_to_str(p: impl AsRef<Path>) -> Result<String> {
Ok(p.as_ref().to_str().ok_or_else(|| anyhow!("Invalid path: {:?}", p.as_ref()))?.to_owned())
}
impl PathStr for Path {
fn stringify(&self) -> Result<String> {
path_to_str(self)
}
}
impl PathStr for PathBuf {
fn stringify(&self) -> Result<String> {
path_to_str(&self)
}
}

View File

@ -30,8 +30,7 @@ onMounted(async () => {
general.dirs = d as Dirs; general.dirs = d as Dirs;
}); });
await prf.reloadList(); await Promise.all([prf.reloadList(), prf.reload()]);
await prf.reload();
if (prf.current !== null) { if (prf.current !== null) {
await pkg.reloadAll(); await pkg.reloadAll();

View File

@ -2,25 +2,16 @@
import Button from 'primevue/button'; import Button from 'primevue/button';
import InputText from 'primevue/inputtext'; import InputText from 'primevue/inputtext';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { usePrfStore } from '../stores';
const props = defineProps({ const props = defineProps({
field: String,
default: String,
placeholder: String, placeholder: String,
directory: Boolean, directory: Boolean,
promptname: String, promptname: String,
extension: String, extension: String,
value: String,
callback: Function,
}); });
if (props.field === undefined || props.default === undefined) {
throw new Error('Invalid FilePicker');
}
const prf = usePrfStore();
const cfg = prf.cfg(props.field, props.default);
const filePick = async () => { const filePick = async () => {
const res = await open({ const res = await open({
multiple: false, multiple: false,
@ -35,9 +26,9 @@ const filePick = async () => {
] ]
: [], : [],
}); });
if (res != null) { if (res != null && props.callback !== undefined) {
cfg.value = props.callback(res);
/*path.relative(cfgs.current?.data.exe_dir ?? '', res) */ res; /*path.relative(cfgs.current?.data.exe_dir ?? '', res) */
} }
}; };
</script> </script>
@ -48,6 +39,7 @@ const filePick = async () => {
size="small" size="small"
:placeholder="placeholder" :placeholder="placeholder"
type="text" type="text"
v-model="cfg" :model-value="value"
@update:model-value="(value) => callback && callback(value)"
/> />
</template> </template>

View File

@ -66,7 +66,7 @@ const aimeCodeModel = computed({
}); });
const extraDisplayOptionsDisabled = computed(() => { const extraDisplayOptionsDisabled = computed(() => {
return prf.cfg('display', 'default').value === 'default'; return prf.current?.display.target === 'default';
}); });
(async () => { (async () => {
@ -79,16 +79,16 @@ const extraDisplayOptionsDisabled = computed(() => {
<OptionCategory title="General"> <OptionCategory title="General">
<OptionRow title="mu3.exe"> <OptionRow title="mu3.exe">
<FilePicker <FilePicker
field="target-path"
default=""
:directory="false" :directory="false"
promptname="mu3.exe" promptname="mu3.exe"
extension="exe" extension="exe"
:value="prf.current!.sgt.target"
:callback="(value: string) => (prf.current!.sgt.target = value)"
></FilePicker> ></FilePicker>
</OptionRow> </OptionRow>
<OptionRow title="mu3hook"> <OptionRow title="mu3hook">
<Select <Select
:model-value="prf.cfg('hook', 'segatools-mu3hook')" model-value="segatools-mu3hook"
:options="hookList" :options="hookList"
option-label="title" option-label="title"
option-value="value" option-value="value"
@ -96,24 +96,27 @@ const extraDisplayOptionsDisabled = computed(() => {
</OptionRow> </OptionRow>
<OptionRow title="amfs"> <OptionRow title="amfs">
<FilePicker <FilePicker
field="amfs"
default=""
placeholder="amfs"
:directory="true" :directory="true"
placeholder="amfs"
:value="prf.current!.sgt.amfs"
:callback="(value: string) => (prf.current!.sgt.amfs = value)"
></FilePicker> ></FilePicker>
</OptionRow> </OptionRow>
<OptionRow title="option"> <OptionRow title="option">
<FilePicker <FilePicker
field="option"
default="option"
:directory="true" :directory="true"
placeholder="option"
:value="prf.current!.sgt.option"
:callback="(value: string) => (prf.current!.sgt.option = value)"
></FilePicker> ></FilePicker>
</OptionRow> </OptionRow>
<OptionRow title="appdata"> <OptionRow title="appdata">
<FilePicker <FilePicker
field="appdata"
default="appdata"
:directory="true" :directory="true"
:value="prf.current!.sgt.appdata"
:callback="
(value: string) => (prf.current!.sgt.appdata = value)
"
></FilePicker> ></FilePicker>
</OptionRow> </OptionRow>
</OptionCategory> </OptionCategory>
@ -123,7 +126,7 @@ const extraDisplayOptionsDisabled = computed(() => {
title="Target display" title="Target display"
> >
<Select <Select
:model-value="prf.cfg('display', 'default')" v-model="prf.current!.display.target"
:options="displayList" :options="displayList"
option-label="title" option-label="title"
option-value="value" option-value="value"
@ -136,7 +139,7 @@ const extraDisplayOptionsDisabled = computed(() => {
:min="480" :min="480"
:max="9999" :max="9999"
:use-grouping="false" :use-grouping="false"
:model-value="prf.cfgAny('rez-w', 1080)" v-model="prf.current!.display.rez[0]"
/> />
x x
<InputNumber <InputNumber
@ -145,17 +148,18 @@ const extraDisplayOptionsDisabled = computed(() => {
:min="640" :min="640"
:max="9999" :max="9999"
:use-grouping="false" :use-grouping="false"
:model-value="prf.cfgAny('rez-h', 1920)" v-model="prf.current!.display.rez[1]"
/> />
</OptionRow> </OptionRow>
<OptionRow title="Display mode"> <OptionRow title="Display mode">
<SelectButton <SelectButton
:model-value="prf.cfg('display-mode', 'borderless')" v-model="prf.current!.display.mode"
:options="[ :options="[
{ title: 'Window', value: 'window' }, { title: 'Window', value: 'Window' },
{ title: 'Borderless window', value: 'borderless' }, { title: 'Borderless window', value: 'Borderless' },
{ title: 'Fullscreen', value: 'fullscreen' }, { title: 'Fullscreen', value: 'Fullscreen' },
]" ]"
:allow-empty="false"
option-label="title" option-label="title"
option-value="value" option-value="value"
/> />
@ -165,12 +169,13 @@ const extraDisplayOptionsDisabled = computed(() => {
v-if="capabilities.includes('display')" v-if="capabilities.includes('display')"
> >
<SelectButton <SelectButton
:model-value="prf.cfg('display-rotation', 0)" v-model="prf.current!.display.rotation"
:options="[ :options="[
{ title: 'Unchanged', value: 0 }, { title: 'Unchanged', value: 0 },
{ title: 'Portrait', value: 90 }, { title: 'Portrait', value: 90 },
{ title: 'Portrait (flipped)', value: 270 }, { title: 'Portrait (flipped)', value: 270 },
]" ]"
:allow-empty="false"
option-label="title" option-label="title"
option-value="value" option-value="value"
:disabled="extraDisplayOptionsDisabled" :disabled="extraDisplayOptionsDisabled"
@ -183,7 +188,7 @@ const extraDisplayOptionsDisabled = computed(() => {
:min="60" :min="60"
:max="999" :max="999"
:use-grouping="false" :use-grouping="false"
:model-value="prf.cfgAny('frequency', 60)" v-model="prf.current!.display.frequency"
:disabled="extraDisplayOptionsDisabled" :disabled="extraDisplayOptionsDisabled"
/> />
</OptionRow> </OptionRow>
@ -191,32 +196,69 @@ const extraDisplayOptionsDisabled = computed(() => {
title="Match display resolution with the game" title="Match display resolution with the game"
v-if="capabilities.includes('display')" v-if="capabilities.includes('display')"
> >
<!-- @vue-expect-error -->
<ToggleSwitch <ToggleSwitch
:disabled=" :disabled="
extraDisplayOptionsDisabled || extraDisplayOptionsDisabled ||
prf.cfg('display-mode', 'borderless').value != 'borderless' prf.current?.display.mode !== 'Borderless'
" "
:model-value="prf.cfg('borderless-fullscreen', false)" v-model="prf.current!.display.borderless_fullscreen"
/> />
</OptionRow> </OptionRow>
</OptionCategory> </OptionCategory>
<OptionCategory title="Network"> <OptionCategory title="Network">
<OptionRow title="Server address"> <OptionRow title="Network type">
<SelectButton
v-model="prf.current!.network.network_type"
:options="[
{ title: 'Remote', value: 'Remote' },
{ title: 'Local (ARTEMiS)', value: 'Artemis' },
]"
:allow-empty="false"
option-label="title"
option-value="value"
/>
</OptionRow>
<OptionRow
v-if="prf.current!.network.network_type == 'Artemis'"
title="ARTEMiS path"
>
<FilePicker
:directory="false"
promptname="index.py"
extension="py"
:value="prf.current!.network.local_path"
:callback="
(value: string) => (prf.current!.network.local_path = value)
"
></FilePicker>
</OptionRow>
<OptionRow
v-if="prf.current!.network.network_type == 'Artemis'"
title="ARTEMiS console"
>
<ToggleSwitch v-model="prf.current!.network.local_console" />
</OptionRow>
<OptionRow
v-if="prf.current!.network.network_type == 'Remote'"
title="Server address"
>
<InputText <InputText
class="shrink" class="shrink"
size="small" size="small"
:maxlength="40" :maxlength="40"
placeholder="192.168.1.234" placeholder="192.168.1.234"
:model-value="prf.cfgAny<string>('dns-default', '')" v-model="prf.current!.network.remote_address"
/> </OptionRow /> </OptionRow
><OptionRow title="Keychip"> ><OptionRow
v-if="prf.current!.network.network_type == 'Remote'"
title="Keychip"
>
<InputText <InputText
class="shrink" class="shrink"
size="small" size="small"
:maxlength="16" :maxlength="16"
placeholder="A123-01234567890" placeholder="A123-01234567890"
:model-value="prf.cfgAny('keychip', '')" v-model="prf.current!.network.keychip"
/> </OptionRow /> </OptionRow
><OptionRow title="Subnet"> ><OptionRow title="Subnet">
<InputText <InputText
@ -224,33 +266,33 @@ const extraDisplayOptionsDisabled = computed(() => {
size="small" size="small"
:maxlength="15" :maxlength="15"
placeholder="192.168.1.0" placeholder="192.168.1.0"
:model-value="prf.cfgAny('subnet', '')" v-model="prf.current!.network.subnet"
/> />
</OptionRow> </OptionRow>
<OptionRow title="Address suffix"> <OptionRow title="Address suffix">
<InputText <InputNumber
class="shrink" class="shrink"
size="small" size="small"
:maxlength="3" :maxlength="3"
:min="0"
:max="255"
placeholder="12" placeholder="12"
:model-value="prf.cfgAny('addrsuffix', '')" v-model="prf.current!.network.suffix"
/> />
</OptionRow> </OptionRow>
</OptionCategory> </OptionCategory>
<OptionCategory title="Misc"> <OptionCategory title="Misc">
<OptionRow title="OpenSSL bug workaround for Intel ≥10th gen"> <OptionRow title="OpenSSL bug workaround for Intel ≥10th gen">
<!-- @vue-expect-error --> <ToggleSwitch v-model="prf.current!.sgt.intel" />
<ToggleSwitch :model-value="prf.cfg('intel', false)" />
</OptionRow> </OptionRow>
<OptionRow title="Aime emulation"> <OptionRow title="Aime emulation">
<!-- @vue-expect-error --> <ToggleSwitch v-model="prf.current!.sgt.enable_aime" />
<ToggleSwitch :model-value="prf.cfg('aime', false)" />
</OptionRow> </OptionRow>
<OptionRow title="Aime code"> <OptionRow title="Aime code">
<InputText <InputText
class="shrink" class="shrink"
size="small" size="small"
:disabled="prf.cfg<boolean>('aime', false).value !== true" :disabled="prf.current?.sgt.enable_aime !== true"
:maxlength="20" :maxlength="20"
placeholder="00000000000000000000" placeholder="00000000000000000000"
v-model="aimeCodeModel" v-model="aimeCodeModel"
@ -268,6 +310,9 @@ const extraDisplayOptionsDisabled = computed(() => {
extension="cfg" extension="cfg"
/> />
</OptionRow> </OptionRow>
<OptionRow title="BepInEx console">
<ToggleSwitch v-model="prf.current!.bepinex.console" />
</OptionRow>
</OptionCategory> </OptionCategory>
</template> </template>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import Button from 'primevue/button'; import Button from 'primevue/button';
import ProfileListEntry from './ProfileListEntry.vue';
import { usePrfStore } from '../stores'; import { usePrfStore } from '../stores';
const prf = usePrfStore(); const prf = usePrfStore();
@ -18,70 +19,25 @@ const prf = usePrfStore();
icon="pi pi-plus" icon="pi pi-plus"
class="chunithm-button profile-button" class="chunithm-button profile-button"
@click="() => prf.create('chunithm')" @click="() => prf.create('chunithm')"
:disabled="true"
/> />
</div> </div>
<div class="mt-12 flex flex-col flex-wrap align-middle gap-4"> <div class="mt-12 flex flex-col flex-wrap align-middle gap-4">
<div v-for="p in prf.list"> <div v-for="p in prf.list">
<div class="flex flex-row flex-wrap align-middle gap-2"> <ProfileListEntry :p="p" />
<Button
:disabled="
prf.current?.game === p.game &&
prf.current?.name === p.name
"
:label="p.name"
:class="
(p.game === 'chunithm'
? 'chunithm-button'
: 'ongeki-button') +
' ' +
'self-center profile-button'
"
@click="prf.switchTo(p.game, p.name)"
/>
<Button
rounded
icon="pi pi-trash"
severity="danger"
aria-label="remove"
size="small"
class="self-center ml-2"
style="width: 2rem; height: 2rem"
:disabled="true"
/>
<Button
rounded
icon="pi pi-clone"
severity="warn"
aria-label="duplicate"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
:disabled="true"
/>
<Button
rounded
icon="pi pi-pencil"
severity="help"
aria-label="duplicate"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
:disabled="true"
/>
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style>
.profile-button { .profile-button {
width: 14em; width: 14em;
white-space: nowrap; white-space: nowrap;
} }
.ongeki-button { .ongeki-button {
background-color: var(--p-pink-400); background-color: var(--p-pink-400) !important;
border-color: var(--p-pink-400); border-color: var(--p-pink-400) !important;
} }
.ongeki-button:hover, .ongeki-button:hover,
@ -91,8 +47,8 @@ const prf = usePrfStore();
} }
.chunithm-button { .chunithm-button {
background-color: var(--p-yellow-400); background-color: var(--p-yellow-400) !important;
border-color: var(--p-yellow-400); border-color: var(--p-yellow-400) !important;
} }
.chunithm-button:hover, .chunithm-button:hover,
.chunithm-button:active { .chunithm-button:active {

View File

@ -0,0 +1,113 @@
<script setup lang="ts">
import { ref } from 'vue';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import * as path from '@tauri-apps/api/path';
import { open } from '@tauri-apps/plugin-shell';
import { useGeneralStore, usePrfStore } from '../stores';
import { ProfileMeta } from '../types';
const prf = usePrfStore();
const general = useGeneralStore();
const isEditing = ref(false);
const props = defineProps({
p: Object as () => ProfileMeta,
});
if (props.p === undefined) {
throw new Error('Invalid ProfileListEntry');
}
const rename = async (event: KeyboardEvent) => {
if (event.key !== 'Enter') {
return;
}
isEditing.value = false;
if (
event.target !== null &&
'value' in event.target &&
typeof event.target.value === 'string'
) {
const value = event.target.value
.replaceAll('..', '')
.replaceAll('\\', '')
.replaceAll('/', '');
if (value.length > 0) {
await prf.rename(props.p!, value);
}
}
};
</script>
<template>
<div class="flex flex-row flex-wrap align-middle gap-2">
<Button
:disabled="
prf.current?.game === p!.game && prf.current?.name === p!.name
"
:class="
(p!.game === 'chunithm' ? 'chunithm-button' : 'ongeki-button') +
' ' +
'self-center profile-button'
"
@click="prf.switchTo(p!.game, p!.name)"
>
<div v-if="!isEditing">{{ p!.name }}</div>
<div v-else>
<InputText
:model-value="p!.name"
@vue:mounted="$event?.el?.focus()"
@keyup="rename"
@focusout="isEditing = false"
>
</InputText></div
></Button>
<Button
rounded
icon="pi pi-trash"
severity="danger"
aria-label="remove"
size="small"
class="self-center ml-2"
style="width: 2rem; height: 2rem"
:disabled="true"
/>
<Button
rounded
icon="pi pi-clone"
severity="help"
aria-label="duplicate"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
:disabled="true"
/>
<Button
rounded
icon="pi pi-pencil"
severity="help"
aria-label="rename"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
@click="isEditing = true"
/>
<Button
rounded
icon="pi pi-folder"
severity="help"
aria-label="open-directory"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
@click="
path
.join(general.dataDir, `profile-${p!.game}-${p!.name}`)
.then(open)
"
/>
</div>
</template>

View File

@ -25,10 +25,10 @@ const kill = async () => {
}; };
const disabledTooltip = computed(() => { const disabledTooltip = computed(() => {
if (prf.cfg('target-path', '').value.length === 0) { if (prf.current?.sgt.target.length === 0) {
return 'The game path must be specified'; return 'The game path must be specified';
} }
if (prf.cfg('amfs', '').value.length === 0) { if (prf.current?.sgt.amfs.length === 0) {
return 'The amfs path must be specified'; return 'The amfs path must be specified';
} }
return null; return null;

View File

@ -1,4 +1,4 @@
import { Ref, computed, ref } from 'vue'; import { Ref, computed, ref, watchEffect } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path'; import * as path from '@tauri-apps/api/path';
@ -110,43 +110,25 @@ export const usePrfStore = defineStore('prf', () => {
() => () =>
pkg !== undefined && pkg !== undefined &&
current.value !== null && current.value !== null &&
current.value?.data.mods.includes(pkgKey(pkg)) current.value?.mods.includes(pkgKey(pkg))
); );
const reload = async () => { const reload = async () => {
current.value = await invoke('get_current_profile'); const p: any = await invoke('get_current_profile');
if (p['OngekiProfile'] !== undefined) {
current.value = { ...p.OngekiProfile, game: 'ongeki' };
}
if (current.value !== null) { if (current.value !== null) {
changePrimaryColor(current.value.game); changePrimaryColor(current.value.game);
} }
}; };
const save = async () => {
await invoke('save_current_profile');
};
const cfg = <T extends string | boolean | number>(key: string, dflt: T) =>
computed({
get() {
return (current.value?.data.cfg[key] as T | undefined) ?? dflt;
},
async set(value) {
if (value !== undefined) {
await invoke('set_cfg', { key, value: value });
await reload();
await save();
}
},
});
// Hack around PrimeVu not supporting WritableComputedRef
const cfgAny = <T extends string | boolean | number>(
key: string,
dflt: T
) => cfg(key, dflt) as any;
const create = async (game: Game) => { const create = async (game: Game) => {
try { try {
await invoke('init_profile', { game, name: 'new-profile' }); await invoke('init_profile', {
game,
name: 'new-profile',
});
await reload(); await reload();
await reloadList(); await reloadList();
} catch (e) { } catch (e) {
@ -159,6 +141,22 @@ export const usePrfStore = defineStore('prf', () => {
} }
}; };
const rename = async (profile: ProfileMeta, name: string) => {
await invoke('rename_profile', {
profile,
name,
});
if (
current.value?.game === profile.game &&
current.value.name === profile.name
) {
current.value.name = name;
}
await reloadList();
};
const switchTo = async (game: Game, name: string) => { const switchTo = async (game: Game, name: string) => {
await invoke('load_profile', { game, name }); await invoke('load_profile', { game, name });
await reload(); await reload();
@ -169,14 +167,9 @@ export const usePrfStore = defineStore('prf', () => {
}; };
const reloadList = async () => { const reloadList = async () => {
const raw = (await invoke('list_profiles')) as [Game, string][]; // list.value.splice(0, list.value.length);
list.value = (await invoke('list_profiles')) as ProfileMeta[];
list.value = raw.map(([game, name]) => { console.log(list.value);
return {
game,
name,
};
});
}; };
const togglePkg = async (pkg: Package | undefined, enable: boolean) => { const togglePkg = async (pkg: Package | undefined, enable: boolean) => {
@ -185,7 +178,6 @@ export const usePrfStore = defineStore('prf', () => {
} }
await invoke('toggle_package', { key: pkgKey(pkg), enable }); await invoke('toggle_package', { key: pkgKey(pkg), enable });
await reload(); await reload();
await save();
}; };
const generalStore = useGeneralStore(); const generalStore = useGeneralStore();
@ -201,15 +193,21 @@ export const usePrfStore = defineStore('prf', () => {
await reload(); await reload();
}); });
watchEffect(async () => {
if (current.value !== null) {
await invoke('save_current_profile', {
profile: { OngekiProfile: current.value },
});
}
});
return { return {
current, current,
list, list,
isPkgEnabled, isPkgEnabled,
reload, reload,
save,
cfg,
cfgAny,
create, create,
rename,
switchTo, switchTo,
reloadList, reloadList,
togglePkg, togglePkg,

View File

@ -26,13 +26,47 @@ export interface ProfileMeta {
name: string; name: string;
} }
export interface Profile extends ProfileMeta { export interface SegatoolsConfig {
data: { target: string;
mods: string[]; amfs: string;
cfg: { [key: string]: string | boolean | number }; option: string;
}; appdata: string;
enable_aime: boolean;
intel: boolean;
} }
export interface DisplayConfig {
target: String;
rez: [number, number];
mode: 'Window' | 'Borderless' | 'Fullscreen';
rotation: number;
frequency: number;
borderless_fullscreen: boolean;
}
export interface NetworkConfig {
network_type: 'Remote' | 'Artemis';
local_path: string;
local_console: boolean;
remote_address: string;
keychip: string;
subnet: string;
suffix: number | null;
}
export interface BepInExConfig {
console: boolean;
}
export interface Profile extends ProfileMeta {
mods: string[];
sgt: SegatoolsConfig;
display: DisplayConfig;
network: NetworkConfig;
bepinex: BepInExConfig;
}
export type Module = 'sgt' | 'display' | 'network';
export interface Dirs { export interface Dirs {
config_dir: string; config_dir: string;
data_dir: string; data_dir: string;