feat: more breaking changes

This commit is contained in:
2025-03-06 20:38:18 +00:00
parent 39ba6a5891
commit cda8230d7d
19 changed files with 638 additions and 559 deletions

562
rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -46,4 +46,4 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
winsafe = { version = "0.0.23", features = ["user"] } winsafe = { version = "0.0.23", features = ["user"] }
displayz = "0.1.0" displayz = "^0.2.0"

View File

@ -14,56 +14,51 @@ pub struct GlobalConfig {
pub struct AppData { pub struct AppData {
pub profile: Option<Profile>, pub profile: Option<Profile>,
pub pkgs: PackageStore, pub pkgs: PackageStore,
pub cfg: GlobalConfig pub cfg: GlobalConfig,
} }
impl AppData { impl AppData {
pub fn new(app: AppHandle) -> AppData { pub fn new(apph: AppHandle) -> AppData {
let path = util::get_dirs() let cfg = std::fs::read_to_string(util::config_dir().join("config.json"))
.config_dir()
.join("config.json");
let cfg = std::fs::read_to_string(&path)
.and_then(|s| Ok(serde_json::from_str::<GlobalConfig>(&s)?)) .and_then(|s| Ok(serde_json::from_str::<GlobalConfig>(&s)?))
.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, name).ok(), Some((ref game, ref name)) => Profile::load(game.clone(), name.clone()).ok(),
None => None None => None
}; };
AppData { AppData {
profile, profile,
pkgs: PackageStore::new(app), pkgs: PackageStore::new(apph.clone()),
cfg cfg,
} }
} }
pub fn write(&self) -> Result<(), std::io::Error> { pub fn write(&self) -> Result<(), std::io::Error> {
let path = util::get_dirs() std::fs::write(util::config_dir().join("config.json"), serde_json::to_string(&self.cfg)?)
.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<()> { pub fn switch_profile(&mut self, game: Game, name: String) -> Result<()> {
self.profile = Profile::load(game, name).ok(); match Profile::load(game.clone(), name.clone()) {
if self.profile.is_some() { Ok(profile) => {
self.cfg.recent_profile = Some((game.to_owned(), name.to_owned())); self.profile = Some(profile);
} else { self.cfg.recent_profile = Some((game, name));
self.cfg.recent_profile = None; self.write()?;
Ok(())
}
Err(e) => {
self.profile = None;
self.cfg.recent_profile = None;
Err(e)
}
} }
self.write()?;
Ok(())
} }
pub fn toggle_package(&mut self, key: PkgKey, enable: bool) -> Result<()> { pub fn toggle_package(&mut self, key: PkgKey, enable: bool) -> Result<()> {
log::debug!("toggle: {} {}", key, enable); log::debug!("toggle: {} {}", key, enable);
let profile = self.profile.as_mut() let profile = self.profile.as_mut().ok_or_else(|| anyhow!("No profile"))?;
.ok_or_else(|| anyhow!("No profile"))?;
if enable { if enable {
let pkg = self.pkgs.get(&key)?; let pkg = self.pkgs.get(&key)?;

View File

@ -1,6 +1,5 @@
use log; use log;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::fs; use tokio::fs;
@ -9,7 +8,7 @@ use crate::pkg::{Package, PkgKey};
use crate::pkg_store::InstallResult; use crate::pkg_store::InstallResult;
use crate::profile::Profile; use crate::profile::Profile;
use crate::appdata::AppData; use crate::appdata::AppData;
use crate::{liner, start}; use crate::{liner, start, util};
use tauri::{AppHandle, Manager, State}; use tauri::{AppHandle, Manager, State};
@ -121,7 +120,7 @@ pub async fn load_profile(state: State<'_, Mutex<AppData>>, game: Game, name: St
log::debug!("invoke: load_profile({} {:?})", game, name); log::debug!("invoke: load_profile({} {:?})", game, name);
let mut appd = state.lock().await; let mut appd = state.lock().await;
appd.switch_profile(&game, &name).map_err(|e| e.to_string())?; appd.switch_profile(game, name).map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
@ -133,17 +132,6 @@ pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Opt
Ok(appd.profile.clone()) Ok(appd.profile.clone())
} }
#[tauri::command]
pub async fn get_current_profile_dir(state: State<'_, Mutex<AppData>>) -> Result<PathBuf, &str> {
let appd = state.lock().await;
if let Some(p) = &appd.profile {
Ok(p.dir())
} else {
Err("No profile loaded")
}
}
#[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>>) -> Result<(), ()> {
log::debug!("invoke: save_current_profile"); log::debug!("invoke: save_current_profile");
@ -161,54 +149,23 @@ pub async fn save_current_profile(state: State<'_, Mutex<AppData>>) -> Result<()
#[tauri::command] #[tauri::command]
pub async fn init_profile( pub async fn init_profile(
state: State<'_, Mutex<AppData>>, state: State<'_, Mutex<AppData>>,
exe_path: PathBuf game: Game,
name: String
) -> Result<Profile, String> { ) -> Result<Profile, String> {
log::debug!("invoke: init_profile({:?})", exe_path); log::debug!("invoke: init_profile({}, {})", game, name);
let mut appd = state.lock().await; let mut appd = state.lock().await;
if let Some(new_profile) = Profile::new(exe_path) { let new_profile = Profile::new(game, name);
new_profile.save().await;
appd.profile = Some(new_profile.clone());
fs::create_dir_all(new_profile.dir()).await
.map_err(|e| format!("Unable to create the profile directory: {}", e))?;
Ok(new_profile) fs::create_dir_all(new_profile.config_dir()).await
} else { .map_err(|e| format!("Unable to create the profile config directory: {}", e))?;
Err("Unrecognized game".to_owned()) fs::create_dir_all(new_profile.data_dir()).await
} .map_err(|e| format!("Unable to create the profile data directory: {}", e))?;
}
#[tauri::command] appd.profile = Some(new_profile.clone());
pub async fn read_profile_data( new_profile.save().await;
state: State<'_, Mutex<AppData>>,
path: PathBuf
) -> Result<String, String> {
let appd = state.lock().await;
if let Some(p) = &appd.profile { Ok(new_profile)
let res = fs::read_to_string(p.dir().join(&path)).await
.map_err(|e| format!("Unable to open {:?}: {}", path, e))?;
Ok(res)
} else {
Err("No profile loaded".to_owned())
}
}
#[tauri::command]
pub async fn write_profile_data(
state: State<'_, Mutex<AppData>>,
path: PathBuf,
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))?;
Ok(())
} else {
Err("No profile loaded".to_owned())
}
} }
#[tauri::command] #[tauri::command]
@ -266,3 +223,10 @@ pub async fn list_displays() -> Result<Vec<String>, ()> {
Ok(Vec::new()) Ok(Vec::new())
} }
#[tauri::command]
pub async fn list_directories() -> Result<util::Dirs, ()> {
log::debug!("invoke: list_directores");
Ok(util::all_dirs().clone())
}

View File

@ -17,7 +17,7 @@ pub async fn prepare_display(_: &Profile) -> Result<()> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub async fn prepare_display(p: &Profile) -> Result<Option<DisplayInfo>> { pub async fn prepare_display(p: &Profile) -> Result<Option<DisplayInfo>> {
use anyhow::anyhow; use anyhow::anyhow;
use displayz::{query_displays, Orientation, Resolution}; use displayz::{query_displays, Orientation, Resolution, Frequency};
let display_name = p.get_str("display", "default"); let display_name = p.get_str("display", "default");
let rotation = p.get_int("display-rotation", 0); let rotation = p.get_int("display-rotation", 0);
@ -57,11 +57,22 @@ pub async fn prepare_display(p: &Profile) -> Result<Option<DisplayInfo>> {
} }
} }
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) { if p.get_str("display-mode", "borderless") == "borderless" && p.get_bool("borderless-fullscreen", false) {
settings.borrow_mut().resolution = Resolution::new( settings.borrow_mut().resolution = Resolution::new(width, height);
p.get_int("rez-w", 1080).try_into().expect("Negative resolution"),
p.get_int("rez-h", 1920).try_into().expect("Negative resolution")
);
} }
display_set.apply()?; display_set.apply()?;
@ -74,6 +85,7 @@ pub async fn prepare_display(p: &Profile) -> Result<Option<DisplayInfo>> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
pub async fn undo_display(info: DisplayInfo) -> Result<()> { pub async fn undo_display(info: DisplayInfo) -> Result<()> {
use anyhow::anyhow;
use displayz::query_displays; use displayz::query_displays;
let display_set = query_displays()?; let display_set = query_displays()?;

View File

@ -31,12 +31,6 @@ pub async fn run(_args: Vec<String>) {
.unwrap_or_default() .unwrap_or_default()
); );
try_join!(
fs::create_dir_all(util::config_dir()),
fs::create_dir_all(util::pkg_dir()),
fs::create_dir_all(util::cache_dir())
).expect("Unable to create working directories");
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
let _ = app let _ = app
@ -74,12 +68,29 @@ pub async fn run(_args: Vec<String>) {
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.setup(|app| { .setup(|app| {
let apph = app.handle();
util::init_dirs(&apph);
let app_data = AppData::new(app.handle().clone()); let app_data = AppData::new(app.handle().clone());
app.manage(Mutex::new(app_data)); app.manage(Mutex::new(app_data));
app.deep_link().register_all()?; app.deep_link().register_all()?;
let apph = app.handle(); log::debug!("\n{:?}\n{:?}\n{:?}", util::config_dir(), util::pkg_dir(), util::cache_dir());
tauri::async_runtime::spawn(async {
let e = try_join!(
fs::create_dir_all(util::config_dir()),
fs::create_dir_all(util::pkg_dir()),
fs::create_dir_all(util::cache_dir())
);
if let Err(e) = e {
log::error!("Unable to create base directories: {}", e);
std::process::exit(1);
}
});
app.listen("download-end", closure!(clone apph, |ev| { app.listen("download-end", closure!(clone apph, |ev| {
let raw = ev.payload(); let raw = ev.payload();
@ -107,17 +118,15 @@ pub async fn run(_args: Vec<String>) {
cmd::init_profile, cmd::init_profile,
cmd::load_profile, cmd::load_profile,
cmd::get_current_profile, cmd::get_current_profile,
cmd::get_current_profile_dir,
cmd::save_current_profile, cmd::save_current_profile,
cmd::read_profile_data, cmd::set_cfg,
cmd::write_profile_data,
cmd::startline, cmd::startline,
cmd::kill, cmd::kill,
cmd::list_platform_capabilities,
cmd::set_cfg,
cmd::list_displays, cmd::list_displays,
cmd::list_platform_capabilities,
cmd::list_directories,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@ -17,7 +17,7 @@ async fn symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Resul
} }
pub async fn line_up(p: &Profile, pkg_hash: String) -> Result<()> { pub async fn line_up(p: &Profile, pkg_hash: String) -> Result<()> {
let dir_out = p.dir(); let dir_out = p.data_dir();
if dir_out.join("option").exists() { if dir_out.join("option").exists() {
fs::remove_dir_all(dir_out.join("option")).await?; fs::remove_dir_all(dir_out.join("option")).await?;
@ -25,7 +25,7 @@ pub async fn line_up(p: &Profile, pkg_hash: String) -> Result<()> {
fs::create_dir_all(dir_out.join("option")).await?; fs::create_dir_all(dir_out.join("option")).await?;
let hash_path = p.dir().join(".sl-state"); let hash_path = p.data_dir().join(".sl-state");
let prev_hash = fs::read_to_string(&hash_path).await.unwrap_or_default(); let prev_hash = fs::read_to_string(&hash_path).await.unwrap_or_default();
if prev_hash != pkg_hash { if prev_hash != pkg_hash {
log::debug!("state {} -> {}", prev_hash, pkg_hash); log::debug!("state {} -> {}", prev_hash, pkg_hash);
@ -40,7 +40,7 @@ pub async fn line_up(p: &Profile, pkg_hash: String) -> Result<()> {
} }
async fn prepare_packages(p: &Profile) -> Result<()> { async fn prepare_packages(p: &Profile) -> Result<()> {
let dir_out = p.dir(); let dir_out = p.data_dir();
if dir_out.join("BepInEx").exists() { if dir_out.join("BepInEx").exists() {
fs::remove_dir_all(dir_out.join("BepInEx")).await?; fs::remove_dir_all(dir_out.join("BepInEx")).await?;
@ -71,25 +71,26 @@ async fn prepare_packages(p: &Profile) -> Result<()> {
} }
async fn prepare_config(p: &Profile) -> Result<()> { async fn prepare_config(p: &Profile) -> Result<()> {
let dir_out = p.dir(); let dir_out = p.data_dir();
let ini_in_raw = fs::read_to_string(p.data.exe_dir.join("segatools.ini")).await?; 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 ini_in = Ini::load_from_str(&ini_in_raw)?;
let mut opt_dir_in = PathBuf::from( let mut opt_dir_in = PathBuf::from(p.get_str("option", ""));
ini_in.section(Some("vfs")) if opt_dir_in.as_os_str().len() > 0 && opt_dir_in.is_relative() {
.ok_or_else(|| anyhow!("No VFS section in segatools.ini"))? opt_dir_in = exe_dir.join(opt_dir_in);
.get("option")
.ok_or_else(|| anyhow!("No option specified in segatools.ini"))?
);
if opt_dir_in.is_relative() {
opt_dir_in = p.data.exe_dir.join(opt_dir_in);
} }
let opt_dir_out = &dir_out.join("option"); let opt_dir_out = &dir_out.join("option");
let mut ini_out = ini_in.clone(); let mut ini_out = ini_in.clone();
ini_out.with_section(Some("vfs")).set( ini_out.with_section(Some("vfs"))
"option", .set(
util::path_to_str(opt_dir_out)? "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")) ini_out.with_section(Some("unity"))
.set("enable", "1") .set("enable", "1")
@ -107,9 +108,11 @@ async fn prepare_config(p: &Profile) -> Result<()> {
ini_out.write_to_file(dir_out.join("segatools.ini"))?; ini_out.write_to_file(dir_out.join("segatools.ini"))?;
log::debug!("Option dir: {:?} -> {:?}", opt_dir_in, opt_dir_out); log::debug!("Option dir: {:?} -> {:?}", opt_dir_in, opt_dir_out);
for opt in opt_dir_in.read_dir()? { if opt_dir_in.as_os_str().len() > 0 {
let opt = opt?; for opt in opt_dir_in.read_dir()? {
symlink(&opt.path(), opt_dir_out.join(opt.file_name())).await?; let opt = opt?;
symlink(&opt.path(), opt_dir_out.join(opt.file_name())).await?;
}
} }
log::debug!("prepare config: done"); log::debug!("prepare config: done");

View File

@ -5,7 +5,6 @@ use crate::pkg::PkgKeyVersion;
// /c/{game}/api/v1/package // /c/{game}/api/v1/package
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)]
pub struct V1Package { pub struct V1Package {
pub owner: String, pub owner: String,
pub package_url: String, pub package_url: String,
@ -14,7 +13,6 @@ pub struct V1Package {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)]
pub struct V1Version { pub struct V1Version {
pub name: String, pub name: String,
pub description: String, pub description: String,

View File

@ -14,73 +14,67 @@ pub struct Profile {
// The contents of profile-{game}-{name}.json // The contents of profile-{game}-{name}.json
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone)]
pub struct ProfileData { pub struct ProfileData {
pub exe_dir: PathBuf,
pub mods: BTreeSet<PkgKey>, pub mods: BTreeSet<PkgKey>,
pub wine_runtime: Option<PathBuf>,
pub wine_prefix: Option<PathBuf>,
// cfg is temporarily just a map to make iteration easier // cfg is temporarily just a map to make iteration easier
// eventually it should become strict // eventually it should become strict
pub cfg: BTreeMap<String, serde_json::Value> pub cfg: BTreeMap<String, serde_json::Value>
} }
impl Profile { impl Profile {
pub fn new(exe_path: PathBuf) -> Option<Profile> { pub fn new(game: Game, mut name: String) -> Profile {
let game; name = name.trim().replace(" ", "-");
if exe_path.ends_with("mu3.exe") {
game = misc::Game::Ongeki while Self::config_dir_f(&game, &name).exists() {
} else if exe_path.ends_with("chusanApp.exe") { name = format!("new-{}", name);
// game = misc::Game::Chunithm;
return None;
} else {
return None;
} }
Some(Profile { Profile {
name: format!("{}", "default"), name,
game, game,
data: ProfileData { data: ProfileData {
exe_dir: exe_path.parent().unwrap().to_owned(),
mods: BTreeSet::new(), mods: BTreeSet::new(),
wine_runtime: None,
wine_prefix: None,
cfg: BTreeMap::new() cfg: BTreeMap::new()
} }
}) }
} }
pub fn dir(&self) -> PathBuf { fn config_dir_f(game: &Game, name: &str) -> PathBuf {
util::get_dirs() util::config_dir().join(format!("profile-{}-{}", game, name))
.data_dir() }
.join(format!("profile-{}-{}", self.game, self.name))
.to_owned() 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)>> { pub async fn list() -> Result<Vec<(Game, String)>> {
let path = std::fs::read_dir( let path = std::fs::read_dir(util::config_dir())?;
util::get_dirs().config_dir()
)?;
let mut res = Vec::new(); let mut res = Vec::new();
for f in path { for f in path {
let f = f?; let f = f?;
if let Some(pair) = Self::name_from_path(f.path()) { if let Ok(meta) = f.metadata() {
res.push(pair); if !meta.is_dir() {
continue;
}
log::debug!("{:?}", f);
if let Some(pair) = Self::name_from_path(f.path()) {
res.push(pair);
}
} }
} }
Ok(res) Ok(res)
} }
pub fn load(game: &Game, name: &str) -> Result<Profile> { pub fn load(game: Game, name: String) -> Result<Profile> {
let path = util::get_dirs() let path = Self::config_dir_f(&game, &name).join("profile.json");
.config_dir()
.join(format!("profile-{}-{}.json", game, name));
if let Ok(s) = std::fs::read_to_string(&path) { 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) let data = serde_json::from_str::<ProfileData>(&s)
.map_err(|e| anyhow!("Unable to parse {:?}: {:?}", path, e))?; .map_err(|e| anyhow!("Unable to parse {:?}: {:?}", path, e))?;
@ -95,9 +89,7 @@ impl Profile {
} }
pub async fn save(&self) { pub async fn save(&self) {
let path = util::get_dirs() let path = self.config_dir().join("profile.json");
.config_dir()
.join(format!("profile-{}-{}.json", self.game, self.name));
let s = serde_json::to_string_pretty(&self.data).unwrap(); let s = serde_json::to_string_pretty(&self.data).unwrap();
fs::write(&path, s).await.unwrap(); fs::write(&path, s).await.unwrap();
@ -131,7 +123,7 @@ impl Profile {
fn name_from_path(path: impl AsRef<Path>) -> Option<(Game, String)> { fn name_from_path(path: impl AsRef<Path>) -> Option<(Game, String)> {
let regex = regex::Regex::new( let regex = regex::Regex::new(
r"profile-([^\-]+)-([^\-]+)\.json" r"^profile-([^\-]+)-(.+)$"
).expect("Invalid regex"); ).expect("Invalid regex");
let fname = path.as_ref().file_name().unwrap_or_default().to_string_lossy(); let fname = path.as_ref().file_name().unwrap_or_default().to_string_lossy();

View File

@ -1,5 +1,6 @@
use anyhow::Result; use anyhow::{anyhow, Result};
use std::fs::File; use std::fs::File;
use std::path::PathBuf;
use tokio::process::Command; use tokio::process::Command;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use std::process::Stdio; use std::process::Stdio;
@ -12,19 +13,22 @@ static CREATE_NO_WINDOW: u32 = 0x08000000;
pub async fn start(p: &Profile, app: AppHandle) -> Result<()> { pub async fn start(p: &Profile, app: AppHandle) -> Result<()> {
use tokio::task::JoinSet; use tokio::task::JoinSet;
let ini_path = p.dir().join("segatools.ini"); let ini_path = p.data_dir().join("segatools.ini");
log::debug!("With path {}", ini_path.to_string_lossy()); log::debug!("With path {}", ini_path.to_string_lossy());
let mut game_builder; let mut game_builder;
let mut amd_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")] #[cfg(target_os = "windows")]
let display_info = crate::display::prepare_display(p).await?; let display_info = crate::display::prepare_display(p).await?;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
game_builder = Command::new(p.data.exe_dir.join("inject.exe")); game_builder = Command::new(exe_dir.join("inject.exe"));
amd_builder = Command::new("cmd.exe"); amd_builder = Command::new("cmd.exe");
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@ -35,7 +39,7 @@ pub async fn start(p: &Profile, app: AppHandle) -> Result<()> {
game_builder = Command::new(&wine); game_builder = Command::new(&wine);
amd_builder = Command::new(&wine); amd_builder = Command::new(&wine);
game_builder.arg(p.data.exe_dir.join("inject.exe")); game_builder.arg(exe_dir.join("inject.exe"));
amd_builder.arg("cmd.exe"); amd_builder.arg("cmd.exe");
} }
@ -45,10 +49,10 @@ pub async fn start(p: &Profile, app: AppHandle) -> Result<()> {
"SEGATOOLS_CONFIG_PATH", "SEGATOOLS_CONFIG_PATH",
&ini_path, &ini_path,
) )
.current_dir(&p.data.exe_dir) .current_dir(&exe_dir)
.args([ .args([
"/C", "/C",
&util::path_to_str(p.data.exe_dir.join("inject.exe"))?, "-d", "-k", "mu3hook.dll", &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" "amdaemon.exe", "-f", "-c", "config_common.json", "config_server.json", "config_client.json"
]); ]);
game_builder game_builder
@ -58,9 +62,9 @@ pub async fn start(p: &Profile, app: AppHandle) -> Result<()> {
) )
.env( .env(
"INOHARA_CONFIG_PATH", "INOHARA_CONFIG_PATH",
p.dir().join("inohara.cfg"), p.config_dir().join("inohara.cfg"),
) )
.current_dir(&p.data.exe_dir) .current_dir(&exe_dir)
.args([ .args([
"-d", "-k", "mu3hook.dll", "-d", "-k", "mu3hook.dll",
"mu3.exe", "-monitor 1", "mu3.exe", "-monitor 1",
@ -86,8 +90,8 @@ pub async fn start(p: &Profile, app: AppHandle) -> Result<()> {
} }
let amd_log = File::create(p.dir().join("amdaemon.log"))?; let amd_log = File::create(p.data_dir().join("amdaemon.log"))?;
let game_log = File::create(p.dir().join("mu3.log"))?; let game_log = File::create(p.data_dir().join("mu3.log"))?;
amd_builder amd_builder
.stdout(Stdio::from(amd_log)); .stdout(Stdio::from(amd_log));

View File

@ -1,26 +1,63 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use directories::ProjectDirs; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager};
use std::{path::{Path, PathBuf}, sync::OnceLock};
pub fn get_dirs() -> ProjectDirs { #[cfg(not(target_os = "windows"))]
ProjectDirs::from("org", "7EVENDAYSHOLIDAYS", "STARTLINER") static NAME: &str = "startliner";
.expect("Unable to set up config directories")
#[cfg(target_os = "windows")]
static NAME: &str = "STARTLINER";
#[derive(Clone, Serialize, Deserialize)]
pub struct Dirs {
config_dir: PathBuf,
data_dir: PathBuf,
cache_dir: PathBuf,
} }
pub fn config_dir() -> PathBuf { static DIRS: OnceLock<Dirs> = OnceLock::new();
get_dirs().config_dir().to_owned()
pub fn init_dirs(apph: &AppHandle) {
DIRS.get_or_init(|| {
if cfg!(windows) {
Dirs {
config_dir: apph.path().data_dir().expect("Unable to set project directories").join(NAME).join("cfg"),
data_dir: apph.path().data_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),
}
} else {
Dirs {
config_dir: apph.path().config_dir().expect("Unable to set project directories").join(NAME),
data_dir: apph.path().data_dir().expect("Unable to set project directories").join(NAME),
cache_dir: apph.path().cache_dir().expect("Unable to set project directories").join(NAME),
}
}
});
}
pub fn all_dirs() -> &'static Dirs {
&DIRS.get().expect("Directories uninitialized")
}
pub fn config_dir() -> &'static Path {
&DIRS.get().expect("Directories uninitialized").config_dir
}
pub fn data_dir() -> &'static Path {
&DIRS.get().expect("Directories uninitialized").data_dir
}
pub fn cache_dir() -> &'static Path {
&DIRS.get().expect("Directories uninitialized").cache_dir
} }
pub fn pkg_dir() -> PathBuf { pub fn pkg_dir() -> PathBuf {
get_dirs().data_dir().join("pkg").to_owned() data_dir().join("pkg")
} }
pub fn pkg_dir_of(namespace: &str, name: &str) -> PathBuf { pub fn pkg_dir_of(namespace: &str, name: &str) -> PathBuf {
pkg_dir().join(format!("{}-{}", namespace, name)).to_owned() pkg_dir().join(format!("{}-{}", namespace, name))
}
pub fn cache_dir() -> PathBuf {
get_dirs().cache_dir().to_owned()
} }
pub fn path_to_str(p: impl AsRef<Path>) -> Result<String> { pub fn path_to_str(p: impl AsRef<Path>) -> Result<String> {

View File

@ -11,10 +11,13 @@ import ModStore from './ModStore.vue';
import OptionList from './OptionList.vue'; import OptionList from './OptionList.vue';
import ProfileList from './ProfileList.vue'; import ProfileList from './ProfileList.vue';
import StartButton from './StartButton.vue'; import StartButton from './StartButton.vue';
import { usePkgStore, usePrfStore } from '../stores'; import { invoke } from '../invoke';
import { useGeneralStore, usePkgStore, usePrfStore } from '../stores';
import { Dirs } from '../types';
const pkg = usePkgStore(); const pkg = usePkgStore();
const prf = usePrfStore(); const prf = usePrfStore();
const general = useGeneralStore();
pkg.setupListeners(); pkg.setupListeners();
@ -23,6 +26,10 @@ const currentTab = ref('3');
const isProfileDisabled = computed(() => prf.current === null); const isProfileDisabled = computed(() => prf.current === null);
onMounted(async () => { onMounted(async () => {
invoke('list_directories').then((d) => {
general.dirs = d as Dirs;
});
await prf.reloadList(); await prf.reloadList();
await prf.reload(); await prf.reload();
@ -65,27 +72,10 @@ onMounted(async () => {
<OptionList /> <OptionList />
</TabPanel> </TabPanel>
<TabPanel value="3"> <TabPanel value="3">
<strong>UNDER CONSTRUCTION</strong><br />Many features are <strong>UNDER CONSTRUCTION</strong><br />Some features are
missing.<br />Existing features are expected to break any missing.<br />Existing features are expected to break any
time. time.
<div v-if="isProfileDisabled">
<br />Select <code>mu3.exe</code> to create a profile:
</div>
<ProfileList /> <ProfileList />
<div v-if="isProfileDisabled">
<div
style="
margin-top: 5px;
font-weight: bolder;
font-size: 3em;
color: red;
line-height: 1.1em;
"
>
segatools 2024-12-23 or later has to be installed
</div>
(this will change in the future)
</div>
<img <img
v-if="prf.current?.game === 'ongeki'" v-if="prf.current?.game === 'ongeki'"
src="/sticker-ongeki.svg" src="/sticker-ongeki.svg"
@ -97,13 +87,23 @@ onMounted(async () => {
class="fixed bottom-0 right-0" class="fixed bottom-0 right-0"
/> />
<br /><br /><br /> <br /><br /><br />
<Button <footer
style="position: fixed; left: 10px; bottom: 10px" style="
icon="pi pi-discord" position: fixed;
as="a" left: 0px;
target="_blank" bottom: 0px;
href="https://discord.gg/jxvzHjjEmc" height: 40px;
/> width: 100%;
"
>
<Button
style="margin-left: 6px"
icon="pi pi-discord"
as="a"
target="_blank"
href="https://discord.gg/jxvzHjjEmc"
/>
</footer>
</TabPanel> </TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>

View File

@ -4,7 +4,9 @@ import Button from 'primevue/button';
import * as path from '@tauri-apps/api/path'; import * as path from '@tauri-apps/api/path';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'; import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { invoke } from '../invoke'; import { usePrfStore } from '../stores';
const prf = usePrfStore();
const props = defineProps({ const props = defineProps({
filename: String, filename: String,
@ -53,13 +55,10 @@ const filePick = async () => {
}; };
(async () => { (async () => {
const profileDir: string = await invoke('get_current_profile_dir');
if (props.filename === undefined) { if (props.filename === undefined) {
throw new Error('FileEditor without a filename'); throw new Error('FileEditor without a filename');
} }
target_path.value = await path.join(await prf.configDir, props.filename);
target_path.value = await path.join(profileDir, props.filename);
await load(target_path.value); await load(target_path.value);
})(); })();
</script> </script>

View File

@ -5,7 +5,8 @@ import InputText from 'primevue/inputtext';
import Select from 'primevue/select'; import Select from 'primevue/select';
import SelectButton from 'primevue/selectbutton'; import SelectButton from 'primevue/selectbutton';
import ToggleSwitch from 'primevue/toggleswitch'; import ToggleSwitch from 'primevue/toggleswitch';
import { invoke as unproxied_invoke } from '@tauri-apps/api/core'; import * as path from '@tauri-apps/api/path';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import FileEditor from './FileEditor.vue'; import FileEditor from './FileEditor.vue';
import FilePicker from './FilePicker.vue'; import FilePicker from './FilePicker.vue';
import OptionCategory from './OptionCategory.vue'; import OptionCategory from './OptionCategory.vue';
@ -32,20 +33,6 @@ const hookList: Ref<{ title: string; value: string }[]> = ref([
}, },
]); ]);
unproxied_invoke('read_profile_data', {
path: 'aime.txt',
})
.then((v: unknown) => {
if (typeof v === 'string') {
aimeCode.value = v;
} else {
aimeCode.value = '';
}
})
.catch(() => {
aimeCode.value = '';
});
invoke('list_platform_capabilities') invoke('list_platform_capabilities')
.then(async (v: unknown) => { .then(async (v: unknown) => {
if (Array.isArray(v)) { if (Array.isArray(v)) {
@ -72,26 +59,33 @@ const aimeCodeModel = computed({
async set(value: string) { async set(value: string) {
aimeCode.value = value; aimeCode.value = value;
if (value.match(/^[0-9]{20}$/)) { if (value.match(/^[0-9]{20}$/)) {
await invoke('write_profile_data', { const aime_path = await path.join(await prf.configDir, 'aime.txt');
path: 'aime.txt', await writeTextFile(aime_path, aimeCode.value);
content: aimeCode.value,
});
} }
}, },
}); });
const extraDisplayOptionsDisabled = computed(() => {
return prf.cfg('display', 'default').value === 'default';
});
(async () => {
const aime_path = await path.join(await prf.configDir, 'aime.txt');
aimeCode.value = await readTextFile(aime_path);
})();
</script> </script>
<template> <template>
<OptionCategory title="General"> <OptionCategory title="General">
<!-- <OptionRow title="mu3.exe"> <OptionRow title="mu3.exe">
<FilePicker <FilePicker
field="game-dir" field="target-path"
default="" default=""
:directory="false" :directory="false"
promptname="mu3.exe" promptname="mu3.exe"
extension="exe" extension="exe"
></FilePicker> ></FilePicker>
</OptionRow> --> </OptionRow>
<OptionRow title="mu3hook"> <OptionRow title="mu3hook">
<Select <Select
:model-value="prf.cfg('hook', 'segatools-mu3hook')" :model-value="prf.cfg('hook', 'segatools-mu3hook')"
@ -135,17 +129,14 @@ const aimeCodeModel = computed({
option-value="value" option-value="value"
></Select> ></Select>
</OptionRow> </OptionRow>
<OptionRow id="resolution" title="Game resolution"> <OptionRow class="number-input" title="Game resolution">
<InputNumber <InputNumber
class="shrink" class="shrink"
size="small" size="small"
:min="480" :min="480"
:max="9999" :max="9999"
:use-grouping="false" :use-grouping="false"
:model-value=" :model-value="prf.cfgAny('rez-w', 1080)"
// prettier-ignore Because primevue fucked up
prf.cfg('rez-w', 1080) as any
"
/> />
x x
<InputNumber <InputNumber
@ -154,10 +145,7 @@ const aimeCodeModel = computed({
:min="640" :min="640"
:max="9999" :max="9999"
:use-grouping="false" :use-grouping="false"
:model-value=" :model-value="prf.cfgAny('rez-h', 1920)"
// prettier-ignore
prf.cfg('rez-h', 1920) as any
"
/> />
</OptionRow> </OptionRow>
<OptionRow title="Display mode"> <OptionRow title="Display mode">
@ -185,7 +173,18 @@ const aimeCodeModel = computed({
]" ]"
option-label="title" option-label="title"
option-value="value" option-value="value"
:disabled="prf.cfg('display', 'default').value === 'default'" :disabled="extraDisplayOptionsDisabled"
/>
</OptionRow>
<OptionRow class="number-input" title="Refresh Rate">
<InputNumber
class="shrink"
size="small"
:min="60"
:max="999"
:use-grouping="false"
:model-value="prf.cfgAny('frequency', 60)"
:disabled="extraDisplayOptionsDisabled"
/> />
</OptionRow> </OptionRow>
<OptionRow <OptionRow
@ -195,7 +194,7 @@ const aimeCodeModel = computed({
<!-- @vue-expect-error --> <!-- @vue-expect-error -->
<ToggleSwitch <ToggleSwitch
:disabled=" :disabled="
prf.cfg('display', 'default').value === 'default' || extraDisplayOptionsDisabled ||
prf.cfg('display-mode', 'borderless').value != 'borderless' prf.cfg('display-mode', 'borderless').value != 'borderless'
" "
:model-value="prf.cfg('borderless-fullscreen', false)" :model-value="prf.cfg('borderless-fullscreen', false)"
@ -209,10 +208,7 @@ const aimeCodeModel = computed({
size="small" size="small"
:maxlength="40" :maxlength="40"
placeholder="192.168.1.234" placeholder="192.168.1.234"
:model-value=" :model-value="prf.cfgAny<string>('dns-default', '')"
// prettier-ignore
prf.cfg<string>('dns-default', '') as any
"
/> </OptionRow /> </OptionRow
><OptionRow title="Keychip"> ><OptionRow title="Keychip">
<InputText <InputText
@ -220,10 +216,7 @@ const aimeCodeModel = computed({
size="small" size="small"
:maxlength="16" :maxlength="16"
placeholder="A123-01234567890" placeholder="A123-01234567890"
:model-value=" :model-value="prf.cfgAny('keychip', '')"
// prettier-ignore
prf.cfg('keychip', '') as any
"
/> </OptionRow /> </OptionRow
><OptionRow title="Subnet"> ><OptionRow title="Subnet">
<InputText <InputText
@ -231,10 +224,7 @@ const aimeCodeModel = computed({
size="small" size="small"
:maxlength="15" :maxlength="15"
placeholder="192.168.1.0" placeholder="192.168.1.0"
:model-value=" :model-value="prf.cfgAny('subnet', '')"
// prettier-ignore
prf.cfg('subnet', '') as any
"
/> />
</OptionRow> </OptionRow>
</OptionCategory> </OptionCategory>
@ -271,7 +261,7 @@ const aimeCodeModel = computed({
</template> </template>
<style> <style>
#resolution .p-inputnumber-input { .number-input .p-inputnumber-input {
width: 4rem; width: 4rem;
} }

View File

@ -9,11 +9,17 @@ const prf = usePrfStore();
<div class="mt-4 flex flex-wrap align-middle gap-4"> <div class="mt-4 flex flex-wrap align-middle gap-4">
<Button <Button
:disabled="prf.list.length > 0" :disabled="prf.list.length > 0"
label="Create profile" label="O.N.G.E.K.I. profile"
icon="pi pi-plus" icon="pi pi-plus"
aria-label="open-executable" class="ongeki-button"
class="create-button" @click="() => prf.create('ongeki')"
@click="prf.prompt" />
<Button
:disabled="prf.list.length > 0"
label="CHUNITHM profile"
icon="pi pi-plus"
class="chunithm-button"
@click="() => prf.create('chunithm')"
/> />
<div v-for="p in prf.list"> <div v-for="p in prf.list">
@ -51,7 +57,7 @@ const prf = usePrfStore();
.ongeki-button { .ongeki-button {
background-color: var(--p-pink-400); background-color: var(--p-pink-400);
border-color: var(--p-pink-400); border-color: var(--p-pink-400);
width: 10em; width: 14em;
} }
.ongeki-button:hover, .ongeki-button:hover,
@ -63,7 +69,7 @@ const prf = usePrfStore();
.chunithm-button { .chunithm-button {
background-color: var(--p-yellow-400); background-color: var(--p-yellow-400);
border-color: var(--p-yellow-400); border-color: var(--p-yellow-400);
width: 10em; width: 14em;
} }
.chunithm-button:hover, .chunithm-button:hover,
.chunithm-button:active { .chunithm-button:active {

View File

@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Ref, ref } from 'vue'; import { Ref, computed, ref } from 'vue';
import Button from 'primevue/button'; import Button from 'primevue/button';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { invoke } from '../invoke'; import { invoke } from '../invoke';
import { usePrfStore } from '../stores';
const prf = usePrfStore();
type StartStatus = 'ready' | 'preparing' | 'running'; type StartStatus = 'ready' | 'preparing' | 'running';
const startStatus: Ref<StartStatus> = ref('ready'); const startStatus: Ref<StartStatus> = ref('ready');
@ -21,6 +24,16 @@ const kill = async () => {
startStatus.value = 'ready'; startStatus.value = 'ready';
}; };
const disabledTooltip = computed(() => {
if (prf.cfg('target-path', '').value.length === 0) {
return 'The game path must be specified';
}
if (prf.cfg('amfs', '').value.length === 0) {
return 'The amfs path must be specified';
}
return null;
});
listen('launch-start', () => { listen('launch-start', () => {
startStatus.value = 'running'; startStatus.value = 'running';
}); });
@ -33,7 +46,8 @@ listen('launch-end', () => {
<template> <template>
<Button <Button
v-if="startStatus === 'ready'" v-if="startStatus === 'ready'"
:disabled="false" v-tooltip="disabledTooltip"
:disabled="disabledTooltip !== null"
icon="pi pi-play" icon="pi pi-play"
label="START" label="START"
aria-label="start" aria-label="start"

View File

@ -3,6 +3,7 @@ import { createPinia } from 'pinia';
import { definePreset } from '@primevue/themes'; import { definePreset } from '@primevue/themes';
import Theme from '@primevue/themes/aura'; import Theme from '@primevue/themes/aura';
import PrimeVue from 'primevue/config'; import PrimeVue from 'primevue/config';
import Tooltip from 'primevue/tooltip';
import App from './components/App.vue'; import App from './components/App.vue';
const pinia = createPinia(); const pinia = createPinia();
@ -16,4 +17,5 @@ app.use(PrimeVue, {
preset: Preset, preset: Preset,
}, },
}); });
app.directive('tooltip', Tooltip);
app.mount('#app'); app.mount('#app');

View File

@ -1,15 +1,40 @@
import { Ref, computed, ref } from 'vue'; import { Ref, computed, ref } 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 { open } from '@tauri-apps/plugin-dialog'; import * as path from '@tauri-apps/api/path';
import { invoke } from './invoke'; import { invoke } from './invoke';
import { Game, Package, Profile, ProfileMeta } from './types'; import { Dirs, Game, Package, Profile, ProfileMeta } from './types';
import { changePrimaryColor, pkgKey } from './util'; import { changePrimaryColor, pkgKey } from './util';
type InstallStatus = { type InstallStatus = {
pkg: string; pkg: string;
}; };
export const useGeneralStore = defineStore('general', () => {
const dirs: Ref<Dirs | null> = ref(null);
const configDir = computed(() => {
if (dirs.value === null) {
throw new Error('Invalid directory access');
}
return dirs.value.config_dir;
});
const dataDir = computed(() => {
if (dirs.value === null) {
throw new Error('Invalid directory access');
}
return dirs.value.data_dir;
});
const cacheDir = computed(() => {
if (dirs.value === null) {
throw new Error('Invalid directory access');
}
return dirs.value.cache_dir;
});
return { dirs, configDir, dataDir, cacheDir };
});
export const usePkgStore = defineStore('pkg', { export const usePkgStore = defineStore('pkg', {
state: (): { pkg: { [key: string]: Package } } => { state: (): { pkg: { [key: string]: Package } } => {
return { return {
@ -113,25 +138,15 @@ export const usePrfStore = defineStore('prf', () => {
}, },
}); });
const prompt = async () => { // Hack around PrimeVu not supporting WritableComputedRef
const exePath = await open({ const cfgAny = <T extends string | boolean | number>(
multiple: false, key: string,
directory: false, dflt: T
filters: [ ) => cfg(key, dflt) as any;
{
name: 'mu3.exe or chusanApp.exe',
extensions: ['exe'],
},
],
});
if (exePath !== null) {
await create(exePath);
}
};
const create = async (exePath: string) => { const create = async (game: Game) => {
try { try {
await invoke('init_profile', { exePath }); await invoke('init_profile', { game, name: 'new-profile' });
await reload(); await reload();
await reloadList(); await reloadList();
} catch (e) { } catch (e) {
@ -173,6 +188,15 @@ export const usePrfStore = defineStore('prf', () => {
await save(); await save();
}; };
const generalStore = useGeneralStore();
const configDir = computed(async () => {
return await path.join(
generalStore.configDir,
`profile-${current.value?.game}-${current.value?.name}`
);
});
listen<InstallStatus>('install-end', async () => { listen<InstallStatus>('install-end', async () => {
await reload(); await reload();
}); });
@ -184,10 +208,11 @@ export const usePrfStore = defineStore('prf', () => {
reload, reload,
save, save,
cfg, cfg,
prompt, cfgAny,
create, create,
switchTo, switchTo,
reloadList, reloadList,
togglePkg, togglePkg,
configDir,
}; };
}); });

View File

@ -28,8 +28,13 @@ export interface ProfileMeta {
export interface Profile extends ProfileMeta { export interface Profile extends ProfileMeta {
data: { data: {
exe_dir: string;
mods: string[]; mods: string[];
cfg: { [key: string]: string | boolean | number }; cfg: { [key: string]: string | boolean | number };
}; };
} }
export interface Dirs {
config_dir: string;
data_dir: string;
cache_dir: string;
}