feat: etc

This commit is contained in:
2025-02-27 02:11:37 +01:00
parent 1586f81152
commit 947b384511
18 changed files with 263 additions and 146 deletions

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
rust/**/*

View File

@ -64,7 +64,8 @@ Arbitrary scripts are not supported by design and that will probably never chang
- CHUNITHM support
- segatools as a special package
- Progress bars and other GUI sugar
- Rebuilding the profile only when necessary
- Search bar
- Start check
### Endgame

BIN
bun.lockb

Binary file not shown.

View File

@ -17,6 +17,7 @@
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-fs": "~2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-shell": "~2",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",

1
rust/Cargo.lock generated
View File

@ -4341,6 +4341,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-opener",
"tauri-plugin-shell",
"tauri-plugin-single-instance",

View File

@ -39,6 +39,7 @@ async-std = "1.13.0"
closure = "0.3.0"
derive_more = { version = "2.0.1", features = ["display"] }
junction = "1.2.0"
tauri-plugin-fs = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }

View File

@ -1,20 +1,18 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default",
"core:window:allow-close",
"core:app:allow-app-hide",
"shell:default",
"shell:default",
"opener:default",
"dialog:default",
"dialog:default",
"deep-link:default"
]
}
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"core:window:allow-close",
"core:app:allow-app-hide",
"shell:default",
"dialog:default",
"deep-link:default",
"fs:default",
"fs:allow-data-read-recursive",
"fs:allow-data-write-recursive"
]
}

View File

@ -1,11 +1,13 @@
use anyhow::{anyhow, Result};
use std::hash::{DefaultHasher, Hash, Hasher};
use crate::pkg::PkgKey;
use crate::Profile;
use crate::pkg_store::PackageStore;
use crate::Profile;
use anyhow::{anyhow, Result};
pub struct AppData {
pub profile: Option<Profile>,
pub pkgs: PackageStore
pub pkgs: PackageStore,
}
impl AppData {
@ -37,4 +39,14 @@ impl AppData {
Ok(())
}
}
pub fn sum_packages(&self, p: &Profile) -> String {
let mut hasher = DefaultHasher::new();
for pkg in &p.mods {
let x = self.pkgs.get(pkg).unwrap().loc.as_ref().unwrap();
pkg.hash(&mut hasher);
x.version.hash(&mut hasher);
}
hasher.finish().to_string()
}
}

View File

@ -20,16 +20,24 @@ pub async fn startline(app: AppHandle) -> Result<(), String> {
let appd = state.lock().await;
if let Some(p) = &appd.profile {
// TODO if p.needsUpdate
liner::line_up(p).await.expect("Line-up failed");
let hash = appd.sum_packages(p);
liner::line_up(p, hash).await
.map_err(|e| e.to_string())?;
start::start(p, app_copy)
.map_err(|e| { log::error!("Error launching: {}", e.to_string()); e.to_string() })
//Ok(())
.map_err(|e| e.to_string())
} else {
Err("No profile".to_owned())
}
}
#[tauri::command]
pub async fn kill() -> Result<(), String> {
start::pkill("amdaemon.exe").await;
// The start routine will kill the other process
Ok(())
}
#[tauri::command]
pub async fn install_package(state: State<'_, tokio::sync::Mutex<AppData>>, key: PkgKey) -> Result<InstallResult, String> {
log::debug!("invoke: install_package({})", key);

View File

@ -107,7 +107,8 @@ pub async fn run(_args: Vec<String>) {
cmd::init_profile,
cmd::save_profile,
cmd::startline,
cmd::set_cfg
cmd::kill,
cmd::set_cfg,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -17,9 +17,23 @@ async fn symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Resul
junction::create(src, dst)
}
pub async fn line_up(p: &Profile) -> Result<()> {
let dir_out = util::profile_dir(&p);
log::info!("Preparing {}", dir_out.to_string_lossy());
pub async fn line_up(p: &Profile, pkg_hash: String) -> Result<()> {
let hash_path = p.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.dir();
let mut futures = JoinSet::new();
if dir_out.join("BepInEx").exists() {
@ -34,25 +48,29 @@ pub async fn line_up(p: &Profile) -> Result<()> {
for m in &p.mods {
log::debug!("Preparing {}", m);
let dir_out = util::profile_dir(&p);
let (namespace, name) = m.0.split_at(m.0.find("-").expect("Invalid mod definition"));
let bpx = util::pkg_dir_of(namespace, &name[1..])
let bpx_dir = util::pkg_dir_of(namespace, &name[1..])
.join("app")
.join("BepInEx");
if bpx.exists() {
util::copy_recursive(&bpx, &dir_out.join("BepInEx"))?;
if bpx_dir.exists() {
util::copy_recursive(&bpx_dir, &dir_out.join("BepInEx"))?;
}
let opt = util::pkg_dir_of(namespace, &name[1..]).join("option");
if opt.exists() {
let x = opt.read_dir().unwrap().next().unwrap()?;
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?;
}
}
}
// Todo temporary
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 = Ini::load_from_str(&ini_in_raw)?;
let mut opt_dir_in = PathBuf::from(
@ -78,7 +96,7 @@ pub async fn line_up(p: &Profile) -> Result<()> {
util::path_to_str(dir_out.join("BepInEx").join("core").join("BepInEx.Preloader.dll"))?
);
if prepare_aime(p).await.unwrap_or(false) {
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"))?);
@ -93,16 +111,4 @@ pub async fn line_up(p: &Profile) -> Result<()> {
}
Ok(())
}
// Todo multiple codes
async fn prepare_aime(p: &Profile) -> Result<bool> {
if p.get_bool("aime", true) {
if let Some(code) = p.cfg.get("aime-code") {
let code = code.as_str().expect("Invalid config");
fs::write(util::profile_dir(&p).join("aime.txt"), code).await?;
return Ok(true);
}
}
Ok(false)
}

View File

@ -6,7 +6,7 @@ use tokio::fs;
use crate::{model::{local, rainy}, util};
// {namespace}-{name}
#[derive(Eq, Hash, PartialEq, Clone, Serialize, Deserialize, Display)]
#[derive(Eq, Hash, PartialEq, PartialOrd, Ord, Clone, Serialize, Deserialize, Display)]
pub struct PkgKey(pub String);
// {namespace}-{name}-{version}

View File

@ -1,4 +1,5 @@
use std::{collections::{HashMap, HashSet}, path::PathBuf};
use anyhow::Result;
use std::{collections::{BTreeSet, HashMap}, path::PathBuf};
use crate::{model::misc, pkg::PkgKey, util};
use serde::{Deserialize, Serialize};
use tokio::fs;
@ -11,7 +12,7 @@ pub struct Profile {
pub game: misc::Game,
pub exe_dir: PathBuf,
pub name: String,
pub mods: HashSet<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
@ -25,7 +26,7 @@ impl Profile {
game: misc::Game::Ongeki,
exe_dir: exe_path.parent().unwrap().to_owned(),
name: "ongeki-default".to_owned(),
mods: HashSet::new(),
mods: BTreeSet::new(),
#[cfg(target_os = "linux")]
wine_runtime: Some(std::path::Path::new("/usr/bin/wine").to_path_buf()),
@ -45,6 +46,13 @@ impl Profile {
}
}
pub fn dir(&self) -> PathBuf {
util::get_dirs()
.data_dir()
.join("profile-".to_owned() + &self.name)
.to_owned()
}
pub fn load() -> Option<Profile> {
let path = util::get_dirs()
.config_dir()
@ -65,6 +73,11 @@ impl Profile {
log::info!("Written to {}", path.to_string_lossy());
}
pub fn get_cfg(&self, key: &str) -> Result<&serde_json::Value> {
self.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)
.and_then(|c| c.as_bool())

View File

@ -1,50 +1,40 @@
use anyhow::Result;
use std::fs::File;
use tokio::process::Command;
use tauri::{AppHandle, Emitter};
use std::process::Stdio;
use crate::profile::Profile;
use crate::util;
#[cfg(target_os = "linux")]
pub fn start(p: &Profile, app: AppHandle) -> Result<()> {
let p = p.clone();
tauri::async_runtime::spawn(async move {
let rv = Command::new(p.wine_runtime.as_ref().unwrap())
.env(
"SEGATOOLS_CONFIG_PATH",
util::profile_dir(&p).join("segatools.ini"),
)
.env("WINEPREFIX", p.wine_prefix.as_ref().unwrap())
.arg(p.exe_dir.join("start.bat"))
.spawn();
match rv {
Ok(mut child) => {
_ = child.wait().await;
log::debug!("Fin");
},
Err(e) => {
log::error!("Fail: {}", e);
}
}
_ = app.emit("launch-end", "");
});
Ok(())
}
#[cfg(target_os = "windows")]
static CREATE_NO_WINDOW: i32 = 0x08000000;
pub fn start(p: &Profile, app: AppHandle) -> Result<()> {
use std::process::Stdio;
use tokio::task::JoinSet;
let create_no_window = 0x08000000;
let ini_path = util::profile_dir(&p).join("segatools.ini");
let ini_path = p.dir().join("segatools.ini");
log::debug!("With path {}", ini_path.to_string_lossy());
log::info!("Launching amdaemon");
let mut amd_builder = Command::new("cmd.exe");
let mut game_builder = Command::new(p.exe_dir.join("inject.exe"));
let mut game_builder;
let mut amd_builder;
#[cfg(target_os = "windows")]
{
game_builder = Command::new(p.exe_dir.join("inject.exe"));
amd_builder = Command::new("cmd.exe");
}
#[cfg(target_os = "linux")]
{
let wine = p.wine_runtime.as_ref()
.expect("No wine path specified");
game_builder = Command::new(wine);
amd_builder = Command::new(wine);
game_builder.arg(p.exe_dir.join("inject.exe"));
amd_builder.arg("cmd.exe");
}
let display_mode = p.get_str("display-mode", "borderless");
@ -76,24 +66,38 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> {
game_builder.arg("-popupwindow");
}
if !cfg!(debug_assertions) {
amd_builder
.creation_flags(create_no_window)
// Obviously, this is a meme
// Output will be handled properly at a later time
.stdout(Stdio::null())
.stderr(Stdio::null());
#[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);
}
game_builder
.creation_flags(create_no_window)
.stdout(Stdio::null())
.stderr(Stdio::null());
let amd_log = File::create(p.dir().join("amdaemon.log"))?;
let game_log = File::create(p.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");
}
log::info!("Launching amdaemon: {:?}", amd_builder);
log::info!("Launching mu3: {:?}", game_builder);
let mut amd = amd_builder.spawn()?;
let mut game = game_builder.spawn()?;
@ -101,19 +105,24 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> {
let mut set = JoinSet::new();
set.spawn(async move {
amd.wait().await.expect("amdaemon failed to run")
(amd.wait().await.expect("amdaemon failed to run"), "amdaemon.exe")
});
set.spawn(async move {
game.wait().await.expect("mu3 failed to run")
(game.wait().await.expect("mu3 failed to run"), "mu3.exe")
});
let res = set.join_next().await.expect("No spawn").expect("No result");
_ = app.emit("launch-start", "");
log::info!("One of the processes died with return code {}", res);
let (rc, process_name) = set.join_next().await.expect("No spawn").expect("No result");
_ = Command::new("taskkill.exe").arg("/f").arg("/im").arg("amdaemon.exe").creation_flags(create_no_window).output().await;
_ = Command::new("taskkill.exe").arg("/f").arg("/im").arg("mu3.exe").creation_flags(create_no_window).output().await;
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");
@ -123,4 +132,16 @@ pub fn start(p: &Profile, app: AppHandle) -> Result<()> {
});
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

@ -2,8 +2,6 @@ use anyhow::{anyhow, Result};
use directories::ProjectDirs;
use std::path::{Path, PathBuf};
use crate::profile::Profile;
pub fn get_dirs() -> ProjectDirs {
ProjectDirs::from("org", "7EVENDAYSHOLIDAYS", "STARTLINER")
.expect("Unable to set up config directories")
@ -21,13 +19,6 @@ pub fn pkg_dir_of(namespace: &str, name: &str) -> PathBuf {
pkg_dir().join(format!("{}-{}", namespace, name)).to_owned()
}
pub fn profile_dir(p: &Profile) -> PathBuf {
get_dirs()
.data_dir()
.join("profile-".to_owned() + &p.name)
.to_owned()
}
pub fn cache_dir() -> PathBuf {
get_dirs().cache_dir().to_owned()
}

View File

@ -6,13 +6,12 @@ import TabList from 'primevue/tablist';
import TabPanel from 'primevue/tabpanel';
import TabPanels from 'primevue/tabpanels';
import Tabs from 'primevue/tabs';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
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 StartButton from './StartButton.vue';
import { usePkgStore } from '../stores';
import { changePrimaryColor } from '../util';
@ -20,7 +19,6 @@ const store = usePkgStore();
store.setupListeners();
const currentTab = ref('3');
const startEnabled = ref(false);
const loadProfile = async (openWindow: boolean) => {
await store.reloadProfile();
@ -42,7 +40,6 @@ const loadProfile = async (openWindow: boolean) => {
}
if (store.profile !== null) {
changePrimaryColor(store.profile.game);
startEnabled.value = true;
currentTab.value = '0';
}
@ -51,11 +48,6 @@ const loadProfile = async (openWindow: boolean) => {
const isProfileDisabled = computed(() => store.profile === null);
const startline = async () => {
startEnabled.value = false;
await invoke('startline');
};
onOpenUrl((urls) => {
console.log('deep link:', urls);
});
@ -63,10 +55,6 @@ onOpenUrl((urls) => {
onMounted(async () => {
await loadProfile(false);
});
listen('launch-end', () => {
startEnabled.value = true;
});
</script>
<template>
@ -87,15 +75,7 @@ listen('launch-end', () => {
><div class="pi pi-question-circle"></div
></Tab>
<div class="grow"></div>
<Button
:disabled="!startEnabled"
icon="pi pi-play"
label="START"
aria-label="start"
size="small"
class="m-2.5"
@click="startline()"
/>
<StartButton />
</TabList>
</div>
<TabPanels class="w-full grow mt-[3rem]">

View File

@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import Fieldset from 'primevue/fieldset';
import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import RadioButton from 'primevue/radiobutton';
import Toggle from 'primevue/toggleswitch';
import * as path from '@tauri-apps/api/path';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
import { usePkgStore } from '../stores';
const store = usePkgStore();
@ -23,7 +25,32 @@ const cfgRezW = _cfg('rez-w', 1080);
const cfgRezH = _cfg('rez-h', 1920);
const cfgDisplayMode = _cfg('display-mode', 'borderless');
const cfgAime = _cfg('aime', false);
const cfgAimeCode = _cfg('aime-code', '');
const aimeCode = ref('');
// temp
let aimePath = '';
(async () => {
aimePath = await path.join(
await path.dataDir(),
'startliner/profile-ongeki-default/aime.txt'
);
aimeCode.value = await readTextFile(aimePath);
})();
path.homeDir().then(console.log);
const aimeCodeModel = computed({
get() {
return aimeCode.value;
},
async set(value: string) {
aimeCode.value = value;
if (value.match(/^[0-9]{20}$/)) {
await writeTextFile(aimePath, aimeCode.value);
}
},
});
</script>
<template>
@ -98,13 +125,9 @@ const cfgAimeCode = _cfg('aime-code', '');
class="shrink"
size="small"
:disabled="store.cfg('aime') !== true"
:invalid="
store.cfg('aime') === true &&
store.cfg('aime-code')?.toString().length !== 20
"
:maxlength="20"
placeholder="00000000000000000000"
v-model="cfgAimeCode"
v-model="aimeCodeModel"
/>
</label>
</div>

View File

@ -0,0 +1,59 @@
<script setup lang="ts">
import { Ref, ref } from 'vue';
import Button from 'primevue/button';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
type StartStatus = 'ready' | 'preparing' | 'running';
const startStatus: Ref<StartStatus> = ref('ready');
const startline = async () => {
startStatus.value = 'preparing';
await invoke('startline');
};
const kill = async () => {
await invoke('kill');
startStatus.value = 'ready';
};
listen('launch-start', () => {
startStatus.value = 'running';
});
listen('launch-end', () => {
startStatus.value = 'ready';
});
</script>
<template>
<Button
v-if="startStatus === 'ready'"
:disabled="false"
icon="pi pi-play"
label="START"
aria-label="start"
size="small"
class="m-2.5"
@click="startline()"
/>
<Button
v-else-if="startStatus === 'preparing'"
disabled
icon="pi pi-spin pi-spinner"
label="START"
aria-label="start"
size="small"
class="m-2.5"
/>
<Button
v-else
:disabled="false"
icon="pi pi-ban"
label="STOP"
aria-label="stop"
size="small"
class="m-2.5"
@click="kill()"
/>
</template>