feat: misc improvements

This commit is contained in:
2025-03-18 23:27:17 +00:00
parent fe1a32f31b
commit 1191cdd95c
15 changed files with 264 additions and 68 deletions

View File

@ -1,5 +1,6 @@
use std::hash::{DefaultHasher, Hash, Hasher};
use crate::model::config::GlobalConfig;
use crate::pkg::{Feature, Status};
use crate::profiles::AnyProfile;
use crate::{model::misc::Game, pkg::PkgKey};
use crate::pkg_store::PackageStore;
@ -18,6 +19,13 @@ pub struct AppData {
pub state: GlobalState,
}
#[derive(PartialEq, Debug, Copy, Clone)]
pub enum ToggleAction {
Disable,
EnableSelf,
EnableRecursive,
}
impl AppData {
pub fn new(apph: AppHandle) -> AppData {
let cfg = std::fs::read_to_string(util::config_dir().join("config.json"))
@ -59,26 +67,34 @@ impl AppData {
}
}
pub fn toggle_package(&mut self, key: PkgKey, enable: bool) -> Result<()> {
log::debug!("toggle: {} {}", key, enable);
pub fn toggle_package(&mut self, key: PkgKey, action: ToggleAction) -> Result<()> {
log::debug!("toggle: {} {:?}", key, action);
let profile = self.profile.as_mut().ok_or_else(|| anyhow!("No profile"))?;
if enable {
if action != ToggleAction::Disable {
let pkg = self.pkgs.get(&key)?;
let loc = pkg.loc
.clone()
.ok_or_else(|| anyhow!("Attempted to enable a non-existent package"))?;
profile.mod_pkgs_mut().insert(key);
for d in &loc.dependencies {
_ = self.toggle_package(d.clone(), true);
if let Status::OK(feature_set) = loc.status {
log::debug!("{:?}", feature_set);
if feature_set.contains(Feature::Mod) {
profile.mod_pkgs_mut().insert(key);
}
}
if action == ToggleAction::EnableRecursive {
for d in &loc.dependencies {
_ = self.toggle_package(d.clone(), action);
}
}
} else {
profile.mod_pkgs_mut().remove(&key);
for (ckey, pkg) in self.pkgs.get_all() {
if let Some(loc) = pkg.loc {
if loc.dependencies.contains(&key) {
self.toggle_package(ckey, false)?;
self.toggle_package(ckey, action)?;
}
}
}
@ -99,4 +115,10 @@ impl AppData {
}
hasher.finish().to_string()
}
pub fn fix(&mut self) {
if let Some(p) = &mut self.profile {
p.fix(&self.pkgs);
}
}
}

View File

@ -8,7 +8,7 @@ use crate::pkg::{Package, PkgKey};
use crate::pkg_store::{InstallResult, PackageStore};
use crate::profiles::ongeki::OngekiProfile;
use crate::profiles::{self, AnyProfile, Profile, ProfileMeta, ProfilePaths};
use crate::appdata::AppData;
use crate::appdata::{AppData, ToggleAction};
use crate::model::misc::StartCheckError;
use crate::util;
@ -103,6 +103,9 @@ pub async fn delete_package(state: State<'_, tokio::sync::Mutex<AppData>>, key:
let mut appd = state.lock().await;
appd.pkgs.delete_package(&key, true)
.await
.map_err(|e| e.to_string())?;
appd.toggle_package(key, ToggleAction::Disable)
.map_err(|e| e.to_string())
}
@ -121,7 +124,7 @@ pub async fn toggle_package(state: State<'_, tokio::sync::Mutex<AppData>>, key:
log::debug!("invoke: toggle_package({}, {})", key, enable);
let mut appd = state.lock().await;
appd.toggle_package(key, enable)
appd.toggle_package(key, if enable { ToggleAction::EnableRecursive } else { ToggleAction::Disable })
.map_err(|e| e.to_string())
}
@ -205,6 +208,7 @@ pub async fn load_profile(state: State<'_, Mutex<AppData>>, game: Game, name: St
let mut appd = state.lock().await;
appd.switch_profile(game, name).map_err(|e| e.to_string())?;
appd.fix();
Ok(())
}
@ -290,14 +294,31 @@ pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Opt
}
#[tauri::command]
pub async fn save_current_profile(state: State<'_, Mutex<AppData>>, profile: AnyProfile) -> Result<(), String> {
pub async fn sync_current_profile(state: State<'_, Mutex<AppData>>, profile: AnyProfile) -> Result<(), String> {
log::debug!("invoke: sync_current_profile");
let mut appd = state.lock().await;
if let Some(p) = &mut appd.profile {
p.sync(profile);
}
Ok(())
}
#[tauri::command]
pub async fn save_current_profile(state: State<'_, Mutex<AppData>>) -> Result<(), String> {
log::debug!("invoke: save_current_profile");
let mut appd = state.lock().await;
profile.save().map_err(|e| e.to_string())?;
appd.profile = Some(profile);
Ok(())
appd.fix();
match &mut appd.profile {
Some(p) => {
p.save().map_err(|e| e.to_string())
},
None => {
Err("no profile to save".to_owned())
}
}
}
#[tauri::command]

View File

@ -8,15 +8,19 @@ mod appdata;
mod modules;
mod profiles;
use std::sync::OnceLock;
use anyhow::anyhow;
use closure::closure;
use appdata::AppData;
use appdata::{AppData, ToggleAction};
use model::misc::Game;
use pkg::PkgKey;
use tauri::{AppHandle, Listener, Manager};
use pkg_store::Payload;
use tauri::{AppHandle, Listener, Manager, RunEvent};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_cli::CliExt;
use tokio::{sync::Mutex, fs, try_join};
use tokio::{fs, sync::Mutex, try_join};
static EXIT_REQUESTED: OnceLock<()> = OnceLock::new();
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub async fn run(_args: Vec<String>) {
@ -30,7 +34,7 @@ pub async fn run(_args: Vec<String>) {
.unwrap_or_default()
);
tauri::Builder::default()
let tauri = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
let _ = app
.get_webview_window("main")
@ -65,8 +69,8 @@ pub async fn run(_args: Vec<String>) {
} else {
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::App("index.html".into()))
.title("STARTLINER")
.inner_size(600f64, 500f64)
.min_inner_size(600f64, 500f64)
.inner_size(640f64, 480f64)
.min_inner_size(640f64, 480f64)
.build()?;
start_immediately = false;
}
@ -102,17 +106,19 @@ pub async fn run(_args: Vec<String>) {
});
app.listen("download-end", closure!(clone apph, |ev| {
log::debug!("download-end triggered: {}", ev.payload());
let raw = ev.payload();
let key = PkgKey(raw[1..raw.len()-1].to_owned());
let apph = apph.clone();
tauri::async_runtime::spawn(async move {
let mutex = apph.state::<Mutex<AppData>>();
let mut appd = mutex.lock().await;
_ = appd.pkgs.install_package(&key, true, false).await;
log::debug!("download-end install {:?}", appd.pkgs.install_package(&key, true, false).await);
});
}));
app.listen("launch-end", closure!(clone apph, |_| {
log::debug!("launch-end triggered");
let apph = apph.clone();
tauri::async_runtime::spawn(async move {
let mutex = apph.state::<Mutex<AppData>>();
@ -123,6 +129,26 @@ pub async fn run(_args: Vec<String>) {
});
}));
app.listen("install-end-prelude", closure!(clone apph, |ev| {
log::debug!("install-end-prelude triggered: {}", ev.payload());
let payload = serde_json::from_str::<Payload>(ev.payload());
let apph = apph.clone();
if let Ok(payload) = payload {
tauri::async_runtime::spawn(async move {
let mutex = apph.state::<Mutex<AppData>>();
let mut appd = mutex.lock().await;
log::debug!(
"install-end-prelude toggle {:?}",
appd.toggle_package(payload.pkg.clone(), ToggleAction::EnableSelf)
);
use tauri::Emitter;
log::debug!("install-end {:?}", apph.emit("install-end", payload));
});
} else {
log::error!("install-end-prelude: invalid payload: {}", ev.payload());
}
}));
if start_immediately == true {
let apph = apph.clone();
tauri::async_runtime::spawn(async move {
@ -163,14 +189,40 @@ pub async fn run(_args: Vec<String>) {
cmd::duplicate_profile,
cmd::delete_profile,
cmd::get_current_profile,
cmd::sync_current_profile,
cmd::save_current_profile,
cmd::list_displays,
cmd::list_platform_capabilities,
cmd::list_directories,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application");
tauri.run(move |app, event| {
match event {
RunEvent::ExitRequested { api, code, .. } => {
log::debug!("exit request {:?} {:?}", api, code);
if EXIT_REQUESTED.get().is_none() {
_= EXIT_REQUESTED.set(());
api.prevent_exit();
let app = app.clone();
tauri::async_runtime::spawn(async move {
let mutex = app.state::<Mutex<AppData>>();
let appd = mutex.lock().await;
if let Some(p) = &appd.profile {
log::debug!("save: {:?}", p.save());
app.exit(0);
}
});
}
},
RunEvent::Exit => {
log::info!("exit");
}
_ => {}
}
});
}
fn deep_link(app: AppHandle, args: Vec<String>) {

View File

@ -2,9 +2,10 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::pkg::PkgKey;
#[derive(Deserialize, Serialize, Clone)]
#[derive(Deserialize, Serialize, Clone, Default, PartialEq)]
pub enum Aime {
BuiltIn,
Disabled,
#[default] BuiltIn,
AMNet(PkgKey),
Other(PkgKey),
}
@ -27,7 +28,8 @@ pub struct Segatools {
pub target: PathBuf,
pub hook: Option<PkgKey>,
pub io: Option<PkgKey>,
pub aime: Option<Aime>,
#[serde(default)]
pub aime: Aime,
pub amfs: PathBuf,
pub option: PathBuf,
pub appdata: PathBuf,
@ -45,7 +47,7 @@ impl Default for Segatools {
amfs: PathBuf::default(),
option: PathBuf::default(),
appdata: PathBuf::from("appdata"),
aime: Some(Aime::BuiltIn),
aime: Aime::default(),
intel: false,
amnet: AMNet::default(),
}

View File

@ -60,7 +60,7 @@ impl Network {
cmd.arg(&self.local_path);
cmd.current_dir(artemis_dir);
cmd.spawn()
.map_err(|e| anyhow!("Unable to spawn artemis: {}", e))?;
.map_err(|e| anyhow!("unable to spawn artemis: {}", e))?;
} else {
log::warn!("unable to parse the artemis hostname");
}

View File

@ -3,8 +3,34 @@ use std::path::PathBuf;
use anyhow::{anyhow, Result};
use ini::Ini;
use crate::{model::{profile::{Aime, Segatools}, segatools_base::segatools_base}, profiles::ProfilePaths, util::{self, PathStr}};
use crate::pkg_store::PackageStore;
impl Segatools {
pub fn fix(&mut self, store: &PackageStore) {
macro_rules! remove_if_nonpresent {
($item:expr,$key:expr,$emptyval:expr,$store:expr) => {
if let Ok(pkg) = $store.get($key) {
if pkg.loc.is_none() {
$item = $emptyval;
}
} else {
$item = $emptyval;
}
}
}
if let Some(key) = &self.hook {
remove_if_nonpresent!(self.hook, key, None, store);
}
if let Some(key) = &self.io {
remove_if_nonpresent!(self.io, key, None, store);
}
match &self.aime {
Aime::AMNet(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store),
Aime::Other(key) => remove_if_nonpresent!(self.aime, key, Aime::BuiltIn, store),
_ => {},
}
}
pub async fn line_up(&self, p: &impl ProfilePaths) -> Result<Ini> {
log::debug!("begin line-up: segatools");
@ -50,11 +76,11 @@ impl Segatools {
pfx_dir.join("BepInEx").join("core").join("BepInEx.Preloader.dll").stringify()?
);
if let Some(aime) = &self.aime {
if self.aime != Aime::Disabled {
ini_out.with_section(Some("aime"))
.set("enable", "1")
.set("aimePath", p.config_dir().join("aime.txt").stringify()?);
if let Aime::AMNet(key) = aime {
if let Aime::AMNet(key) = &self.aime {
let mut aimeio = ini_out.with_section(Some("aimeio"));
aimeio
.set("path", util::pkg_dir().join(key.to_string()).join("segatools").join("aimeio.dll").stringify()?)

View File

@ -20,7 +20,6 @@ pub struct Package {
pub namespace: String,
pub name: String,
pub description: String,
pub icon: String,
pub loc: Option<Local>,
pub rmt: Option<Remote>
}
@ -50,6 +49,7 @@ pub struct Local {
pub path: PathBuf,
pub dependencies: BTreeSet<PkgKey>,
pub status: Status,
pub icon: String,
}
#[derive(Clone, Serialize, Deserialize)]
@ -58,6 +58,7 @@ pub struct Remote {
pub version: String,
pub package_url: String,
pub download_url: String,
pub icon: String,
pub deprecated: bool,
pub nsfw: bool,
pub categories: Vec<String>,
@ -76,11 +77,11 @@ impl Package {
namespace: p.owner,
name: v.name,
description: v.description,
icon: v.icon,
loc: None,
rmt: Some(Remote {
package_url: p.package_url,
download_url: v.download_url,
icon: v.icon,
deprecated: p.is_deprecated,
nsfw: p.has_nsfw_content,
version: v.version_number,
@ -107,10 +108,10 @@ impl Package {
namespace: Self::dir_to_namespace(&dir)?,
name: mft.name.clone(),
description: mft.description.clone(),
icon,
loc: Some(Local {
version: mft.version_number,
path: dir.to_owned(),
icon,
status,
dependencies
}),

View File

@ -22,7 +22,7 @@ pub struct Payload {
pub pkg: PkgKey
}
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Copy, Serialize, Deserialize, Debug)]
pub enum InstallResult {
Ready, Deferred
}
@ -167,7 +167,7 @@ impl PackageStore {
archive.extract(path)?;
self.reload_package(key.to_owned()).await;
self.app.emit("install-end", Payload {
self.app.emit("install-end-prelude", Payload {
pkg: key.to_owned()
})?;
@ -189,7 +189,7 @@ impl PackageStore {
let rv = Self::clean_up_package(&path).await;
if rv.is_ok() {
self.app.emit("install-end", Payload {
self.app.emit("install-end-prelude", Payload {
pkg: key.to_owned()
})?;
log::info!("deleted {}", key);

View File

@ -3,7 +3,7 @@ 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};
use crate::{model::misc::Game, modules::package::prepare_packages, pkg::PkgKey, pkg_store::PackageStore, util};
pub mod ongeki;
@ -83,6 +83,26 @@ impl AnyProfile {
}
res
}
pub fn fix(&mut self, store: &PackageStore) {
match self {
Self::OngekiProfile(p) => p.sgt.fix(store)
}
}
pub fn sync(&mut self, source: AnyProfile) {
match self {
Self::OngekiProfile(p) => {
#[allow(irrefutable_let_patterns)]
if let AnyProfile::OngekiProfile(source) = source {
p.bepinex = source.bepinex;
p.display = source.display;
p.network = source.network;
p.sgt = source.sgt;
} else {
log::error!("sync: invalid profile type {:?}", source);
}
}
}
}
pub async fn line_up(&self, pkg_hash: String, _app: AppHandle) -> Result<()> {
match self {
Self::OngekiProfile(_p) => {

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import Chip from 'primevue/chip';
import { convertFileSrc } from '@tauri-apps/api/core';
import { Feature, Package } from '../types';
@ -13,8 +14,8 @@ const props = defineProps({
showIcon: Boolean,
});
const iconSrc = () => {
const icon = props.pkg?.icon;
const iconSrc = computed(() => {
const icon = props.pkg?.loc?.icon ?? props.pkg?.rmt?.icon;
if (icon === undefined) {
return '';
@ -23,13 +24,13 @@ const iconSrc = () => {
} else {
return convertFileSrc(icon);
}
};
});
</script>
<template>
<img
v-if="showIcon"
:src="iconSrc()"
:src="iconSrc"
class="self-center rounded-sm"
width="32px"
height="32px"
@ -66,7 +67,7 @@ const iconSrc = () => {
<span
v-if="hasFeature(pkg, Feature.Aime)"
v-tooltip="'Aime'"
class="pi pi-wrench ml-1 text-purple-400"
class="pi pi-credit-card ml-1 text-purple-400"
>
</span>
<span

View File

@ -29,24 +29,48 @@ const displayList: Ref<{ title: string; value: string }[]> = ref([
},
]);
invoke('list_platform_capabilities')
.then(async (v: unknown) => {
if (Array.isArray(v)) {
capabilities.value.push(...v);
}
if (capabilities.value.includes('display')) {
for (const [devName, devString] of (await invoke(
'list_displays'
)) as Array<[string, string]>) {
displayList.value.push({
title: `${devName.replace('\\\\.\\', '')} (${devString})`,
value: devName,
});
const loadDisplays = () => {
const newList = [
{
title: 'Primary',
value: 'default',
},
];
invoke('list_platform_capabilities')
.then(async (v: unknown) => {
let different = false;
if (Array.isArray(v)) {
capabilities.value.push(...v);
}
}
})
.catch(() => {});
if (capabilities.value.includes('display')) {
for (const [devName, devString] of (await invoke(
'list_displays'
)) as Array<[string, string]>) {
newList.push({
title: `${devName.replace('\\\\.\\', '')} (${devString})`,
value: devName,
});
if (
displayList.value.find(
(item) => item.value === devName
) === undefined
) {
different = true;
}
}
}
if (displayList.value.length !== newList.length) {
different = true;
}
if (different) {
displayList.value = newList;
}
})
.catch(() => {});
};
loadDisplays();
prf.reload();
const aimeCodeModel = computed({
get() {
@ -145,6 +169,8 @@ const extraDisplayOptionsDisabled = computed(() => {
:options="displayList"
option-label="title"
option-value="value"
placeholder="(Disconnected)"
@show="loadDisplays"
></Select>
</OptionRow>
<OptionRow class="number-input" title="Game resolution">
@ -212,8 +238,9 @@ const extraDisplayOptionsDisabled = computed(() => {
/>
</OptionRow>
<OptionRow
title="Match display resolution with the game"
title="Borderless fullscreen"
v-if="capabilities.includes('display')"
tooltip="Match display resolution with the game."
>
<ToggleSwitch
:disabled="
@ -305,7 +332,7 @@ const extraDisplayOptionsDisabled = computed(() => {
<Select
v-model="prf.current!.sgt.aime"
:options="[
{ title: 'disabled', value: null },
{ title: 'none', value: 'Disabled' },
{ title: 'segatools built-in', value: 'BuiltIn' },
...pkgs.aimes.map((p) => {
return {
@ -325,7 +352,7 @@ const extraDisplayOptionsDisabled = computed(() => {
<InputText
class="shrink"
size="small"
:disabled="prf.current!.sgt.aime === null"
:disabled="prf.current!.sgt.aime === 'Disabled'"
:maxlength="20"
placeholder="00000000000000000000"
v-model="aimeCodeModel"
@ -350,7 +377,10 @@ const extraDisplayOptionsDisabled = computed(() => {
v-model="prf.current!.sgt.amnet.addr"
/>
</OptionRow>
<OptionRow title="Use AiMeDB for physical cards">
<OptionRow
title="Use AiMeDB for physical cards"
tooltip="Whether physical cards should use AiMeDB to retrieve access codes. If the game is using a hosted network, enable this option to load the same account data/profile as you would get on a physical cab."
>
<ToggleSwitch v-model="prf.current!.sgt.amnet.physical" />
</OptionRow>
</div>
@ -383,6 +413,11 @@ const extraDisplayOptionsDisabled = computed(() => {
}
.p-inputtext {
font-family: monospace;
width: 40vw;
font-family: monospace !important;
}
.p-select {
width: 40vw;
}
</style>

View File

@ -7,6 +7,7 @@ const category = getCurrentInstance()?.parent?.parent?.parent?.parent; // yes in
const props = defineProps({
title: String,
tooltip: String,
});
const searched = computed(() => {
@ -24,7 +25,15 @@ const searched = computed(() => {
<template>
<div v-if="searched" class="flex flex-row w-full p-2 gap-2 items-center">
<div class="grow">{{ title }}</div>
<div class="grow">
<span>{{ title }}</span>
<span
v-if="tooltip"
class="pi pi-question-circle ml-2"
v-tooltip="tooltip"
></span>
</div>
<slot />
</div>
</template>

View File

@ -45,6 +45,7 @@ const startline = async (force: boolean) => {
}
}
try {
await invoke('save_current_profile');
await invoke('startline');
} catch (_) {
startStatus.value = 'ready';

View File

@ -182,6 +182,7 @@ export const usePkgStore = defineStore('pkg', {
export const usePrfStore = defineStore('prf', () => {
const current: Ref<Profile | null> = ref(null);
const list: Ref<ProfileMeta[]> = ref([]);
let timeout: NodeJS.Timeout | null = null;
const isPkgEnabled = (pkg: Package | undefined) =>
computed(
@ -281,9 +282,13 @@ export const usePrfStore = defineStore('prf', () => {
watchEffect(async () => {
if (current.value !== null) {
await invoke('save_current_profile', {
await invoke('sync_current_profile', {
profile: { OngekiProfile: current.value },
});
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => invoke('save_current_profile'), 2000);
}
});

View File

@ -2,12 +2,12 @@ export interface Package {
namespace: string;
name: string;
description: string;
icon: string;
loc: {
version: string;
path: string;
dependencies: string[];
status: Status;
icon: string;
} | null;
rmt: {
version: string;
@ -16,6 +16,7 @@ export interface Package {
deprecated: boolean;
nsfw: boolean;
categories: string[];
icon: string;
} | null;
js: {
busy: boolean;
@ -51,7 +52,7 @@ export interface SegatoolsConfig {
amfs: string;
option: string;
appdata: string;
aime: { AMNet: string } | { Other: string } | null;
aime: { AMNet: string } | { Other: string } | 'BuiltIn' | 'Disabled';
intel: boolean;
amnet: {
name: string;