feat: groundwork for multi-profile support

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

298
public/sticker-chunithm.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 244 KiB

View File

@ -1,16 +1,64 @@
use std::hash::{DefaultHasher, Hash, Hasher};
use crate::pkg::PkgKey;
use crate::{model::misc::Game, pkg::PkgKey};
use crate::pkg_store::PackageStore;
use crate::Profile;
use crate::{util, Profile};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct GlobalConfig {
pub recent_profile: Option<(Game, String)>
}
pub struct AppData {
pub profile: Option<Profile>,
pub pkgs: PackageStore,
pub cfg: GlobalConfig
}
impl AppData {
pub fn new(app: AppHandle) -> AppData {
let path = util::get_dirs()
.config_dir()
.join("config.json");
let cfg = std::fs::read_to_string(&path)
.and_then(|s| Ok(serde_json::from_str::<GlobalConfig>(&s)?))
.unwrap_or_default();
let profile = match cfg.recent_profile {
Some((ref game, ref name)) => Profile::load(game, name).ok(),
None => None
};
AppData {
profile,
pkgs: PackageStore::new(app),
cfg
}
}
pub fn write(&self) -> Result<(), std::io::Error> {
let path = util::get_dirs()
.config_dir()
.join("config.json");
std::fs::write(&path, serde_json::to_string(&self.cfg)?)
}
pub fn switch_profile(&mut self, game: &Game, name: &str) -> Result<()> {
self.profile = Profile::load(game, name).ok();
if self.profile.is_some() {
self.cfg.recent_profile = Some((game.to_owned(), name.to_owned()));
} else {
self.cfg.recent_profile = None;
}
self.write()?;
Ok(())
}
pub fn toggle_package(&mut self, key: PkgKey, enable: bool) -> Result<()> {
log::debug!("toggle: {} {}", key, enable);
@ -22,12 +70,12 @@ impl AppData {
let loc = pkg.loc
.clone()
.ok_or_else(|| anyhow!("Attempted to enable a non-existent package"))?;
profile.mods.insert(key);
profile.data.mods.insert(key);
for d in &loc.dependencies {
_ = self.toggle_package(d.clone(), true);
}
} else {
profile.mods.remove(&key);
profile.data.mods.remove(&key);
for (ckey, pkg) in self.pkgs.get_all() {
if let Some(loc) = pkg.loc {
if loc.dependencies.contains(&key) {
@ -42,7 +90,7 @@ impl AppData {
pub fn sum_packages(&self, p: &Profile) -> String {
let mut hasher = DefaultHasher::new();
for pkg in &p.mods {
for pkg in &p.data.mods {
let x = self.pkgs.get(pkg).unwrap().loc.as_ref().unwrap();
pkg.hash(&mut hasher);
x.version.hash(&mut hasher);

View File

@ -4,6 +4,7 @@ use std::path::PathBuf;
use tokio::sync::Mutex;
use tokio::fs;
use crate::model::misc::Game;
use crate::pkg::{Package, PkgKey};
use crate::pkg_store::InstallResult;
use crate::profile::Profile;
@ -106,6 +107,23 @@ pub async fn fetch_listings(state: State<'_, Mutex<AppData>>) -> Result<(), Stri
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn list_profiles() -> Result<Vec<(Game, String)>, String> {
log::debug!("invoke: list_profiles");
let list = Profile::list().await.map_err(|e| e.to_string())?;
Ok(list)
}
#[tauri::command]
pub async fn load_profile(state: State<'_, Mutex<AppData>>, game: Game, name: String) -> Result<(), String> {
log::debug!("invoke: load_profile({} {:?})", game, name);
let mut appd = state.lock().await;
appd.switch_profile(&game, &name).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Option<Profile>, ()> {
log::debug!("invoke: get_current_profile");
@ -115,8 +133,8 @@ pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Opt
}
#[tauri::command]
pub async fn save_profile(state: State<'_, Mutex<AppData>>) -> Result<(), ()> {
log::debug!("invoke: save_profile");
pub async fn save_current_profile(state: State<'_, Mutex<AppData>>) -> Result<(), ()> {
log::debug!("invoke: save_current_profile");
let appd = state.lock().await;
if let Some(p) = &appd.profile {
@ -133,18 +151,17 @@ pub async fn init_profile(
state: State<'_, Mutex<AppData>>,
exe_path: PathBuf
) -> Result<Profile, String> {
log::debug!("invoke: init_profile({})", exe_path.to_string_lossy());
log::debug!("invoke: init_profile({:?})", exe_path);
let mut appd = state.lock().await;
let new_profile = Profile::new(exe_path);
if let Some(new_profile) = Profile::new(exe_path) {
new_profile.save().await;
appd.profile = Some(new_profile.clone());
new_profile.save().await;
appd.profile = Some(new_profile.clone());
fs::create_dir(new_profile.dir()).await
.map_err(|e| format!("Unable to create profile directory: {}", e))?;
Ok(new_profile)
Ok(new_profile)
} else {
Err("Unrecognized game".to_owned())
}
}
// #[tauri::command]
@ -184,7 +201,7 @@ pub async fn write_profile_data(
content: String
) -> Result<(), String> {
let appd = state.lock().await;
if let Some(p) = &appd.profile {
fs::write(p.dir().join(&path), content).await
.map_err(|e| format!("Unable to write to {:?}: {}", path, e))?;
@ -204,7 +221,7 @@ pub async fn set_cfg(
let mut appd = state.lock().await;
if let Some(p) = &mut appd.profile {
p.cfg.insert(key, value);
p.data.cfg.insert(key, value);
}
Ok(())

View File

@ -12,7 +12,6 @@ mod appdata;
use closure::closure;
use appdata::AppData;
use pkg::PkgKey;
use pkg_store::PackageStore;
use profile::Profile;
use tauri::{Listener, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
@ -47,6 +46,7 @@ pub async fn run(_args: Vec<String>) {
// Todo deindent this chimera
let url = &args[1];
if &url[..13] == "rainycolor://" {
log::info!("Deep link: {}", url);
let regex = regex::Regex::new(
r"rainycolor://v1/install/rainy\.patafour\.zip/([^/]+)/([^/]+)/[0-9]+\.[0-9]+\.[0-9]+/"
).expect("Invalid regex");
@ -72,10 +72,7 @@ pub async fn run(_args: Vec<String>) {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
let app_data = AppData {
profile: Profile::load(),
pkgs: PackageStore::new(app.handle().clone())
};
let app_data = AppData::new(app.handle().clone());
app.manage(Mutex::new(app_data));
app.deep_link().register_all()?;
@ -103,9 +100,11 @@ pub async fn run(_args: Vec<String>) {
cmd::install_package,
cmd::delete_package,
cmd::toggle_package,
cmd::get_current_profile,
cmd::list_profiles,
cmd::init_profile,
cmd::save_profile,
cmd::load_profile,
cmd::get_current_profile,
cmd::save_current_profile,
cmd::read_profile_data,
cmd::write_profile_data,
cmd::startline,

View File

@ -46,7 +46,7 @@ async fn prepare_packages(p: &Profile) -> Result<()> {
fs::remove_dir_all(dir_out.join("BepInEx")).await?;
}
for m in &p.mods {
for m in &p.data.mods {
log::debug!("Preparing {}", m);
let (namespace, name) = m.0.split_at(m.0.find("-").expect("Invalid mod definition"));
let bpx_dir = util::pkg_dir_of(namespace, &name[1..]) // cut the hyphen
@ -65,13 +65,15 @@ async fn prepare_packages(p: &Profile) -> Result<()> {
}
}
log::debug!("prepare packages: done");
Ok(())
}
pub async fn prepare_config(p: &Profile) -> Result<()> {
let dir_out = p.dir();
let ini_in_raw = fs::read_to_string(p.exe_dir.join("segatools.ini")).await?;
let ini_in_raw = fs::read_to_string(p.data.exe_dir.join("segatools.ini")).await?;
let ini_in = Ini::load_from_str(&ini_in_raw)?;
let mut opt_dir_in = PathBuf::from(
ini_in.section(Some("vfs"))
@ -80,7 +82,7 @@ pub async fn prepare_config(p: &Profile) -> Result<()> {
.ok_or_else(|| anyhow!("No option specified in segatools.ini"))?
);
if opt_dir_in.is_relative() {
opt_dir_in = p.exe_dir.join(opt_dir_in);
opt_dir_in = p.data.exe_dir.join(opt_dir_in);
}
let opt_dir_out = &dir_out.join("option");
@ -104,11 +106,13 @@ pub async fn prepare_config(p: &Profile) -> Result<()> {
ini_out.write_to_file(dir_out.join("segatools.ini"))?;
log::debug!("Option dir: {} -> {}", opt_dir_in.to_string_lossy(), opt_dir_out.to_string_lossy());
log::debug!("Option dir: {:?} -> {:?}", opt_dir_in, opt_dir_out);
for opt in opt_dir_in.read_dir()? {
let opt = opt?;
symlink(&opt.path(), opt_dir_out.join(opt.file_name())).await?;
}
log::debug!("prepare config: done");
Ok(())
}

View File

@ -2,6 +2,27 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize)]
pub enum Game {
#[serde(rename = "ongeki")]
Ongeki,
#[serde(rename = "chunithm")]
Chunithm,
}
}
impl Game {
pub fn from_str(s: &str) -> Option<Game> {
match s {
"ongeki" => Some(Game::Ongeki),
"chunithm" => Some(Game::Chunithm),
_ => None
}
}
}
impl std::fmt::Display for Game {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Game::Ongeki => write!(f, "ongeki"),
Game::Chunithm => write!(f, "chunithm")
}
}
}

View File

@ -1,17 +1,20 @@
use anyhow::Result;
use std::{collections::{BTreeSet, HashMap}, path::PathBuf};
use crate::{model::misc, pkg::PkgKey, util};
use anyhow::{Result, anyhow};
use std::{collections::{BTreeSet, HashMap}, path::{Path, PathBuf}};
use crate::{model::misc::{self, Game}, pkg::PkgKey, util};
use serde::{Deserialize, Serialize};
use tokio::fs;
// {game}-profile-{name}.json
#[derive(Deserialize, Serialize, Clone)]
#[allow(dead_code)]
pub struct Profile {
pub game: misc::Game,
pub exe_dir: PathBuf,
pub name: String,
pub data: ProfileData
}
// The contents of profile-{game}-{name}.json
#[derive(Deserialize, Serialize, Clone)]
pub struct ProfileData {
pub exe_dir: PathBuf,
pub mods: BTreeSet<PkgKey>,
pub wine_runtime: Option<PathBuf>,
pub wine_prefix: Option<PathBuf>,
@ -21,80 +24,126 @@ pub struct Profile {
}
impl Profile {
pub fn new(exe_path: PathBuf) -> Profile {
Profile {
game: misc::Game::Ongeki,
exe_dir: exe_path.parent().unwrap().to_owned(),
name: "ongeki-default".to_owned(),
mods: BTreeSet::new(),
#[cfg(target_os = "linux")]
wine_runtime: Some(std::path::Path::new("/usr/bin/wine").to_path_buf()),
#[cfg(target_os = "windows")]
wine_runtime: None,
#[cfg(target_os = "linux")]
wine_prefix: Some(
directories::UserDirs::new()
.expect("No home directory")
.home_dir()
.join(".wine"),
),
#[cfg(target_os = "windows")]
wine_prefix: None,
cfg: HashMap::new()
pub fn new(exe_path: PathBuf) -> Option<Profile> {
let game;
if exe_path.ends_with("mu3.exe") {
game = misc::Game::Ongeki
} else if exe_path.ends_with("chusanApp.exe") {
// game = misc::Game::Chunithm;
return None;
} else {
return None;
}
Some(Profile {
name: format!("{}", "default"),
game,
data: ProfileData {
exe_dir: exe_path.parent().unwrap().to_owned(),
mods: BTreeSet::new(),
wine_runtime: None,
wine_prefix: None,
cfg: HashMap::new()
}
})
}
pub fn dir(&self) -> PathBuf {
util::get_dirs()
.data_dir()
.join("profile-".to_owned() + &self.name)
.join(format!("profile-{}-{}", self.game, self.name))
.to_owned()
}
pub fn load() -> Option<Profile> {
pub async fn list() -> Result<Vec<(Game, String)>> {
let path = std::fs::read_dir(
util::get_dirs().config_dir()
)?;
let mut res = Vec::new();
for f in path {
let f = f?;
if let Some(pair) = Self::name_from_path(f.path()) {
res.push(pair);
}
}
Ok(res)
}
pub fn load(game: &Game, name: &str) -> Result<Profile> {
let path = util::get_dirs()
.config_dir()
.join("profile-ongeki-default.json");
if let Ok(s) = std::fs::read_to_string(path) {
Some(serde_json::from_str(&s).expect("Invalid profile json"))
.join(format!("profile-{}-{}.json", game, name));
if let Ok(s) = std::fs::read_to_string(&path) {
let (game, name) = Self::name_from_path(&path)
.ok_or_else(|| anyhow!("Invalid filename: {:?}", path.file_name()))?;
let data = serde_json::from_str::<ProfileData>(&s)
.map_err(|e| anyhow!("Unable to parse {:?}: {:?}", path, e))?;
Ok(Profile {
game,
name,
data
})
} else {
None
Err(anyhow!("Unable to open {:?}", path))
}
}
pub async fn save(&self) {
let path = util::get_dirs()
.config_dir()
.join("profile-ongeki-default.json");
let s = serde_json::to_string_pretty(self).unwrap();
.join(format!("profile-{}-{}.json", self.game, self.name));
let s = serde_json::to_string_pretty(&self.data).unwrap();
fs::write(&path, s).await.unwrap();
log::info!("Written to {}", path.to_string_lossy());
}
#[allow(dead_code)]
pub fn get_cfg(&self, key: &str) -> Result<&serde_json::Value> {
self.cfg.get(key)
self.data.cfg.get(key)
.ok_or_else(|| anyhow::anyhow!("Invalid config entry {}", key))
}
pub fn get_bool(&self, key: &str, default: bool) -> bool {
self.cfg.get(key)
self.data.cfg.get(key)
.and_then(|c| c.as_bool())
.unwrap_or(default)
}
pub fn get_int(&self, key: &str, default: i64) -> i64 {
self.cfg.get(key)
self.data.cfg.get(key)
.and_then(|c| c.as_i64())
.unwrap_or(default)
}
pub fn get_str(&self, key: &str, default: &str) -> String {
self.cfg.get(key)
self.data.cfg.get(key)
.and_then(|c| c.as_str())
.unwrap_or(default)
.to_owned()
}
fn name_from_path(path: impl AsRef<Path>) -> Option<(Game, String)> {
let regex = regex::Regex::new(
r"profile-([^\-]+)-([^\-]+)\.json"
).expect("Invalid regex");
let fname = path.as_ref().file_name().unwrap_or_default().to_string_lossy();
if let Some(caps) = regex.captures(&fname) {
let game = caps.get(1).unwrap().as_str();
let name = caps.get(2).unwrap().as_str().to_owned();
if let Some(game) = Game::from_str(game) {
return Some((game, name));
}
}
None
}
}

View File

@ -1,5 +1,6 @@
use anyhow::Result;
use std::fs::File;
use std::path::PathBuf;
use tokio::process::Command;
use tauri::{AppHandle, Emitter};
use std::process::Stdio;
@ -26,13 +27,13 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> {
}
#[cfg(target_os = "linux")]
{
let wine = p.wine_runtime.as_ref()
.expect("No wine path specified");
let wine = p.data.wine_runtime.clone()
.unwrap_or_else(|| PathBuf::from("/usr/bin/wine"));
game_builder = Command::new(wine);
amd_builder = Command::new(wine);
game_builder = Command::new(&wine);
amd_builder = Command::new(&wine);
game_builder.arg(p.exe_dir.join("inject.exe"));
game_builder.arg(p.data.exe_dir.join("inject.exe"));
amd_builder.arg("cmd.exe");
}
@ -42,10 +43,10 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> {
"SEGATOOLS_CONFIG_PATH",
&ini_path,
)
.current_dir(&p.exe_dir)
.current_dir(&p.data.exe_dir)
.args([
"/C",
&util::path_to_str(p.exe_dir.join("inject.exe"))?, "-d", "-k", "mu3hook.dll",
&util::path_to_str(p.data.exe_dir.join("inject.exe"))?, "-d", "-k", "mu3hook.dll",
"amdaemon.exe", "-f", "-c", "config_common.json", "config_server.json", "config_client.json"
]);
game_builder
@ -53,7 +54,7 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> {
"SEGATOOLS_CONFIG_PATH",
ini_path,
)
.current_dir(&p.exe_dir)
.current_dir(&p.data.exe_dir)
.args([
"-d", "-k", "mu3hook.dll",
"mu3.exe", "-monitor 1",
@ -68,10 +69,14 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> {
#[cfg(target_os = "linux")]
{
let wineprefix = p.wine_prefix.as_ref()
.expect("No wineprefix specified");
amd_builder.env("WINEPREFIX", wineprefix);
game_builder.env("WINEPREFIX", wineprefix);
let wineprefix = p.data.wine_prefix.clone().unwrap_or_else(||
directories::UserDirs::new()
.expect("No home directory")
.home_dir()
.join(".wine")
);
amd_builder.env("WINEPREFIX", &wineprefix);
game_builder.env("WINEPREFIX", &wineprefix);
}

View File

@ -7,53 +7,31 @@ import TabPanel from 'primevue/tabpanel';
import TabPanels from 'primevue/tabpanels';
import Tabs from 'primevue/tabs';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { open } from '@tauri-apps/plugin-dialog';
import ModList from './ModList.vue';
import ModStore from './ModStore.vue';
import Options from './Options.vue';
import ProfileList from './ProfileList.vue';
import StartButton from './StartButton.vue';
import { usePkgStore } from '../stores';
import { changePrimaryColor } from '../util';
import { usePkgStore, usePrfStore } from '../stores';
const store = usePkgStore();
store.setupListeners();
const pkg = usePkgStore();
const prf = usePrfStore();
pkg.setupListeners();
prf.setupListeners();
const currentTab = ref('3');
const loadProfile = async (openWindow: boolean) => {
await store.reloadProfile();
if (store.profile === null && openWindow) {
const exePath = await open({
multiple: false,
directory: false,
filters: [
{
name: 'mu3.exe' /* or chusanApp.exe'*/,
extensions: ['exe'],
},
],
});
if (exePath !== null) {
await store.initProfile(exePath);
}
}
if (store.profile !== null) {
changePrimaryColor(store.profile.game);
currentTab.value = '0';
}
await store.reloadAll();
};
const isProfileDisabled = computed(() => store.profile === null);
onOpenUrl((urls) => {
console.log('deep link:', urls);
});
const isProfileDisabled = computed(() => prf.current === null);
onMounted(async () => {
await loadProfile(false);
await prf.reloadList();
await prf.reload();
if (prf.current !== null) {
await pkg.reloadAll();
currentTab.value = '0';
}
});
</script>
@ -93,14 +71,10 @@ onMounted(async () => {
missing.<br />Existing features are expected to break any
time.
<div v-if="isProfileDisabled">
<br />Select <code>mu3.exe</code> to create a
profile:<br />
<Button
label="Create profile"
icon="pi pi-plus"
aria-label="open-executable"
@click="loadProfile(true)"
/><br />
<br />Select <code>mu3.exe</code> to create a profile:
</div>
<ProfileList />
<div v-if="isProfileDisabled">
<div
style="
margin-top: 5px;
@ -115,10 +89,15 @@ onMounted(async () => {
(this will change in the future)
</div>
<img
v-if="store.profile?.game === 'Ongeki'"
v-if="prf.current?.game === 'ongeki'"
src="/sticker-ongeki.svg"
class="fixed bottom-0 right-0"
/>
<img
v-else-if="prf.current?.game === 'chunithm'"
src="/sticker-chunithm.svg"
class="fixed bottom-0 right-0"
/>
<br /><br /><br />
<Button
style="position: fixed; left: 10px; bottom: 10px"

View File

@ -1,20 +1,16 @@
<script setup lang="ts">
import Fieldset from 'primevue/fieldset';
import ModListEntry from './ModListEntry.vue';
import { usePkgStore } from '../stores';
import { Profile } from '../types';
import { usePkgStore, usePrfStore } from '../stores';
defineProps({
profile: Object as () => Profile,
});
const pkgs = usePkgStore();
const pkg = usePkgStore();
const prf = usePrfStore();
const group = () => {
const a = Object.assign(
{},
Object.groupBy(
pkgs.allLocal
pkg.allLocal
.sort((p1, p2) => p1.namespace.localeCompare(p2.namespace))
.sort((p1, p2) => p1.name.localeCompare(p2.name)),
({ namespace }) => namespace
@ -23,7 +19,7 @@ const group = () => {
return a;
};
pkgs.reloadProfile();
prf.reload();
</script>
<template>

View File

@ -5,17 +5,17 @@ import { open } from '@tauri-apps/plugin-shell';
import InstallButton from './InstallButton.vue';
import ModTitlecard from './ModTitlecard.vue';
import UpdateButton from './UpdateButton.vue';
import { usePkgStore } from '../stores';
import { usePrfStore } from '../stores';
import { Package } from '../types';
const store = usePkgStore();
const prf = usePrfStore();
const props = defineProps({
pkg: Object as () => Package,
});
const toggle = async (value: boolean) => {
await store.toggle(props.pkg, value);
await prf.togglePkg(props.pkg, value);
};
</script>
@ -27,7 +27,7 @@ const toggle = async (value: boolean) => {
class="scale-[1.33] shrink-0"
inputId="switch"
:disabled="!pkg?.loc"
:modelValue="store.isEnabled(pkg)"
:modelValue="prf.isPkgEnabled(pkg)"
v-on:value-change="toggle"
/>
<InstallButton :pkg="pkg" />

View File

@ -6,18 +6,17 @@ import InputText from 'primevue/inputtext';
import RadioButton from 'primevue/radiobutton';
import Toggle from 'primevue/toggleswitch';
import { invoke } from '@tauri-apps/api/core';
import * as path from '@tauri-apps/api/path';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { usePkgStore } from '../stores';
import { usePrfStore } from '../stores';
const prf = usePrfStore();
const store = usePkgStore();
const _cfg = <T extends string | number | boolean>(key: string, dflt: T) =>
computed({
get() {
return (store.cfg(key) as T) ?? dflt;
return (prf.cfg(key) as T) ?? dflt;
},
async set(value) {
await store.set_cfg(key, value ?? dflt);
await prf.setCfg(key, value ?? dflt);
},
});
@ -126,7 +125,7 @@ const aimeCodeModel = computed({
<InputText
class="shrink"
size="small"
:disabled="store.cfg('aime') !== true"
:disabled="prf.cfg('aime') !== true"
:maxlength="20"
placeholder="00000000000000000000"
v-model="aimeCodeModel"

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import Button from 'primevue/button';
import { usePrfStore } from '../stores';
const prf = usePrfStore();
</script>
<template>
<div class="mt-4 flex flex-wrap align-middle gap-4">
<Button
:disabled="prf.list.length > 0"
label="Create profile"
icon="pi pi-plus"
aria-label="open-executable"
class="create-button"
@click="prf.prompt"
/>
<div v-for="p in prf.list">
<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 grow'
"
@click="prf.switchTo(p.game, p.name)"
/>
</div>
</div>
</template>
<style scoped>
.create-button {
background-color: var(--p-green-400);
border-color: var(--p-green-400);
width: 10em;
}
.create-button:hover,
.create-button:active {
background-color: var(--p-green-300) !important;
border-color: var(--p-green-300) !important;
}
.ongeki-button {
background-color: var(--p-pink-400);
border-color: var(--p-pink-400);
width: 10em;
}
.ongeki-button:hover,
.ongeki-button:active {
background-color: var(--p-pink-300) !important;
border-color: var(--p-pink-300) !important;
}
.chunithm-button {
background-color: var(--p-yellow-400);
border-color: var(--p-yellow-400);
width: 10em;
}
.chunithm-button:hover,
.chunithm-button:active {
background-color: var(--p-yellow-300) !important;
border-color: var(--p-yellow-300) !important;
}
</style>

View File

@ -1,18 +1,18 @@
import { defineStore } from 'pinia';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { Package, Profile } from './types';
import { pkgKey } from './util';
import { open } from '@tauri-apps/plugin-dialog';
import { Game, Package, Profile, ProfileMeta } from './types';
import { changePrimaryColor, pkgKey } from './util';
type InstallStatus = {
pkg: string;
};
export const usePkgStore = defineStore('pkg', {
state: (): { pkg: { [key: string]: Package }; prf: Profile | null } => {
state: (): { pkg: { [key: string]: Package } } => {
return {
pkg: {},
prf: null,
};
},
getters: {
@ -22,10 +22,6 @@ export const usePkgStore = defineStore('pkg', {
all: (state) => Object.values(state),
allLocal: (state) => Object.values(state.pkg).filter((p) => p.loc),
allRemote: (state) => Object.values(state.pkg).filter((p) => p.rmt),
profile: (state) => state.prf,
isEnabled: (state) => (pkg: Package | undefined) =>
pkg !== undefined && state.prf?.mods.includes(pkgKey(pkg)),
cfg: (state) => (key: string) => state.prf?.cfg[key],
},
actions: {
setupListeners() {
@ -38,7 +34,6 @@ export const usePkgStore = defineStore('pkg', {
listen<InstallStatus>('install-end', async (ev) => {
const key = ev.payload.pkg;
await this.reload(key);
await this.reloadProfile();
this.pkg[key].js.busy = false;
});
},
@ -73,36 +68,108 @@ export const usePkgStore = defineStore('pkg', {
Object.assign(this.pkg[key], pkg);
},
async initProfile(exePath: string) {
this.prf = await invoke('init_profile', { exePath });
},
async saveProfile() {
await invoke('save_profile');
},
async reloadProfile() {
this.prf = await invoke('get_current_profile');
},
async fetch() {
await invoke('fetch_listings');
await this.reloadAll();
},
},
});
async toggle(pkg: Package | undefined, enable: boolean) {
export const usePrfStore = defineStore('prf', {
state: (): { prf: Profile | null; list: ProfileMeta[] } => {
return {
prf: null,
list: [],
};
},
getters: {
current: (state) => state.prf,
isPkgEnabled: (state) => (pkg: Package | undefined) =>
pkg !== undefined && state.prf?.data.mods.includes(pkgKey(pkg)),
cfg: (state) => (key: string) => state.prf?.data.cfg[key],
},
actions: {
setupListeners() {
listen<InstallStatus>('install-end', async () => {
await this.reload();
});
},
async prompt() {
const exePath = await open({
multiple: false,
directory: false,
filters: [
{
name: 'mu3.exe or chusanApp.exe',
extensions: ['exe'],
},
],
});
if (exePath !== null) {
await this.create(exePath);
}
},
async create(exePath: string) {
try {
await invoke('init_profile', { exePath });
await this.reload();
await this.reloadList();
} catch (e) {
this.prf = null;
}
if (this.prf !== null) {
const pkgs = usePkgStore();
pkgs.reloadAll();
}
},
async switchTo(game: Game, name: string) {
await invoke('load_profile', { game, name });
await this.reload();
if (this.prf !== null) {
const pkgs = usePkgStore();
pkgs.reloadAll();
}
},
async save() {
await invoke('save_current_profile');
},
async reload() {
this.prf = await invoke('get_current_profile');
if (this.prf !== null) {
changePrimaryColor(this.prf.game);
}
},
async reloadList() {
const raw = (await invoke('list_profiles')) as [Game, string][];
this.list = raw.map(([game, name]) => {
return {
game,
name,
};
});
},
async togglePkg(pkg: Package | undefined, enable: boolean) {
if (pkg === undefined) {
return;
}
await invoke('toggle_package', { key: pkgKey(pkg), enable });
await this.reloadProfile();
await this.saveProfile();
await this.reload();
await this.save();
},
async set_cfg(key: string, value: string | boolean | number) {
async setCfg(key: string, value: string | boolean | number) {
await invoke('set_cfg', { key, value });
await this.reloadProfile();
await this.saveProfile();
await this.reload();
await this.save();
},
},
});

View File

@ -19,11 +19,16 @@ export interface Package {
};
}
export interface Profile {
export type Game = 'ongeki' | 'chunithm';
export interface ProfileMeta {
game: Game;
name: string;
game: 'Ongeki' | 'Chunithm';
mods: string[];
cfg: { [key: string]: string | boolean | number };
}
export type PackageC = Map<string, Package>;
export interface Profile extends ProfileMeta {
data: {
mods: string[];
cfg: { [key: string]: string | boolean | number };
};
}

View File

@ -1,8 +1,8 @@
import { updatePrimaryPalette } from '@primevue/themes';
import { Package } from './types';
export const changePrimaryColor = (game: 'Ongeki' | 'Chunithm') => {
const color = game === 'Ongeki' ? 'pink' : 'yellow';
export const changePrimaryColor = (game: 'ongeki' | 'chunithm') => {
const color = game === 'ongeki' ? 'pink' : 'yellow';
updatePrimaryPalette({
50: `{${color}.50}`,