feat: start checks

This commit is contained in:
2025-03-16 17:55:38 +00:00
parent 08d6a2a2fe
commit 8d55e92fc9
26 changed files with 456 additions and 211 deletions

View File

@ -1,7 +1,6 @@
### Short-term ### Short-term
- CHUNITHM support - CHUNITHM support
- Start checks
- https://gitea.tendokyu.moe/TeamTofuShop/segatools/issues/63 - https://gitea.tendokyu.moe/TeamTofuShop/segatools/issues/63
### Long-term ### Long-term

View File

@ -1,19 +1,14 @@
use std::hash::{DefaultHasher, Hash, Hasher}; use std::hash::{DefaultHasher, Hash, Hasher};
use crate::model::config::GlobalConfig;
use crate::profiles::AnyProfile; use crate::profiles::AnyProfile;
use crate::{model::misc::Game, pkg::PkgKey}; use crate::{model::misc::Game, pkg::PkgKey};
use crate::pkg_store::PackageStore; use crate::pkg_store::PackageStore;
use crate::util; use crate::util;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use tauri::AppHandle; use tauri::AppHandle;
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct GlobalConfig {
pub recent_profile: Option<(Game, String)>
}
pub struct GlobalState { pub struct GlobalState {
pub remain_open: bool pub remain_open: bool,
} }
pub struct AppData { pub struct AppData {
@ -34,6 +29,8 @@ impl AppData {
None => None None => None
}; };
log::debug!("Recent profile: {:?}", profile);
AppData { AppData {
profile: profile, profile: profile,
pkgs: PackageStore::new(apph.clone()), pkgs: PackageStore::new(apph.clone()),
@ -43,7 +40,7 @@ impl AppData {
} }
pub fn write(&self) -> Result<(), std::io::Error> { pub fn write(&self) -> Result<(), std::io::Error> {
std::fs::write(util::config_dir().join("config.json"), serde_json::to_string(&self.cfg)?) std::fs::write(util::config_dir().join("config.json"), serde_json::to_string_pretty(&self.cfg)?)
} }
pub fn switch_profile(&mut self, game: Game, name: String) -> Result<()> { pub fn switch_profile(&mut self, game: Game, name: String) -> Result<()> {
@ -72,12 +69,12 @@ impl AppData {
let loc = pkg.loc let loc = pkg.loc
.clone() .clone()
.ok_or_else(|| anyhow!("Attempted to enable a non-existent package"))?; .ok_or_else(|| anyhow!("Attempted to enable a non-existent package"))?;
profile.pkgs_mut().insert(key); profile.mod_pkgs_mut().insert(key);
for d in &loc.dependencies { for d in &loc.dependencies {
_ = self.toggle_package(d.clone(), true); _ = self.toggle_package(d.clone(), true);
} }
} else { } else {
profile.pkgs_mut().remove(&key); profile.mod_pkgs_mut().remove(&key);
for (ckey, pkg) in self.pkgs.get_all() { for (ckey, pkg) in self.pkgs.get_all() {
if let Some(loc) = pkg.loc { if let Some(loc) = pkg.loc {
if loc.dependencies.contains(&key) { if loc.dependencies.contains(&key) {
@ -92,10 +89,13 @@ impl AppData {
pub fn sum_packages(&self, p: &AnyProfile) -> String { pub fn sum_packages(&self, p: &AnyProfile) -> String {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
for pkg in p.pkgs().into_iter() { for key in p.mod_pkgs().into_iter() {
let x = self.pkgs.get(&pkg).unwrap().loc.as_ref().unwrap(); if let Ok(pkg) = self.pkgs.get(&key) {
pkg.hash(&mut hasher); if let Some(loc) = &pkg.loc {
x.version.hash(&mut hasher); key.hash(&mut hasher);
loc.version.hash(&mut hasher);
}
}
} }
hasher.finish().to_string() hasher.finish().to_string()
} }

View File

@ -5,12 +5,50 @@ use tokio::fs;
use tauri::{AppHandle, Manager, State}; use tauri::{AppHandle, Manager, State};
use crate::model::misc::Game; use crate::model::misc::Game;
use crate::pkg::{Package, PkgKey}; use crate::pkg::{Package, PkgKey};
use crate::pkg_store::InstallResult; use crate::pkg_store::{InstallResult, PackageStore};
use crate::profiles::ongeki::OngekiProfile; use crate::profiles::ongeki::OngekiProfile;
use crate::profiles::{self, AnyProfile, Profile, ProfileMeta, ProfilePaths}; use crate::profiles::{self, AnyProfile, Profile, ProfileMeta, ProfilePaths};
use crate::appdata::AppData; use crate::appdata::AppData;
use crate::model::misc::StartCheckError;
use crate::util; use crate::util;
#[tauri::command]
pub async fn start_check(state: State<'_, Mutex<AppData>>) -> Result<Vec<StartCheckError>, String> {
log::debug!("invoke: start_check");
let appd = state.lock().await;
let prf = appd.profile.as_ref().ok_or_else(|| format!("No profile to check"))?;
let pkgs = appd.pkgs.get_all();
let mut res = Vec::new();
for key in prf.mod_pkgs() {
if let Some(pkg) = pkgs.get(key) {
if let Some(loc) = &pkg.loc {
for dep in &loc.dependencies {
if !prf.mod_pkgs().contains(dep) {
res.push(StartCheckError::MissingDependency(key.clone(), dep.clone()));
}
}
} else {
res.push(StartCheckError::MissingLocalPackage(key.clone()));
}
} else {
res.push(StartCheckError::MissingRemotePackage(key.clone()));
}
}
for key in prf.special_pkgs() {
log::debug!("special: {}", key);
if let Some(pkg) = pkgs.get(&key) {
if pkg.loc.is_some() {
continue;
}
}
res.push(StartCheckError::MissingTool(key));
}
Ok(res)
}
#[tauri::command] #[tauri::command]
pub async fn startline(app: AppHandle) -> Result<(), String> { pub async fn startline(app: AppHandle) -> Result<(), String> {
log::debug!("invoke: startline"); log::debug!("invoke: startline");
@ -110,9 +148,25 @@ pub async fn get_all_packages(state: State<'_, Mutex<AppData>>) -> Result<HashMa
pub async fn fetch_listings(state: State<'_, Mutex<AppData>>) -> Result<(), String> { pub async fn fetch_listings(state: State<'_, Mutex<AppData>>) -> Result<(), String> {
log::debug!("invoke: fetch_listings"); log::debug!("invoke: fetch_listings");
{
let appd = state.lock().await;
if !appd.pkgs.is_offline() {
log::warn!("fetch_listings: already done");
return Ok(());
}
if appd.cfg.offline_mode {
log::info!("fetch_listings: skipped");
return Err("offline mode".to_owned());
}
}
let listings = PackageStore::fetch_listings().await
.map_err(|e| e.to_string())?;
let mut appd = state.lock().await; let mut appd = state.lock().await;
appd.pkgs.fetch_listings().await appd.pkgs.process_fetched_listings(listings);
.map_err(|e| e.to_string())
Ok(())
} }
#[tauri::command] #[tauri::command]

View File

@ -144,6 +144,10 @@ pub async fn run(_args: Vec<String>) {
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
cmd::start_check,
cmd::startline,
cmd::kill,
cmd::get_package, cmd::get_package,
cmd::get_all_packages, cmd::get_all_packages,
cmd::reload_all_packages, cmd::reload_all_packages,
@ -161,9 +165,6 @@ pub async fn run(_args: Vec<String>) {
cmd::get_current_profile, cmd::get_current_profile,
cmd::save_current_profile, cmd::save_current_profile,
cmd::startline,
cmd::kill,
cmd::list_displays, cmd::list_displays,
cmd::list_platform_capabilities, cmd::list_platform_capabilities,
cmd::list_directories, cmd::list_directories,
@ -189,8 +190,8 @@ fn deep_link(app: AppHandle, args: Vec<String>) {
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
let mutex = app.state::<Mutex<AppData>>(); let mutex = app.state::<Mutex<AppData>>();
let mut appd = mutex.lock().await; let mut appd = mutex.lock().await;
if let Err(e) = appd.pkgs.fetch_listings().await { if appd.pkgs.is_offline() {
log::warn!("Deep link fetch failed: {:?}", e); log::warn!("Deep link installation failed: offline");
} else if let Err(e) = appd.pkgs.install_package(&key, true, true).await { } else if let Err(e) = appd.pkgs.install_package(&key, true, true).await {
log::warn!("Deep link installation failed: {}", e.to_string()); log::warn!("Deep link installation failed: {}", e.to_string());
} }

View File

@ -1,102 +1,10 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::pkg::PkgKey; use super::misc::Game;
#[derive(Deserialize, Serialize, Clone)] #[derive(Serialize, Deserialize, Clone, Default)]
pub struct Segatools { pub struct GlobalConfig {
pub target: PathBuf, pub recent_profile: Option<(Game, String)>,
pub hook: Option<PkgKey>,
pub io: Option<PkgKey>,
pub amfs: PathBuf,
pub option: PathBuf,
pub appdata: PathBuf,
pub enable_aime: bool,
pub intel: bool,
}
impl Default for Segatools { #[serde(default)]
fn default() -> Self { pub offline_mode: bool,
Segatools {
target: PathBuf::default(),
hook: Some(PkgKey("segatools-mu3hook".to_owned())),
io: None,
amfs: PathBuf::default(),
option: PathBuf::default(),
appdata: PathBuf::from("appdata"),
enable_aime: false,
intel: false
}
}
}
#[derive(Deserialize, Serialize, Clone, Default, PartialEq)]
pub enum DisplayMode {
Window,
#[default] Borderless,
Fullscreen
}
#[derive(Deserialize, Serialize, Clone)]
pub struct Display {
pub target: String,
pub rez: (i32, i32),
pub mode: DisplayMode,
pub rotation: i32,
pub frequency: i32,
pub borderless_fullscreen: bool,
}
impl Default for Display {
fn default() -> Self {
Display {
target: "default".to_owned(),
rez: (1080, 1920),
mode: DisplayMode::Borderless,
rotation: 0,
frequency: 60,
borderless_fullscreen: true,
}
}
}
#[derive(Deserialize, Serialize, Clone, Default, PartialEq)]
pub enum NetworkType {
#[default] Remote,
Artemis,
}
#[derive(Deserialize, Serialize, Clone, Default)]
pub struct Network {
pub network_type: NetworkType,
pub local_path: PathBuf,
pub local_console: bool,
pub remote_address: String,
pub keychip: String,
pub subnet: String,
pub suffix: Option<i32>,
}
#[derive(Deserialize, Serialize, Clone, Default)]
pub struct BepInEx {
pub console: bool,
}
#[derive(Deserialize, Serialize, Clone)]
pub struct Wine {
pub runtime: PathBuf,
pub prefix: PathBuf,
}
impl Default for Wine {
fn default() -> Self {
Wine {
runtime: PathBuf::from("/usr/bin/wine"),
prefix: std::env::var("HOME")
.and_then(|home| Ok(PathBuf::from(home).join(".wine")))
.unwrap_or_default()
}
}
} }

View File

@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::pkg::PkgKey;
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
pub enum Game { pub enum Game {
@ -26,3 +27,11 @@ impl std::fmt::Display for Game {
} }
} }
} }
#[derive(Serialize, Deserialize)]
pub enum StartCheckError {
MissingRemotePackage(PkgKey),
MissingLocalPackage(PkgKey),
MissingDependency(PkgKey, PkgKey),
MissingTool(PkgKey),
}

View File

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

102
rust/src/model/profile.rs Normal file
View File

@ -0,0 +1,102 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::pkg::PkgKey;
#[derive(Deserialize, Serialize, Clone)]
pub struct Segatools {
pub target: PathBuf,
pub hook: Option<PkgKey>,
pub io: Option<PkgKey>,
pub amfs: PathBuf,
pub option: PathBuf,
pub appdata: PathBuf,
pub enable_aime: bool,
pub intel: bool,
}
impl Default for Segatools {
fn default() -> Self {
Segatools {
target: PathBuf::default(),
hook: Some(PkgKey("segatools-mu3hook".to_owned())),
io: None,
amfs: PathBuf::default(),
option: PathBuf::default(),
appdata: PathBuf::from("appdata"),
enable_aime: false,
intel: false
}
}
}
#[derive(Deserialize, Serialize, Clone, Default, PartialEq)]
pub enum DisplayMode {
Window,
#[default] Borderless,
Fullscreen
}
#[derive(Deserialize, Serialize, Clone)]
pub struct Display {
pub target: String,
pub rez: (i32, i32),
pub mode: DisplayMode,
pub rotation: i32,
pub frequency: i32,
pub borderless_fullscreen: bool,
}
impl Default for Display {
fn default() -> Self {
Display {
target: "default".to_owned(),
rez: (1080, 1920),
mode: DisplayMode::Borderless,
rotation: 0,
frequency: 60,
borderless_fullscreen: true,
}
}
}
#[derive(Deserialize, Serialize, Clone, Default, PartialEq)]
pub enum NetworkType {
#[default] Remote,
Artemis,
}
#[derive(Deserialize, Serialize, Clone, Default)]
pub struct Network {
pub network_type: NetworkType,
pub local_path: PathBuf,
pub local_console: bool,
pub remote_address: String,
pub keychip: String,
pub subnet: String,
pub suffix: Option<i32>,
}
#[derive(Deserialize, Serialize, Clone, Default)]
pub struct BepInEx {
pub console: bool,
}
#[derive(Deserialize, Serialize, Clone)]
pub struct Wine {
pub runtime: PathBuf,
pub prefix: PathBuf,
}
impl Default for Wine {
fn default() -> Self {
Wine {
runtime: PathBuf::from("/usr/bin/wine"),
prefix: std::env::var("HOME")
.and_then(|home| Ok(PathBuf::from(home).join(".wine")))
.unwrap_or_default()
}
}
}

View File

@ -1,6 +1,6 @@
use anyhow::Result; use anyhow::Result;
use ini::Ini; use ini::Ini;
use crate::{model::config::BepInEx, profiles::ProfilePaths}; use crate::{model::profile::BepInEx, profiles::ProfilePaths};
impl BepInEx { impl BepInEx {
pub fn line_up(&self, p: &impl ProfilePaths) -> Result<()> { pub fn line_up(&self, p: &impl ProfilePaths) -> Result<()> {

View File

@ -1,5 +1,5 @@
use crate::model::config::{Display, DisplayMode}; use crate::model::profile::{Display, DisplayMode};
use anyhow::Result; use anyhow::Result;
use displayz::{query_displays, DisplaySet}; use displayz::{query_displays, DisplaySet};
use tauri::{AppHandle, Listener}; use tauri::{AppHandle, Listener};

View File

@ -2,7 +2,7 @@ use std::{path::PathBuf, process::Command};
use yaml_rust2::YamlLoader; use yaml_rust2::YamlLoader;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use ini::Ini; use ini::Ini;
use crate::model::config::{Network, NetworkType}; use crate::model::profile::{Network, NetworkType};
impl Network { impl Network {
pub fn line_up(&self, ini: &mut Ini) -> Result<()> { pub fn line_up(&self, ini: &mut Ini) -> Result<()> {
@ -48,9 +48,10 @@ impl Network {
cmd = Command::new("cmd.exe"); cmd = Command::new("cmd.exe");
cmd.arg("/C"); cmd.arg("/C");
if self.local_console == true { // if self.local_console == true {
cmd.arg("start"); cmd.arg("start");
} cmd.arg("/min");
// }
} else { } else {
cmd = Command::new("sh"); cmd = Command::new("sh");
} }

View File

@ -2,7 +2,7 @@ use std::path::PathBuf;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use ini::Ini; use ini::Ini;
use crate::{model::{config::Segatools, segatools_base::segatools_base}, profiles::ProfilePaths, util::{self, PathStr}}; use crate::{model::{profile::Segatools, segatools_base::segatools_base}, profiles::ProfilePaths, util::{self, PathStr}};
impl Segatools { impl Segatools {
pub async fn line_up(&self, p: &impl ProfilePaths) -> Result<Ini> { pub async fn line_up(&self, p: &impl ProfilePaths) -> Result<Ini> {

View File

@ -12,9 +12,9 @@ use crate::download_handler::DownloadHandler;
pub struct PackageStore { pub struct PackageStore {
store: HashMap<PkgKey, Package>, store: HashMap<PkgKey, Package>,
has_fetched: bool,
app: AppHandle, app: AppHandle,
dlh: DownloadHandler dlh: DownloadHandler,
offline: bool,
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
@ -31,9 +31,9 @@ impl PackageStore {
pub fn new(app: AppHandle) -> PackageStore { pub fn new(app: AppHandle) -> PackageStore {
PackageStore { PackageStore {
store: HashMap::new(), store: HashMap::new(),
has_fetched: false,
app: app.clone(), app: app.clone(),
dlh: DownloadHandler::new(app) dlh: DownloadHandler::new(app),
offline: true
} }
} }
@ -75,11 +75,7 @@ impl PackageStore {
Ok(()) Ok(())
} }
pub async fn fetch_listings(&mut self) -> Result<()> { pub async fn fetch_listings() -> Result<Vec<rainy::V1Package>> {
if self.has_fetched {
return Ok(());
}
use async_compression::futures::bufread::GzipDecoder; use async_compression::futures::bufread::GzipDecoder;
use futures::{ use futures::{
io::{self, BufReader, ErrorKind}, io::{self, BufReader, ErrorKind},
@ -87,6 +83,7 @@ impl PackageStore {
}; };
let response = reqwest::get("https://rainy.patafour.zip/c/ongeki/api/v1/package/").await?; let response = reqwest::get("https://rainy.patafour.zip/c/ongeki/api/v1/package/").await?;
let reader = response let reader = response
.bytes_stream() .bytes_stream()
.map_err(|e| io::Error::new(ErrorKind::Other, e)) .map_err(|e| io::Error::new(ErrorKind::Other, e))
@ -96,9 +93,14 @@ impl PackageStore {
let mut data = String::new(); let mut data = String::new();
decoder.read_to_string(&mut data).await?; decoder.read_to_string(&mut data).await?;
let listings: Vec<rainy::V1Package> = serde_json::from_str(&data) Ok(serde_json::from_str(&data)?)
.expect("Invalid JSON"); }
pub fn is_offline(&self) -> bool {
self.offline
}
pub fn process_fetched_listings(&mut self, listings: Vec<rainy::V1Package>) {
for listing in listings { for listing in listings {
// This is None if the package has no versions for whatever reason // This is None if the package has no versions for whatever reason
if let Some(r) = Package::from_rainy(listing) { if let Some(r) = Package::from_rainy(listing) {
@ -114,9 +116,7 @@ impl PackageStore {
} }
} }
self.has_fetched = true; self.offline = false;
Ok(())
} }
pub async fn install_package(&mut self, key: &PkgKey, force: bool, install_deps: bool) -> Result<InstallResult> { pub async fn install_package(&mut self, key: &PkgKey, force: bool, install_deps: bool) -> Result<InstallResult> {

View File

@ -59,17 +59,30 @@ impl AnyProfile {
} }
} }
} }
pub fn mod_pkgs(&self) -> &BTreeSet<PkgKey> {
pub fn pkgs(&self) -> &BTreeSet<PkgKey> {
match self { match self {
Self::OngekiProfile(p) => &p.mods Self::OngekiProfile(p) => &p.mods
} }
} }
pub fn pkgs_mut(&mut self) -> &mut BTreeSet<PkgKey> { pub fn mod_pkgs_mut(&mut self) -> &mut BTreeSet<PkgKey> {
match self { match self {
Self::OngekiProfile(p) => &mut p.mods Self::OngekiProfile(p) => &mut p.mods
} }
} }
pub fn special_pkgs(&self) -> Vec<PkgKey> {
let mut res = Vec::new();
match self {
Self::OngekiProfile(p) => {
if let Some(hook) = &p.sgt.hook {
res.push(hook.clone());
}
if let Some(io) = &p.sgt.io {
res.push(io.clone());
}
}
}
res
}
pub async fn line_up(&self, pkg_hash: String, _app: AppHandle) -> Result<()> { pub async fn line_up(&self, pkg_hash: String, _app: AppHandle) -> Result<()> {
match self { match self {
Self::OngekiProfile(_p) => { Self::OngekiProfile(_p) => {
@ -80,7 +93,7 @@ impl AnyProfile {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
if let Some(info) = info { if let Some(info) = info {
use crate::model::config::Display; use crate::model::profile::Display;
if res.is_ok() { if res.is_ok() {
Display::wait_for_exit(_app, info); Display::wait_for_exit(_app, info);
} else { } else {
@ -141,6 +154,14 @@ impl AnyProfile {
} }
} }
impl std::fmt::Debug for AnyProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OngekiProfile(p) => f.debug_tuple("ongeki").field(&p.name).finish(),
}
}
}
pub async fn list_profiles() -> Result<Vec<ProfileMeta>> { pub async fn list_profiles() -> Result<Vec<ProfileMeta>> {
let path = std::fs::read_dir(util::config_dir())?; let path = std::fs::read_dir(util::config_dir())?;

View File

@ -2,9 +2,9 @@ use serde::{Deserialize, Serialize};
use tauri::AppHandle; use tauri::AppHandle;
use tauri::Emitter; use tauri::Emitter;
use std::{collections::BTreeSet, path::PathBuf, process::Stdio}; use std::{collections::BTreeSet, path::PathBuf, process::Stdio};
use crate::model::config::BepInEx; use crate::model::profile::BepInEx;
use crate::profiles::fixed_name; use crate::profiles::fixed_name;
use crate::{model::{config::{Display, DisplayMode, Network, Segatools}, misc::Game, segatools_base::segatools_base}, pkg::PkgKey, util}; use crate::{model::{profile::{Display, DisplayMode, Network, Segatools}, misc::Game, segatools_base::segatools_base}, pkg::PkgKey, util};
use super::{Profile, ProfileMeta, ProfilePaths}; use super::{Profile, ProfileMeta, ProfilePaths};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use std::fs::File; use std::fs::File;
@ -25,7 +25,7 @@ pub struct OngekiProfile {
pub bepinex: BepInEx, pub bepinex: BepInEx,
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
pub wine: crate::model::config::Wine, pub wine: crate::model::profile::Wine,
} }
impl Profile for OngekiProfile { impl Profile for OngekiProfile {
@ -40,7 +40,7 @@ impl Profile for OngekiProfile {
network: Network::default(), network: Network::default(),
bepinex: BepInEx::default(), bepinex: BepInEx::default(),
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
wine: crate::model::config::Wine::default(), wine: crate::model::profile::Wine::default(),
}; };
p.save()?; p.save()?;
std::fs::write(p.config_dir().join("segatools-base.ini"), segatools_base())?; std::fs::write(p.config_dir().join("segatools-base.ini"), segatools_base())?;

View File

@ -38,8 +38,8 @@ onMounted(async () => {
await Promise.all([prf.reloadList(), prf.reload()]); await Promise.all([prf.reloadList(), prf.reload()]);
if (prf.current !== null) { if (prf.current !== null) {
await pkg.reloadAll();
currentTab.value = 0; currentTab.value = 0;
await pkg.reloadAll();
} }
fetch_promise.then(async () => { fetch_promise.then(async () => {
@ -65,7 +65,7 @@ onMounted(async () => {
><div class="pi pi-list-check"></div ><div class="pi pi-list-check"></div
></Tab> ></Tab>
<Tab <Tab
v-if="!pkg.offline" v-if="pkg.networkStatus === 'online'"
:disabled="isProfileDisabled" :disabled="isProfileDisabled"
:value="1" :value="1"
><div class="pi pi-download"></div ><div class="pi pi-download"></div
@ -98,7 +98,14 @@ onMounted(async () => {
/> />
</div> </div>
<Button <Button
v-if="pkg.offline" v-if="pkg.networkStatus === 'connecting'"
class="shrink self-center"
icon="pi pi-sync pi-spin"
size="small"
:disabled="true"
/>
<Button
v-if="pkg.networkStatus === 'offline'"
class="shrink self-center" class="shrink self-center"
icon="pi pi-sync" icon="pi pi-sync"
size="small" size="small"
@ -151,6 +158,7 @@ onMounted(async () => {
<style lang="css"> <style lang="css">
@import 'tailwindcss'; @import 'tailwindcss';
@import 'primeicons/primeicons.css';
.p-tablist-tab-list { .p-tablist-tab-list {
height: 3rem; height: 3rem;

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import Button from 'primevue/button';
import { open } from '@tauri-apps/plugin-shell';
import { Package } from '../types';
defineProps({
pkg: Object as () => Package,
});
</script>
<template>
<Button
rounded
icon="pi pi-external-link"
severity="info"
aria-label="storepage"
size="small"
class="self-center ml-2"
style="width: 2rem; height: 2rem"
:disabled="!pkg?.rmt"
v-on:click="open(pkg!.rmt!.package_url)"
/>
</template>

View File

@ -1,21 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { computed, ref } from 'vue';
import Button from 'primevue/button';
import Fieldset from 'primevue/fieldset'; import Fieldset from 'primevue/fieldset';
import ModListEntry from './ModListEntry.vue'; import ModListEntry from './ModListEntry.vue';
import { usePkgStore } from '../stores'; import ModTitlecard from './ModTitlecard.vue';
import { usePkgStore, usePrfStore } from '../stores';
import { Package } from '../types';
const props = defineProps({ const props = defineProps({
search: String, search: String,
}); });
const pkg = usePkgStore(); const pkgs = usePkgStore();
const prf = usePrfStore();
const empty = ref(true); const empty = ref(true);
const group = () => { const group = () => {
const a = Object.assign( const a = Object.assign(
{}, {},
Object.groupBy( Object.groupBy(
pkg.allLocal pkgs.allLocal
.filter( .filter(
(p) => (p) =>
props.search === undefined || props.search === undefined ||
@ -34,9 +38,36 @@ const group = () => {
empty.value = Object.keys(a).length === 0; empty.value = Object.keys(a).length === 0;
return a; return a;
}; };
const missing = computed(() => {
return prf.current?.mods.filter((m) => !pkgs.hasLocal(m));
});
</script> </script>
<template> <template>
<Fieldset legend="Missing" v-if="(missing?.length ?? 0) > 0">
<div class="flex items-center" v-for="p in missing">
<ModTitlecard
show-namespace
:pkg="
{
namespace: p.split('-')[0],
name: p.split('-')[1],
} as Package
"
/>
<Button
rounded
icon="pi pi-minus"
severity="danger"
aria-label="install"
size="small"
class="self-center ml-4"
style="width: 2rem; height: 2rem"
v-on:click="prf.togglePkg(p, false)"
/>
</div>
</Fieldset>
<Fieldset v-for="(namespace, key) in group()" :legend="key.toString()"> <Fieldset v-for="(namespace, key) in group()" :legend="key.toString()">
<ModListEntry v-for="p in namespace" :pkg="p" /> <ModListEntry v-for="p in namespace" :pkg="p" />
</Fieldset> </Fieldset>

View File

@ -4,12 +4,14 @@ import Button from 'primevue/button';
import ToggleSwitch from 'primevue/toggleswitch'; import ToggleSwitch from 'primevue/toggleswitch';
import { open } from '@tauri-apps/plugin-shell'; import { open } from '@tauri-apps/plugin-shell';
import InstallButton from './InstallButton.vue'; import InstallButton from './InstallButton.vue';
import LinkButton from './LinkButton.vue';
import ModTitlecard from './ModTitlecard.vue'; import ModTitlecard from './ModTitlecard.vue';
import UpdateButton from './UpdateButton.vue'; import UpdateButton from './UpdateButton.vue';
import { usePrfStore } from '../stores'; import { usePkgStore, usePrfStore } from '../stores';
import { Package } from '../types'; import { Package } from '../types';
const prf = usePrfStore(); const prf = usePrfStore();
const pkgs = usePkgStore();
const props = defineProps({ const props = defineProps({
pkg: Object as () => Package, pkg: Object as () => Package,
@ -27,13 +29,14 @@ const model = computed({
<template> <template>
<div class="flex items-center"> <div class="flex items-center">
<ModTitlecard showVersion :pkg="pkg" /> <ModTitlecard show-version show-icon show-description :pkg="pkg" />
<UpdateButton :pkg="pkg" /> <UpdateButton :pkg="pkg" />
<!-- @vue-expect-error Can't 'as any' because it breaks VSCode --> <!-- @vue-expect-error Can't 'as any' because it breaks VSCode -->
<ToggleSwitch <ToggleSwitch
v-if="pkg?.loc?.kind === 'Mod' || pkg?.loc?.kind === 'Unsupported'"
class="scale-[1.33] shrink-0" class="scale-[1.33] shrink-0"
inputId="switch" inputId="switch"
:disabled="!pkg?.loc" :disabled="pkg!.loc!.kind === 'Unsupported'"
v-model="model" v-model="model"
/> />
<InstallButton :pkg="pkg" /> <InstallButton :pkg="pkg" />
@ -47,17 +50,7 @@ const model = computed({
style="width: 2rem; height: 2rem" style="width: 2rem; height: 2rem"
v-on:click="pkg?.loc && open(pkg.loc.path ?? '')" v-on:click="pkg?.loc && open(pkg.loc.path ?? '')"
/> />
<Button <LinkButton v-if="pkgs.networkStatus === 'online'" :pkg="pkg" />
v-if="pkg?.rmt"
rounded
icon="pi pi-external-link"
severity="info"
aria-label="delete"
size="small"
class="ml-2 shrink-0"
style="width: 2rem; height: 2rem"
v-on:click="open(pkg.rmt.package_url ?? '')"
/>
</div> </div>
</template> </template>

View File

@ -33,14 +33,18 @@ const list = () => {
<template> <template>
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex gap-2">
<div class="grow">Show installed</div>
<ToggleSwitch v-model="pkgs.showInstalled" />
</div>
<div class="flex gap-2"> <div class="flex gap-2">
<div class="text-amber-400 grow">Show deprecated</div> <div class="text-amber-400 grow">Show deprecated</div>
<ToggleSwitch v-model="pkgs.showDeprecated" /> <ToggleSwitch v-model="pkgs.showDeprecated" />
</div> </div>
<div class="flex gap-2"> <!-- <div class="flex gap-2">
<div class="text-red-400 grow">Show NSFW</div> <div class="text-red-400 grow">Show NSFW</div>
<ToggleSwitch v-model="pkgs.showNSFW" /> <ToggleSwitch v-model="pkgs.showNSFW" />
</div> </div> -->
</div> </div>
<div class="flex flex-col gap-2 grow"> <div class="flex flex-col gap-2 grow">
<MultiSelect <MultiSelect
@ -67,7 +71,3 @@ const list = () => {
</div> </div>
<div v-if="empty" class="text-3xl"></div> <div v-if="empty" class="text-3xl"></div>
</template> </template>
<style lang="scss">
@import 'primeicons/primeicons.css';
</style>

View File

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import Button from 'primevue/button';
import { open } from '@tauri-apps/plugin-shell';
import InstallButton from './InstallButton.vue'; import InstallButton from './InstallButton.vue';
import LinkButton from './LinkButton.vue';
import ModTitlecard from './ModTitlecard.vue'; import ModTitlecard from './ModTitlecard.vue';
import { Package } from '../types'; import { Package } from '../types';
@ -11,21 +10,13 @@ defineProps({
</script> </script>
<template> <template>
<ModTitlecard :pkg="pkg" show-namespace show-categories /> <ModTitlecard
<InstallButton :pkg="pkg" /> :pkg="pkg"
<Button show-namespace
rounded show-categories
icon="pi pi-external-link" show-icon
severity="info" show-description
aria-label="storepage"
size="small"
class="self-center ml-2"
style="width: 2rem; height: 2rem"
:disabled="!pkg?.rmt"
v-on:click="open(pkg?.rmt?.package_url ?? '')"
/> />
<InstallButton :pkg="pkg" />
<LinkButton :pkg="pkg" />
</template> </template>
<style lang="scss">
@import 'primeicons/primeicons.css';
</style>

View File

@ -9,6 +9,8 @@ const props = defineProps({
showNamespace: Boolean, showNamespace: Boolean,
showVersion: Boolean, showVersion: Boolean,
showCategories: Boolean, showCategories: Boolean,
showDescription: Boolean,
showIcon: Boolean,
}); });
const iconSrc = () => { const iconSrc = () => {
@ -26,12 +28,13 @@ const iconSrc = () => {
<template> <template>
<img <img
v-if="showIcon"
:src="iconSrc()" :src="iconSrc()"
class="self-center rounded-sm" class="self-center rounded-sm"
width="32px" width="32px"
height="32px" height="32px"
/> />
<label class="m-3 align-middle text grow z-5 h-50px" for="switch"> <label class="m-3 align-middle text grow z-5 h-50px">
<div> <div>
<span class="text-lg"> <span class="text-lg">
{{ pkg?.name ?? 'Untitled' }} {{ pkg?.name ?? 'Untitled' }}
@ -48,6 +51,18 @@ const iconSrc = () => {
class="pi pi-exclamation-triangle ml-1 text-red-400" class="pi pi-exclamation-triangle ml-1 text-red-400"
> >
</span> </span>
<span
v-if="pkg?.loc?.kind === 'Hook'"
v-tooltip="'Hook'"
class="pi pi-wrench ml-1 text-blue-400"
>
</span>
<span
v-if="pkg?.loc?.kind === 'IO'"
v-tooltip="'IO'"
class="pi pi-wrench ml-1 text-green-400"
>
</span>
<span <span
v-if="showNamespace && pkg?.namespace" v-if="showNamespace && pkg?.namespace"
class="text-sm opacity-75" class="text-sm opacity-75"
@ -69,8 +84,8 @@ const iconSrc = () => {
> >
</span> </span>
</div> </div>
<div class="text-sm opacity-75"> <div v-if="showDescription" class="text-sm opacity-75">
{{ pkg?.description ?? 'No description' }} {{ pkg?.description || 'No description' }}
</div> </div>
<div v-if="showCategories" class="mt-1 flex gap-1"> <div v-if="showCategories" class="mt-1 flex gap-1">
<span class="text-xs" v-for="c in pkg?.rmt?.categories" <span class="text-xs" v-for="c in pkg?.rmt?.categories"

View File

@ -250,12 +250,12 @@ const extraDisplayOptionsDisabled = computed(() => {
" "
></FilePicker> ></FilePicker>
</OptionRow> </OptionRow>
<OptionRow <!-- <OptionRow
v-if="prf.current!.network.network_type == 'Artemis'" v-if="prf.current!.network.network_type == 'Artemis'"
title="ARTEMiS console" title="ARTEMiS console"
> >
<ToggleSwitch v-model="prf.current!.network.local_console" /> <ToggleSwitch v-model="prf.current!.network.local_console" />
</OptionRow> </OptionRow> -->
<OptionRow <OptionRow
v-if="prf.current!.network.network_type == 'Remote'" v-if="prf.current!.network.network_type == 'Remote'"
title="Server address" title="Server address"

View File

@ -1,20 +1,52 @@
<script setup lang="ts"> <script setup lang="ts">
import { Ref, computed, ref } from 'vue'; import { Ref, computed, ref } from 'vue';
import Button from 'primevue/button'; import Button from 'primevue/button';
import ConfirmDialog from 'primevue/confirmdialog';
import ScrollPanel from 'primevue/scrollpanel';
import { useConfirm } from 'primevue/useconfirm';
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'; import { usePrfStore } from '../stores';
const prf = usePrfStore(); const prf = usePrfStore();
const confirmDialog = useConfirm();
type StartStatus = 'ready' | 'preparing' | 'running'; type StartStatus = 'ready' | 'preparing' | 'running';
const startStatus: Ref<StartStatus> = ref('ready'); const startStatus: Ref<StartStatus> = ref('ready');
const startline = async () => { const startline = async (force: boolean) => {
startStatus.value = 'preparing'; startStatus.value = 'preparing';
if (!force) {
const start_check: object[] = await invoke('start_check');
if (start_check.length > 0) {
const message = start_check.map((o) => {
if ('MissingRemotePackage' in o) {
return `Package missing: ${o.MissingRemotePackage}`;
} else if ('MissingLocalPackage' in o) {
return `Package missing: ${o.MissingLocalPackage}`;
} else if ('MissingDependency' in o) {
return `Dependency missing: ${o.MissingDependency}`;
} else if ('MissingTool' in o) {
return `Tool missing: ${o.MissingTool}`;
} else {
return 'Unknown error';
}
});
confirmDialog.require({
message: message.join('\n'),
header: 'Start check failed',
accept: () => {
startline(true);
},
});
startStatus.value = 'ready';
return;
}
}
try { try {
await invoke('startline'); await invoke('startline');
} catch (e) { } catch (_) {
startStatus.value = 'ready'; startStatus.value = 'ready';
} }
}; };
@ -44,9 +76,52 @@ listen('launch-start', () => {
listen('launch-end', () => { listen('launch-end', () => {
startStatus.value = 'ready'; startStatus.value = 'ready';
}); });
const messageSplit = (message: any) => {
return message.message?.split('\n');
};
</script> </script>
<template> <template>
<ConfirmDialog>
<template #container="{ message, acceptCallback, rejectCallback }">
<div
class="flex flex-col p-8 bg-surface-0 dark:bg-surface-900 rounded"
>
<span class="font-bold self-center text-2xl block mb-2 mt-2">{{
message.header
}}</span>
<ScrollPanel
v-if="messageSplit(message).length > 5"
style="width: 100%; height: 40vh"
>
<p v-for="m in messageSplit(message)">
{{ m }}
</p></ScrollPanel
>
<div v-else>
<p v-for="m in messageSplit(message)">
{{ m }}
</p>
</div>
<div class="flex self-center items-center gap-2 mt-6">
<Button
label="Run anyway"
@click="acceptCallback"
size="small"
class="w-32"
></Button>
<Button
label="Cancel"
outlined
size="small"
@click="rejectCallback"
class="w-32"
></Button>
</div>
</div>
</template>
</ConfirmDialog>
<Button <Button
v-if="startStatus === 'ready'" v-if="startStatus === 'ready'"
v-tooltip="disabledTooltip" v-tooltip="disabledTooltip"
@ -56,7 +131,7 @@ listen('launch-end', () => {
aria-label="start" aria-label="start"
size="small" size="small"
class="m-2.5" class="m-2.5"
@click="startline()" @click="startline(false)"
/> />
<Button <Button
v-else-if="startStatus === 'preparing'" v-else-if="startStatus === 'preparing'"

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 ConfirmationService from 'primevue/confirmationservice';
import Tooltip from 'primevue/tooltip'; import Tooltip from 'primevue/tooltip';
import App from './components/App.vue'; import App from './components/App.vue';
import { changePrimaryColor } from './util'; import { changePrimaryColor } from './util';
@ -18,6 +19,7 @@ app.use(PrimeVue, {
preset: Preset, preset: Preset,
}, },
}); });
app.use(ConfirmationService);
changePrimaryColor(null); changePrimaryColor(null);
app.directive('tooltip', Tooltip); app.directive('tooltip', Tooltip);
app.mount('#app'); app.mount('#app');

View File

@ -57,7 +57,8 @@ export const useGeneralStore = defineStore('general', () => {
export const usePkgStore = defineStore('pkg', { export const usePkgStore = defineStore('pkg', {
state: (): { state: (): {
pkg: { [key: string]: Package }; pkg: { [key: string]: Package };
offline: boolean; networkStatus: 'connecting' | 'offline' | 'online';
showInstalled: boolean;
showDeprecated: boolean; showDeprecated: boolean;
showNSFW: boolean; showNSFW: boolean;
availableCategories: Set<string>; availableCategories: Set<string>;
@ -66,7 +67,8 @@ export const usePkgStore = defineStore('pkg', {
} => { } => {
return { return {
pkg: {}, pkg: {},
offline: false, networkStatus: 'connecting',
showInstalled: false,
showDeprecated: false, showDeprecated: false,
showNSFW: false, showNSFW: false,
availableCategories: new Set(), availableCategories: new Set(),
@ -79,12 +81,14 @@ export const usePkgStore = defineStore('pkg', {
fromName: (state) => (namespace: string, name: string) => fromName: (state) => (namespace: string, name: string) =>
state.pkg[`${namespace}-${name}`] ?? null, state.pkg[`${namespace}-${name}`] ?? null,
all: (state) => Object.values(state), all: (state) => Object.values(state),
allLocal: (state) => allLocal: (state) => Object.values(state.pkg).filter((p) => p.loc),
Object.values(state.pkg).filter((p) => p.loc?.kind === 'Mod'), hasLocal: (state) => (key: string) =>
key in state.pkg && state.pkg[key].loc,
allRemote: (state) => allRemote: (state) =>
Object.values(state.pkg).filter( Object.values(state.pkg).filter(
(p) => (p) =>
p.rmt !== null && p.rmt !== null &&
(state.showInstalled || !p.loc) &&
(state.showDeprecated || !p.rmt.deprecated) && (state.showDeprecated || !p.rmt.deprecated) &&
(state.showNSFW || !p.rmt.nsfw) && (state.showNSFW || !p.rmt.nsfw) &&
(state.includeCategories.length === 0 || (state.includeCategories.length === 0 ||
@ -154,15 +158,16 @@ export const usePkgStore = defineStore('pkg', {
}, },
async fetch(nopopup: boolean) { async fetch(nopopup: boolean) {
this.networkStatus = 'connecting';
try { try {
if (nopopup) { if (nopopup) {
await invoke_nopopup('fetch_listings'); await invoke_nopopup('fetch_listings');
} else { } else {
await invoke('fetch_listings'); await invoke('fetch_listings');
} }
this.offline = false; this.networkStatus = 'online';
} catch (e) { } catch (e) {
this.offline = true; this.networkStatus = 'offline';
return; return;
} }
await this.reloadAll(); await this.reloadAll();
@ -243,11 +248,17 @@ export const usePrfStore = defineStore('prf', () => {
list.value = (await invoke('list_profiles')) as ProfileMeta[]; list.value = (await invoke('list_profiles')) as ProfileMeta[];
}; };
const togglePkg = async (pkg: Package | undefined, enable: boolean) => { const togglePkg = async (
pkg: Package | string | undefined,
enable: boolean
) => {
if (pkg === undefined) { if (pkg === undefined) {
return; return;
} }
await invoke('toggle_package', { key: pkgKey(pkg), enable }); await invoke('toggle_package', {
key: typeof pkg === 'string' ? pkg : pkgKey(pkg),
enable,
});
await reload(); await reload();
}; };