feat: phase 2

Newfound motivation
This commit is contained in:
2025-02-23 05:12:21 +01:00
parent fdf3679fbe
commit a29bce2227
36 changed files with 1367 additions and 615 deletions

4
.gitignore vendored
View File

@ -1,4 +1,3 @@
# Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
@ -12,7 +11,6 @@ dist
dist-ssr dist-ssr
*.local *.local
# Editor directories and files
.vscode .vscode
.idea .idea
.DS_Store .DS_Store
@ -21,3 +19,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
tsconfig.tsbuildinfo

View File

@ -2,9 +2,11 @@
"trailingComma": "es5", "trailingComma": "es5",
"tabWidth": 4, "tabWidth": 4,
"semi": true, "semi": true,
"printWidth": 80,
"singleQuote": true, "singleQuote": true,
"importOrder": [ "importOrder": [
"^vue$", "^vue$",
"^pinia$",
"^@?primevue(.*)$", "^@?primevue(.*)$",
"^@tauri-apps/(.*)$", "^@tauri-apps/(.*)$",
"^(.*)vue$", "^(.*)vue$",

View File

@ -2,7 +2,7 @@
A simple and easy to use mod manager for [many games](https://silentblue.remywiki.com/ONGEKI:bright_MEMORY) using [Rainycolor Watercolor](https://rainy.patafour.zip). A simple and easy to use mod manager for [many games](https://silentblue.remywiki.com/ONGEKI:bright_MEMORY) using [Rainycolor Watercolor](https://rainy.patafour.zip).
For those who just want a glorified `start.bat` clicker with auto-updates, without VHDs, keychips etc. Intended for those who just want a glorified `start.bat` clicker, without VHDs, keychips etc.
(for an all-in-one solution, check out the [BlueSteel launcher](https://yozora.bluesteel.737.jp.net/HarmonyPublic/SOS-Kongou)). (for an all-in-one solution, check out the [BlueSteel launcher](https://yozora.bluesteel.737.jp.net/HarmonyPublic/SOS-Kongou)).
Made with Rust (Tauri) and Vue. Contributions welcome. Made with Rust (Tauri) and Vue. Contributions welcome.
@ -28,15 +28,21 @@ More file overrides may be supported in the future.
Arbitrary scripts are not supported by design and that will probably never change. Arbitrary scripts are not supported by design and that will probably never change.
### Features
- Clean data modding
- Multi-platform
### Todo ### Todo
- Updates and auto-updates - Updates and auto-updates
- CLI - Run from CLI
- Support CHUNITHM - Support CHUNITHM
- Support opts - Support segatools as a special package
- `unwrap()unwrap()unwrap()unwrap()unwrap()unwrap()unwrap()` - Progress bars
- Only rebuild the profile when needed
## Endgame ## Endgame
- Support segatools, IO DLLs and artemis as special packages. - Support IO DLLs and artemis as special packages.
- Support other arcade games (if there is demand). - Support other arcade games (if there is demand).

BIN
bun.lockb

Binary file not shown.

View File

@ -15,10 +15,12 @@
"@primevue/themes": "^4.2.5", "@primevue/themes": "^4.2.5",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-shell": "~2", "@tauri-apps/plugin-shell": "~2",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"pinia": "^3.0.1",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.2.5", "primevue": "^4.2.5",
"roboto-fontface": "*", "roboto-fontface": "*",

204
rust/Cargo.lock generated
View File

@ -109,12 +109,23 @@ version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [ dependencies = [
"event-listener", "event-listener 5.4.0",
"event-listener-strategy", "event-listener-strategy",
"futures-core", "futures-core",
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "async-channel"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
dependencies = [
"concurrent-queue",
"event-listener 2.5.3",
"futures-core",
]
[[package]] [[package]]
name = "async-channel" name = "async-channel"
version = "2.3.1" version = "2.3.1"
@ -165,6 +176,21 @@ dependencies = [
"futures-lite", "futures-lite",
] ]
[[package]]
name = "async-global-executor"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c"
dependencies = [
"async-channel 2.3.1",
"async-executor",
"async-io",
"async-lock",
"blocking",
"futures-lite",
"once_cell",
]
[[package]] [[package]]
name = "async-io" name = "async-io"
version = "2.4.0" version = "2.4.0"
@ -190,7 +216,7 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18"
dependencies = [ dependencies = [
"event-listener", "event-listener 5.4.0",
"event-listener-strategy", "event-listener-strategy",
"pin-project-lite", "pin-project-lite",
] ]
@ -201,14 +227,14 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb"
dependencies = [ dependencies = [
"async-channel", "async-channel 2.3.1",
"async-io", "async-io",
"async-lock", "async-lock",
"async-signal", "async-signal",
"async-task", "async-task",
"blocking", "blocking",
"cfg-if", "cfg-if",
"event-listener", "event-listener 5.4.0",
"futures-lite", "futures-lite",
"rustix", "rustix",
"tracing", "tracing",
@ -243,6 +269,32 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "async-std"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c634475f29802fde2b8f0b505b1bd00dfe4df7d4a000f0b36f7671197d5c3615"
dependencies = [
"async-channel 1.9.0",
"async-global-executor",
"async-io",
"async-lock",
"crossbeam-utils",
"futures-channel",
"futures-core",
"futures-io",
"futures-lite",
"gloo-timers",
"kv-log-macro",
"log",
"memchr",
"once_cell",
"pin-project-lite",
"pin-utils",
"slab",
"wasm-bindgen-futures",
]
[[package]] [[package]]
name = "async-task" name = "async-task"
version = "4.7.1" version = "4.7.1"
@ -367,7 +419,7 @@ version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea"
dependencies = [ dependencies = [
"async-channel", "async-channel 2.3.1",
"async-task", "async-task",
"futures-io", "futures-io",
"futures-lite", "futures-lite",
@ -583,6 +635,12 @@ dependencies = [
"inout", "inout",
] ]
[[package]]
name = "closure"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6173fd61b610d15a7566dd7b7620775627441c4ab9dac8906e17cb93a24b782"
[[package]] [[package]]
name = "cocoa" name = "cocoa"
version = "0.26.0" version = "0.26.0"
@ -941,6 +999,27 @@ dependencies = [
"syn 2.0.98", "syn 2.0.98",
] ]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.98",
"unicode-xid",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -1185,6 +1264,12 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "event-listener"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
[[package]] [[package]]
name = "event-listener" name = "event-listener"
version = "5.4.0" version = "5.4.0"
@ -1202,7 +1287,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2"
dependencies = [ dependencies = [
"event-listener", "event-listener 5.4.0",
"pin-project-lite", "pin-project-lite",
] ]
@ -1655,6 +1740,18 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
[[package]]
name = "gloo-timers"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
dependencies = [
"futures-channel",
"futures-core",
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.18.0" version = "0.18.0"
@ -2286,6 +2383,15 @@ dependencies = [
"selectors", "selectors",
] ]
[[package]]
name = "kv-log-macro"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f"
dependencies = [
"log",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -2375,6 +2481,9 @@ name = "log"
version = "0.4.25" version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f"
dependencies = [
"value-bag",
]
[[package]] [[package]]
name = "lzma-rs" name = "lzma-rs"
@ -3622,7 +3731,7 @@ dependencies = [
"wasm-streams", "wasm-streams",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots",
"windows-registry", "windows-registry 0.2.0",
] ]
[[package]] [[package]]
@ -3853,7 +3962,7 @@ checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
dependencies = [ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
"cssparser", "cssparser",
"derive_more", "derive_more 0.99.19",
"fxhash", "fxhash",
"log", "log",
"matches", "matches",
@ -4201,7 +4310,10 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-compression", "async-compression",
"async-std",
"closure",
"derive_builder", "derive_builder",
"derive_more 2.0.1",
"directories", "directories",
"flate2", "flate2",
"futures", "futures",
@ -4216,9 +4328,11 @@ dependencies = [
"simple_logger", "simple_logger",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-shell", "tauri-plugin-shell",
"tauri-plugin-single-instance",
"tokio", "tokio",
"zip", "zip",
] ]
@ -4539,6 +4653,26 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35d51ffd286073414d26353bcfc9e83e3cd63f96fa7f7a912f92f2118e5de5a6"
dependencies = [
"dunce",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.11",
"tracing",
"url",
"windows-registry 0.3.0",
"windows-result",
]
[[package]] [[package]]
name = "tauri-plugin-dialog" name = "tauri-plugin-dialog"
version = "2.2.0" version = "2.2.0"
@ -4623,6 +4757,22 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47c387d4d96690131dc46d1d2827df5c222b896a2bfeb15a16267229a55c50b5"
dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.11",
"tracing",
"windows-sys 0.59.0",
"zbus",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.3.0" version = "2.3.0"
@ -5159,6 +5309,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@ -5217,6 +5373,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "value-bag"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@ -5621,7 +5783,7 @@ dependencies = [
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
"windows-result", "windows-result",
"windows-strings", "windows-strings 0.1.0",
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
@ -5654,7 +5816,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0"
dependencies = [ dependencies = [
"windows-result", "windows-result",
"windows-strings", "windows-strings 0.1.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-registry"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bafa604f2104cf5ae2cc2db1dee84b7e6a5d11b05f737b60def0ffdc398cbc0a"
dependencies = [
"windows-result",
"windows-strings 0.2.0",
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
@ -5677,6 +5850,15 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-strings"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978d65aedf914c664c510d9de43c8fd85ca745eaff1ed53edf409b479e441663"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.45.0" version = "0.45.0"
@ -6128,7 +6310,7 @@ dependencies = [
"async-trait", "async-trait",
"blocking", "blocking",
"enumflags2", "enumflags2",
"event-listener", "event-listener 5.4.0",
"futures-core", "futures-core",
"futures-lite", "futures-lite",
"hex", "hex",

View File

@ -34,4 +34,11 @@ regex = "1.11.1"
zip = "2.2.2" zip = "2.2.2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
anyhow = "1.0.95" anyhow = "1.0.95"
tauri-plugin-deep-link = "2"
async-std = "1.13.0"
closure = "0.3.0"
derive_more = { version = "2.0.1", features = ["display"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }

View File

@ -14,6 +14,7 @@
"shell:default", "shell:default",
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"dialog:default" "dialog:default",
"deep-link:default"
] ]
} }

33
rust/src/appdata.rs Normal file
View File

@ -0,0 +1,33 @@
use anyhow::{anyhow, Result};
use crate::pkg::PkgKey;
use crate::Profile;
use crate::pkg_store::PackageStore;
pub struct AppData {
pub profile: Option<Profile>,
pub pkgs: PackageStore,
}
impl AppData {
pub fn toggle_package(&mut self, key: PkgKey, enable: bool) -> Result<()> {
log::debug!("toggle: {} {}", key, enable);
let profile = self.profile.as_mut()
.ok_or_else(|| anyhow!("No profile"))?;
if enable {
let pkg = self.pkgs.get(key.clone())?;
let loc = pkg.loc
.clone()
.ok_or_else(|| anyhow!("Attempted to enable a non-existent package"))?;
profile.mods.insert(key);
for d in &loc.dependencies {
self.toggle_package(d.clone(), true)?;
}
} else {
profile.mods.remove(&key);
}
Ok(())
}
}

View File

@ -1,81 +1,108 @@
use log; use log;
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::pkg_remote; use crate::pkg::{Package, PkgKey};
use crate::pkg_local; use crate::pkg_store::InstallResult;
use crate::profile::Profile; use crate::profile::Profile;
use crate::AppData; use crate::appdata::AppData;
use crate::model::Package; use crate::{liner, start};
use tauri::State; use tauri::State;
#[tauri::command] #[tauri::command]
pub async fn startline( pub async fn startline(state: State<'_, Mutex<AppData>>) -> Result<(), String> {
state: State<'_, Mutex<AppData>>,
) -> Result<Option<Profile>, ()> {
log::debug!("invoke: startline"); log::debug!("invoke: startline");
let appd = state.lock().await; let appd = state.lock().await;
Ok(appd.profile.clone()) if let Some(p) = &appd.profile {
} // TODO if p.needsUpdate
liner::line_up(p).await.expect("Line-up failed");
#[tauri::command] start::start(p).map_err(|e| e.to_string()).map(|_| ())
pub async fn download_package(pkg: Package) -> Result<(), String> { //Ok(())
log::debug!("invoke: download_package"); } else {
Err("No profile".to_owned())
pkg_remote::download_package(pkg).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_package(namespace: String, name: String) -> Result<(), String> {
log::debug!("invoke: download_package");
pkg_local::delete_package(namespace, name).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn reload_packages(state: State<'_, tokio::sync::Mutex<AppData>>) -> Result<(), String> {
log::debug!("invoke: reload_packages");
let mut appd = state.lock().await;
// todo: this should only fetch new things
match pkg_local::walk_packages(false).await {
Ok(m) => {
appd.mods_local = m;
Ok(())
}
Err(e) => {
Err(e.to_string())
}
} }
} }
#[tauri::command] #[tauri::command]
pub async fn get_packages(state: State<'_, Mutex<AppData>>) -> Result<Vec<Package>, ()> { pub async fn install_package(state: State<'_, tokio::sync::Mutex<AppData>>, key: PkgKey) -> Result<InstallResult, String> {
log::debug!("invoke: get_packages"); log::debug!("invoke: install_package");
let mut appd = state.lock().await;
let rv = appd.pkgs.install_package(&key, true)
.await
.map_err(|e| e.to_string());
// if rv.is_ok() {
// _ = appd.toggle_package(key, true);
// }
rv
}
#[tauri::command]
pub async fn delete_package(state: State<'_, tokio::sync::Mutex<AppData>>, key: PkgKey) -> Result<(), String> {
log::debug!("invoke: delete_package");
let mut appd = state.lock().await;
appd.pkgs.delete_package(&key, true)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_package(state: State<'_, tokio::sync::Mutex<AppData>>, key: PkgKey) -> Result<Package, String> {
log::debug!("invoke: get_package");
let appd = state.lock().await;
appd.pkgs.get(key)
.map_err(|e| e.to_string())
.cloned()
}
#[tauri::command]
pub async fn toggle_package(state: State<'_, tokio::sync::Mutex<AppData>>, key: PkgKey, enable: bool) -> Result<(), String> {
log::debug!("invoke: toggle_package");
let mut appd = state.lock().await;
appd.toggle_package(key, enable)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn reload_all_packages(state: State<'_, tokio::sync::Mutex<AppData>>) -> Result<(), String> {
log::debug!("invoke: reload_all_packages");
let mut appd = state.lock().await;
appd.pkgs.reload_all()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_all_packages(state: State<'_, Mutex<AppData>>) -> Result<HashMap<PkgKey, Package>, ()> {
log::debug!("invoke: get_all_packages");
let appd = state.lock().await; let appd = state.lock().await;
Ok(appd.mods_local.clone()) Ok(appd.pkgs.get_all())
} }
#[tauri::command] #[tauri::command]
pub async fn get_listings(state: State<'_, Mutex<AppData>>) -> Result<Vec<Package>, String> { pub async fn fetch_listings(state: State<'_, Mutex<AppData>>) -> Result<(), String> {
log::debug!("invoke: get_listings"); log::debug!("invoke: fetch_listings");
let mut appd = state.lock().await; let mut appd = state.lock().await;
match pkg_remote::get_listings(&mut appd).await { appd.pkgs.fetch_listings().await
Ok(l) => Ok(l.clone()), .map_err(|e| e.to_string())
Err(e) => Err(e.to_string())
}
} }
#[tauri::command] #[tauri::command]
pub async fn get_current_profile( pub async fn get_current_profile(state: State<'_, Mutex<AppData>>) -> Result<Option<Profile>, ()> {
state: State<'_, Mutex<AppData>>,
) -> Result<Option<Profile>, ()> {
log::debug!("invoke: get_current_profile"); log::debug!("invoke: get_current_profile");
let appd = state.lock().await; let appd = state.lock().await;
@ -83,9 +110,7 @@ pub async fn get_current_profile(
} }
#[tauri::command] #[tauri::command]
pub async fn save_profile( pub async fn save_profile(state: State<'_, Mutex<AppData>>) -> Result<(), ()> {
state: State<'_, Mutex<AppData>>
) -> Result<(), ()> {
log::debug!("invoke: save_profile"); log::debug!("invoke: save_profile");
let appd = state.lock().await; let appd = state.lock().await;
@ -101,7 +126,7 @@ pub async fn save_profile(
#[tauri::command] #[tauri::command]
pub async fn init_profile( pub async fn init_profile(
state: State<'_, Mutex<AppData>>, state: State<'_, Mutex<AppData>>,
path: PathBuf path: PathBuf,
) -> Result<Profile, String> { ) -> Result<Profile, String> {
log::debug!("invoke: init_profile"); log::debug!("invoke: init_profile");
@ -112,4 +137,4 @@ pub async fn init_profile(
appd.profile = Some(new_profile.clone()); appd.profile = Some(new_profile.clone());
Ok(new_profile) Ok(new_profile)
} }

View File

@ -0,0 +1,57 @@
use std::{collections::HashSet, path::PathBuf};
use tauri::{AppHandle, Emitter};
use tokio::fs::File;
use anyhow::{anyhow, Result};
use crate::pkg::{Package, PkgKey, Remote};
pub struct DownloadHandler {
set: HashSet<String>,
app: AppHandle
}
impl DownloadHandler {
pub fn new(app: AppHandle) -> DownloadHandler {
DownloadHandler {
set: HashSet::new(),
app
}
}
pub fn download_zip(&mut self, zip_path: &PathBuf, pkg: &Package) -> Result<()> {
let rmt = pkg.rmt.as_ref()
.ok_or_else(|| anyhow!("Attempted to download a package without remote data"))?
.clone();
if self.set.contains(zip_path.to_string_lossy().as_ref()) {
Err(anyhow!("Already downloading"))
} else {
self.set.insert(zip_path.to_string_lossy().to_string());
tauri::async_runtime::spawn(Self::download_zip_proc(self.app.clone(), zip_path.clone(), pkg.key(), rmt));
Ok(())
}
}
async fn download_zip_proc(app: AppHandle, zip_path: PathBuf, pkg_key: PkgKey, rmt: Remote) -> Result<()> {
use futures::StreamExt;
use tokio::io::AsyncWriteExt;
// let zip_path_part = zip_path.add_extension("part");
let mut zip_path_part = zip_path.to_owned();
zip_path_part.set_extension("zip.part");
let mut cache_file_w = File::create(&zip_path_part).await?;
let mut byte_stream = reqwest::get(&rmt.download_url).await?.bytes_stream();
log::info!("Downloading: {}", rmt.download_url);
while let Some(item) = byte_stream.next().await {
let i = item?;
cache_file_w.write_all(&mut i.as_ref()).await?;
}
cache_file_w.sync_all().await?;
tokio::fs::rename(&zip_path_part, &zip_path).await?;
app.emit("download-end", pkg_key)?;
Ok(())
}
}

View File

@ -1,30 +1,34 @@
mod cfg;
mod model;
mod cmd; mod cmd;
mod pkg_remote; mod model;
mod pkg_local; mod pkg;
mod util; mod pkg_store;
mod profile; mod profile;
mod util;
mod start;
mod liner;
mod download_handler;
mod appdata;
use tokio::{fs, try_join}; use closure::closure;
use tokio::sync::Mutex; use appdata::AppData;
use model::Package; use pkg::PkgKey;
use tauri::Manager; use pkg_store::PackageStore;
use profile::Profile; use profile::Profile;
use tauri::{Listener, Manager};
struct AppData { use tauri_plugin_deep_link::DeepLinkExt;
profile: Option<Profile>, use tokio::{sync::Mutex, fs, try_join};
mods_local: Vec<Package>,
mods_store: Vec<Package>,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub async fn run(args: Vec<String>) { pub async fn run(_args: Vec<String>) {
simple_logger::init_with_env().unwrap(); simple_logger::init_with_env()
.expect("Unable to initialize the logger");
log::info!( log::info!(
"Running from {}", "Running from {}",
std::env::current_dir().unwrap_or_default().to_str().unwrap_or_default() std::env::current_dir()
.unwrap_or_default()
.to_str()
.unwrap_or_default()
); );
try_join!( try_join!(
@ -33,35 +37,87 @@ pub async fn run(args: Vec<String>) {
fs::create_dir_all(util::cache_dir()) fs::create_dir_all(util::cache_dir())
).expect("Unable to create working directories"); ).expect("Unable to create working directories");
let app_data = AppData { tauri::Builder::default()
profile: pkg_local::load_config(), .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
mods_local: pkg_local::walk_packages(true).await.expect("Unable to scan local packages"), let _ = app
mods_store: [].to_vec(), .get_webview_window("main")
}; .expect("No main window")
.set_focus();
if args.len() == 2 {
// Todo deindent this chimera
let url = &args[1];
if &url[..13] == "rainycolor://" {
let regex = regex::Regex::new(
r"rainycolor://v1/install/rainy\.patafour\.zip/([^/]+)/([^/]+)/[0-9]+\.[0-9]+\.[0-9]+/"
).expect("Invalid regex");
if let Some(caps) = regex.captures(url) {
if caps.len() == 3 {
let apph = app.clone();
let key = PkgKey(format!("{}-{}", caps.get(1).unwrap().as_str(), caps.get(2).unwrap().as_str()));
tauri::async_runtime::spawn(async move {
let mutex = apph.state::<Mutex<AppData>>();
let mut appd = mutex.lock().await;
_ = appd.pkgs.fetch_listings().await;
if let Err(e) = appd.pkgs.install_package(&key, true).await {
log::warn!("Fail: {}", e.to_string());
}
});
}
}
}
}
}))
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_dialog::init())
.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())
};
if args.len() == 1 { app.manage(Mutex::new(app_data));
tauri::Builder::default() app.deep_link().register_all()?;
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init()) let apph = app.handle();
.plugin(tauri_plugin_opener::init())
.setup(|app| { app.listen("download-end", closure!(clone apph, |ev| {
app.manage(Mutex::new(app_data)); let raw = ev.payload();
Ok(()) let key = PkgKey(raw[1..raw.len()-1].to_owned());
}) let apph = apph.clone();
.invoke_handler(tauri::generate_handler![ tauri::async_runtime::spawn(async move {
cmd::get_packages, let mutex = apph.state::<Mutex<AppData>>();
cmd::reload_packages, let mut appd = mutex.lock().await;
cmd::get_listings, _ = appd.pkgs.install_package(&key, true).await;
cmd::download_package, });
cmd::delete_package, }));
cmd::get_current_profile,
cmd::init_profile, app.listen("install-end", closure!(clone apph, |ev| {
cmd::save_profile, let payload = serde_json::from_str::<pkg_store::Payload>(&ev.payload());
cmd::startline let apph = apph.clone();
]) tauri::async_runtime::spawn(async move {
.run(tauri::generate_context!()) let mutex = apph.state::<Mutex<AppData>>();
.expect("error while running tauri application"); let mut appd = mutex.lock().await;
} else { _ = appd.toggle_package(payload.unwrap().pkg, true);
panic!("Not implemented"); });
} }));
Ok(())
})
.invoke_handler(tauri::generate_handler![
cmd::get_package,
cmd::get_all_packages,
cmd::reload_all_packages,
cmd::fetch_listings,
cmd::install_package,
cmd::delete_package,
cmd::toggle_package,
cmd::get_current_profile,
cmd::init_profile,
cmd::save_profile,
cmd::startline
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
} }

50
rust/src/liner.rs Normal file
View File

@ -0,0 +1,50 @@
use tokio::task::JoinSet;
use anyhow::Result;
use tokio::fs;
use crate::util;
use crate::profile::Profile;
pub async fn line_up(p: &Profile) -> Result<()> {
let dir_out = util::profile_dir(&p);
log::info!("Preparing {}", dir_out.to_string_lossy());
let mut futures = JoinSet::new();
if dir_out.join("BepInEx").exists() {
futures.spawn(fs::remove_dir_all(dir_out.join("BepInEx")));
}
if dir_out.join("option").exists() {
futures.spawn(fs::remove_dir_all(dir_out.join("option")));
}
while let Some(_) = futures.join_next().await {}
fs::create_dir_all(dir_out.join("option")).await?;
log::debug!("--");
for m in &p.mods {
log::debug!("{}", m.0);
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..])
.join("app")
.join("BepInEx");
if bpx.exists() {
util::copy_recursive(&bpx, &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()?;
if x.metadata()?.is_dir() {
fs::symlink(&x.path(), &dir_out.join("option").join(x.file_name())).await?;
}
}
}
log::debug!("--");
for opt in p.path.join("option").read_dir()? {
let opt = opt?;
fs::symlink(&opt.path(), &dir_out.join("option").join(opt.file_name())).await?;
}
Ok(())
}

View File

@ -1,4 +1,5 @@
use serde::Deserialize; use serde::Deserialize;
use crate::pkg::PkgKeyVersion;
// manifest.json // manifest.json
@ -8,4 +9,5 @@ pub struct PackageManifest {
pub name: String, pub name: String,
pub version_number: String, pub version_number: String,
pub description: String, pub description: String,
} pub dependencies: Vec<PkgKeyVersion>
}

View File

@ -1,33 +1,7 @@
use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde::{Deserialize, Serialize};
#[derive(Clone)] #[derive(Clone, Serialize, Deserialize)]
pub enum Game { pub enum Game {
Ongeki, Ongeki,
Chunithm, Chunithm,
} }
impl Serialize for Game {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Game::Ongeki => serializer.serialize_str("ongeki"),
Game::Chunithm => serializer.serialize_str("chunithm"),
}
}
}
impl<'de> Deserialize<'de> for Game {
fn deserialize<D>(deserializer: D) -> Result<Game, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.as_str() {
"chunithm" => Ok(Game::Chunithm),
"ongeki" => Ok(Game::Ongeki),
_ => Err(de::Error::custom("unknown game")),
}
}
}

View File

@ -1,23 +1,3 @@
pub mod local; pub mod local;
pub mod misc; pub mod misc;
pub mod rainy; pub mod rainy;
use derive_builder::Builder;
use serde::{Deserialize, Serialize};
#[derive(Clone, Builder, Default, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct Package {
pub namespace: String,
pub name: String,
pub description: String,
pub package_url: String,
pub download_url: String,
pub path: String,
pub enabled: bool,
pub icon: String,
pub version: String,
pub version_available: String,
pub deprecated: bool,
pub dependencies: Vec<Package>,
}

View File

@ -1,4 +1,5 @@
use serde::Deserialize; use serde::Deserialize;
use crate::pkg::PkgKeyVersion;
// /c/{game}/api/v1/package // /c/{game}/api/v1/package
@ -14,34 +15,10 @@ pub struct V1Package {
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
pub struct V1Version { pub struct V1Version {
// no namespace
pub name: String, pub name: String,
pub description: String, pub description: String,
pub version_number: String, pub version_number: String,
pub icon: String, pub icon: String,
pub dependencies: Vec<String>, pub dependencies: Vec<PkgKeyVersion>,
pub download_url: String, pub download_url: String,
} }
// /api/experimental/{namespace}/{name}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct V0Package {
pub owner: String,
pub package_url: String,
pub is_deprecated: bool,
pub latest: V0Version,
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct V0Version {
pub namespace: String,
pub name: String,
pub description: String,
pub version_number: String,
pub icon: String,
pub dependencies: Vec<String>,
pub download_url: String,
}

168
rust/src/pkg.rs Normal file
View File

@ -0,0 +1,168 @@
use anyhow::{Result, anyhow, bail};
use derive_more::Display;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs;
use crate::{model::{local, rainy}, util};
// {namespace}-{name}
#[derive(Eq, Hash, PartialEq, Clone, Serialize, Deserialize, Display)]
pub struct PkgKey(pub String);
// {namespace}-{name}-{version}
#[derive(Clone, Serialize, Deserialize)]
pub struct PkgKeyVersion(String);
#[derive(Clone, Default, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct Package {
pub namespace: String,
pub name: String,
pub description: String,
pub icon: String,
pub loc: Option<Local>,
pub rmt: Option<Remote>
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub enum Kind {
#[default] Mod,
UnsupportedMod
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct Local {
pub version: String,
pub path: PathBuf,
pub dependencies: Vec<PkgKey>,
pub kind: Kind
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[allow(dead_code)]
pub struct Remote {
pub version: String,
pub package_url: String,
pub download_url: String,
pub deprecated: bool,
pub dependencies: Vec<PkgKey>
}
impl Package {
pub fn from_rainy(mut p: rainy::V1Package) -> Option<Package> {
if p.versions.len() == 0 {
return None;
}
let v = p.versions.swap_remove(0);
Some(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,
deprecated: p.is_deprecated,
version: v.version_number,
dependencies: Self::sanitize_deps(v.dependencies)
})
})
}
pub async fn from_dir(dir: PathBuf) -> Result<Package> {
let str = fs::read_to_string(dir.join("manifest.json")).await?;
let mft: local::PackageManifest = serde_json::from_str(&str)?;
let icon = dir.join("icon.png")
.as_os_str()
.to_str()
.unwrap()
.to_owned();
let dependencies = Self::sanitize_deps(mft.dependencies);
Ok(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(),
kind: Kind::Mod,
dependencies
}),
rmt: None
})
}
pub fn key(&self) -> PkgKey {
PkgKey(format!("{}-{}", self.namespace, self.name))
}
pub fn path(&self) -> PathBuf {
util::pkg_dir().join(self.key().0)
}
pub fn _dir_to_key(dir: &Path) -> Result<String> {
let (key, _) = Self::parse_dir_name(dir)?;
Ok(key)
}
pub fn dir_to_namespace(dir: &Path) -> Result<String> {
let (_, n) = Self::parse_dir_name(dir)?;
Ok(n)
}
fn manifest(dir: &Path) -> Result<local::PackageManifest> {
serde_json::from_reader(std::fs::File::open(dir.join("manifest.json"))?)
.map_err(|err| anyhow!("Invalid manifest: {}", err))
}
fn parse_dir_name(dir: &Path) -> Result<(String, String)> {
let mft = Self::manifest(dir)?;
let regex = regex::Regex::new(r"([A-Za-z0-9_]+)-([A-Za-z0-9_]+)$")?;
let dir_name = dir.file_name()
.to_owned()
.ok_or_else(|| anyhow!("Invalid directory name"))?
.to_str()
.ok_or_else(|| anyhow!("Illegal directory name"))?;
let namespace;
if let Some(caps) = regex.captures(dir_name) {
let name_match = caps.get(2)
.ok_or_else(|| anyhow!("Invalid directory name"))?;
if name_match.as_str() != mft.name {
bail!("Invalid manifest or directory name");
}
namespace = caps.get(1)
.ok_or_else(|| anyhow!("Invalid directory name?"))?
.as_str()
.to_owned();
Ok((format!("{}-{}", namespace, mft.name), namespace))
} else {
bail!("Error reading {}: invalid directory name", dir_name);
}
}
fn sanitize_deps(mut deps: Vec<PkgKeyVersion>) -> Vec<PkgKey> {
let regex = regex::Regex::new(r"([A-Za-z0-9_]+)-([A-Za-z0-9_]+)-[0-9\.]+$")
.expect("Invalid regex");
for i in 0..deps.len() {
let caps = regex.captures(&deps[i].0)
.expect("Invalid dependency");
deps[i] = PkgKeyVersion(format!("{}-{}", caps.get(1).unwrap().as_str(), caps.get(2).unwrap().as_str()));
}
let rv: Vec<PkgKey> = unsafe { std::mem::transmute(deps) };
rv
}
}

View File

@ -1,106 +0,0 @@
use anyhow::Result;
use std::fs::{self, File};
use crate::{pkg_remote, profile::Profile, model::{self, local, Package}, util};
pub fn load_config() -> Option<Profile> {
let path = util::get_dirs().config_dir().join("profile-ongeki-default.json");
if let Ok(s) = fs::read_to_string(path) {
Some(serde_json::from_str(&s).expect("Invalid profile json"))
} else {
None
}
}
pub async fn walk_packages(fetch_remote: bool) -> Result<Vec<Package>> {
let mut res = [].to_vec();
let packages = fs::read_dir(util::get_dirs().data_dir().join("pkg"))?;
for package in packages {
let dir = package.unwrap().path();
let mft: local::PackageManifest = serde_json::from_reader(
File::open(dir.join("manifest.json"))?
)?;
let regex = regex::Regex::new(r"([A-Za-z0-9_]+)-([A-Za-z0-9_]+)$")?;
let dir_name = dir.file_name().to_owned().unwrap().to_str().unwrap();
let namespace;
if let Some(caps) = regex.captures(dir_name) {
if caps.len() != 3 || caps.get(2).unwrap().as_str() != mft.name {
log::error!(
"Error reading {}: invalid manifest or directory name",
dir_name
);
continue;
}
namespace = caps.get(1).unwrap().as_str();
} else {
log::error!("Error reading {}: invalid directory name", dir_name);
continue;
}
let mut builder = model::PackageBuilder::default();
builder
.name(mft.name.to_owned())
.namespace(namespace.to_owned())
.enabled(false);
builder.package_url(format!(
"https://rainy.patafour.zip/package/{}/{}",
namespace, mft.name
));
builder
.version(mft.version_number.clone())
.description(mft.description)
.icon(
dir.join("icon.png")
.as_os_str()
.to_str()
.unwrap()
.to_owned(),
)
.path(dir.as_os_str().to_str().unwrap().to_owned())
.dependencies([].to_vec());
builder
.version_available(mft.version_number)
.download_url("".to_owned())
.deprecated(false);
if fetch_remote == true {
if let Ok(rem) = pkg_remote::get_remote_meta(namespace, &mft.name).await {
builder.version_available(rem.latest.version_number);
builder.download_url(rem.latest.download_url);
builder.deprecated(rem.is_deprecated);
}
}
match builder.build() {
Ok(r) => {
res.push(r);
}
Err(e) => {
log::error!("Bad package: {}", e);
}
}
}
Ok(res)
}
pub async fn delete_package(namespace: String, name: String) -> Result<(), tokio::io::Error> {
let path = util::get_dirs()
.data_dir()
.join("pkg")
.join(format!("{}-{}", namespace, name));
if path.exists() && path.join("manifest.json").exists() {
log::debug!("rm -r'ing {}", path.to_string_lossy());
tokio::fs::remove_dir_all(&path).await
} else {
Ok(())
}
}

View File

@ -1,105 +0,0 @@
use anyhow::Result;
use tokio::fs::{self, File};
use crate::{pkg_local, util, AppData};
use crate::model::{rainy, Package};
async fn fetch_listings() -> Result<Vec<Package>> {
use async_compression::futures::bufread::GzipDecoder;
use futures::{
io::{self, BufReader, ErrorKind},
prelude::*,
};
let response = reqwest::get("https://rainy.patafour.zip/c/ongeki/api/v1/package/")
.await?;
let reader = response
.bytes_stream()
.map_err(|e| io::Error::new(ErrorKind::Other, e))
.into_async_read();
let mut decoder = GzipDecoder::new(BufReader::new(reader));
let mut data = String::new();
decoder.read_to_string(&mut data).await?;
let listings: Vec<rainy::V1Package> = serde_json::from_str(&data).expect("Fuck2");
let mut res: Vec<Package> = [].to_vec();
for l in listings {
if let Some(v) = l.versions.last() {
let mut p = Package::default();
p.name = v.name.clone();
p.namespace = l.owner.clone();
p.description = v.description.clone();
p.version = v.version_number.clone();
p.icon = v.icon.clone();
p.package_url = l.package_url.clone();
p.download_url = v.download_url.clone();
res.push(p);
}
}
Ok(res)
}
pub async fn get_listings(appd: &mut AppData) -> Result<&Vec<Package>> {
if appd.mods_store.len() == 0 {
let listings = fetch_listings().await?;
appd.mods_store = listings;
}
Ok(&appd.mods_store)
}
pub async fn get_remote_meta(
namespace: &str,
name: &str,
) -> Result<rainy::V0Package> {
let url = format!(
"https://rainy.patafour.zip/api/experimental/package/{}/{}/",
namespace, name
);
let res = reqwest::get(url).await?.text().await?;
let package: rainy::V0Package = serde_json::from_str(&res)?;
Ok(package)
}
pub async fn download_package(pkg: Package) -> Result<()> {
use futures::StreamExt;
use tokio::io::AsyncWriteExt;
let zip_path = util::cache_dir().join(format!(
"{}-{}-{}.zip",
pkg.namespace, pkg.name, pkg.version
));
if !zip_path.exists() {
// let zip_path_part = zip_path.add_extension("part");
let mut zip_path_part = zip_path.clone();
zip_path_part.set_extension("zip.part");
let mut cache_file_w = File::create(&zip_path_part).await?;
let mut byte_stream = reqwest::get(&pkg.download_url)
.await?
.bytes_stream();
log::info!("downloading: {}", pkg.download_url);
while let Some(item) = byte_stream.next().await {
let i = item?;
cache_file_w.write_all(&mut i.as_ref()).await?;
}
cache_file_w.sync_all().await?;
tokio::fs::rename(&zip_path_part, &zip_path).await?;
}
let cache_file_r = std::fs::File::open(&zip_path)?;
let mut archive = zip::ZipArchive::new(cache_file_r)?;
pkg_local::delete_package(pkg.namespace.clone(), pkg.name.clone()).await?;
let path = util::get_dirs()
.data_dir()
.join("pkg")
.join(format!("{}-{}", pkg.namespace, pkg.name));
fs::create_dir(&path).await?;
archive.extract(path)?;
Ok(())
}

204
rust/src/pkg_store.rs Normal file
View File

@ -0,0 +1,204 @@
use std::collections::HashMap;
use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use tokio::fs;
use tokio::task::JoinSet;
use crate::model::rainy;
use crate::pkg::{Package, PkgKey};
use crate::util;
use crate::download_handler::DownloadHandler;
pub struct PackageStore {
store: HashMap<PkgKey, Package>,
has_fetched: bool,
app: AppHandle,
dlh: DownloadHandler
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Payload {
pub pkg: PkgKey
}
#[derive(Clone, Serialize, Deserialize)]
pub enum InstallResult {
Ready, Deferred
}
impl PackageStore {
pub fn new(app: AppHandle) -> PackageStore {
PackageStore {
store: HashMap::new(),
has_fetched: false,
app: app.clone(),
dlh: DownloadHandler::new(app)
}
}
pub fn get(&self, key: PkgKey) -> Result<&Package> {
self.store.get(&key)
.ok_or_else(|| anyhow!("Invalid package key"))
}
pub fn get_all(&self) -> HashMap<PkgKey, Package> {
self.store.clone()
}
pub async fn reload_package(&mut self, key: PkgKey) {
let dir = util::pkg_dir().join(&key.0);
if let Ok(pkg) = Package::from_dir(dir).await {
self.update_package(key, pkg);
} else {
log::error!("couldn't reload {}", key);
}
}
pub async fn reload_all(&mut self) -> Result<()> {
let dirents = std::fs::read_dir(util::pkg_dir())?;
let mut futures = JoinSet::new();
for dir in dirents {
if let Ok(dir) = dir {
let path = dir.path();
futures.spawn(Package::from_dir(path));
}
}
while let Some(res) = futures.join_next().await {
if let Ok(Ok(pkg)) = res {
self.update_package(pkg.key(), pkg);
}
}
Ok(())
}
pub async fn fetch_listings(&mut self) -> Result<()> {
if self.has_fetched {
return Ok(());
}
use async_compression::futures::bufread::GzipDecoder;
use futures::{
io::{self, BufReader, ErrorKind},
prelude::*,
};
let response = reqwest::get("https://rainy.patafour.zip/c/ongeki/api/v1/package/").await?;
let reader = response
.bytes_stream()
.map_err(|e| io::Error::new(ErrorKind::Other, e))
.into_async_read();
let mut decoder = GzipDecoder::new(BufReader::new(reader));
let mut data = String::new();
decoder.read_to_string(&mut data).await?;
let listings: Vec<rainy::V1Package> = serde_json::from_str(&data)
.expect("Invalid JSON");
for listing in listings {
// This is None if the package has no versions for whatever reason
if let Some(r) = Package::from_rainy(listing) {
//log::warn!("D {}", &r.rmt.as_ref().unwrap().dependencies.first().unwrap_or(&"Nothing".to_owned()));
match self.store.get_mut(&r.key()) {
Some(l) => {
l.rmt = r.rmt;
}
None => {
self.store.insert(r.key(), r);
}
}
}
}
self.has_fetched = true;
Ok(())
}
pub async fn install_package(&mut self, key: &PkgKey, force: bool) -> Result<InstallResult> {
log::debug!("Installing {}", key);
let pkg = self.store.get(key)
.ok_or_else(|| anyhow!("Attempted to install a nonexistent pkg"))?
.clone();
if pkg.loc.is_some() && !force {
return Ok(InstallResult::Ready);
}
let rmt = pkg.rmt.as_ref() //clone()
.ok_or_else(|| anyhow!("Attempted to install a pkg without remote data"))?;
for dep in &rmt.dependencies {
self.app.emit("install-start", Payload {
pkg: dep.to_owned()
})?;
Box::pin(self.install_package(&dep, false)).await?;
}
let zip_path = util::cache_dir().join(format!(
"{}-{}-{}.zip",
pkg.namespace, pkg.name, rmt.version
));
if !zip_path.exists() {
self.dlh.download_zip(&zip_path, &pkg)?;
return Ok(InstallResult::Deferred);
}
let cache_file_r = std::fs::File::open(&zip_path)?;
let mut archive = zip::ZipArchive::new(cache_file_r)?;
self.delete_package(key, false).await?;
let path = pkg.path();
fs::create_dir(&path).await?;
archive.extract(path)?;
self.reload_package(key.to_owned()).await;
self.app.emit("install-end", Payload {
pkg: key.to_owned()
})?;
log::info!("Installed {}", key);
Ok(InstallResult::Ready)
}
pub async fn delete_package(&mut self, key: &PkgKey, force: bool) -> Result<()> {
let pkg = self.store.get_mut(key)
.ok_or_else(|| anyhow!("Attempted to delete a nonexistent pkg"))?;
let path = pkg.path();
if path.exists() && path.join("manifest.json").exists() {
// TODO don't rm -r - use a file whitelist
log::debug!("rm -r'ing {}", path.to_string_lossy());
pkg.loc = None;
let rv = tokio::fs::remove_dir_all(&path).await
.map_err(|e| anyhow!("Could not delete a package: {}", e));
if rv.is_ok() {
self.app.emit("install-end", Payload {
pkg: key.to_owned()
})?;
log::info!("Deleted {}", key);
}
rv
} else {
if force {
Err(anyhow!("Nothing to delete"))
} else {
Ok(())
}
}
}
fn update_package(&mut self, key: PkgKey, mut new: Package) {
if let Some(old) = self.store.get(&key) {
new.rmt = old.rmt.clone();
}
self.store.insert(key, new);
}
}

View File

@ -1,8 +1,8 @@
use std::path::PathBuf; use std::{collections::HashSet, path::{Path, PathBuf}};
use crate::{model::misc, pkg::PkgKey, util};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::fs; use tokio::fs;
use crate::{model::misc, util};
// {game}-profile-{name}.json // {game}-profile-{name}.json
@ -12,22 +12,53 @@ pub struct Profile {
pub game: misc::Game, pub game: misc::Game,
pub path: PathBuf, pub path: PathBuf,
pub name: String, pub name: String,
pub mods: Vec<String>, pub mods: HashSet<PkgKey>,
pub wine_runtime: Option<PathBuf>,
pub wine_prefix: Option<PathBuf>,
} }
impl Profile { impl Profile {
pub fn new(path: PathBuf) -> Profile { pub fn new(path: PathBuf) -> Profile {
Profile { Profile {
game: misc::Game::Ongeki, game: misc::Game::Ongeki,
path: path, path: path.parent().unwrap().to_owned(),
name: "ongeki-default".to_owned(), name: "ongeki-default".to_owned(),
mods: [].to_vec() mods: HashSet::new(),
#[cfg(target_os = "linux")]
wine_runtime: Some(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,
} }
} }
pub fn load() -> Option<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"))
} else {
None
}
}
pub async fn save(&self) { pub async fn save(&self) {
let path = util::get_dirs().config_dir().join("profile-ongeki-default.json"); let path = util::get_dirs()
.config_dir()
.join("profile-ongeki-default.json");
let s = serde_json::to_string_pretty(self).unwrap(); let s = serde_json::to_string_pretty(self).unwrap();
fs::write(&path, s).await.unwrap(); fs::write(&path, s).await.unwrap();
log::info!("Written to {}", path.to_string_lossy()); log::info!("Written to {}", path.to_string_lossy());
} }
} }

16
rust/src/start.rs Normal file
View File

@ -0,0 +1,16 @@
use anyhow::Result;
use tokio::process::{Child, Command};
use crate::profile::Profile;
use crate::util;
#[cfg(target_os = "linux")]
pub fn start(p: &Profile) -> Result<Child> {
Ok(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.path.join("start.bat"))
.spawn()?)
}

View File

@ -1,5 +1,7 @@
use std::path::PathBuf;
use directories::ProjectDirs; use directories::ProjectDirs;
use std::path::{Path, PathBuf};
use crate::profile::Profile;
pub fn get_dirs() -> ProjectDirs { pub fn get_dirs() -> ProjectDirs {
ProjectDirs::from("org", "7EVENDAYSHOLIDAYS", "STARTLINER") ProjectDirs::from("org", "7EVENDAYSHOLIDAYS", "STARTLINER")
@ -14,6 +16,31 @@ pub fn pkg_dir() -> PathBuf {
get_dirs().data_dir().join("pkg").to_owned() get_dirs().data_dir().join("pkg").to_owned()
} }
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 { pub fn cache_dir() -> PathBuf {
get_dirs().cache_dir().to_owned() get_dirs().cache_dir().to_owned()
} }
pub fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(&dst).unwrap();
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let meta = entry.metadata()?;
if meta.is_dir() {
copy_recursive(&entry.path(), &dst.join(entry.file_name()))?;
} else {
std::fs::copy(&entry.path(), &dst.join(entry.file_name()))?;
}
}
Ok(())
}

View File

@ -1,48 +1,53 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "STARTLINER", "productName": "STARTLINER",
"version": "0.1.0", "version": "0.1.0",
"identifier": "moe.tendokyu.akanyan.startliner", "identifier": "moe.tendokyu.akanyan.startliner",
"build": { "build": {
"beforeDevCommand": "bun run dev", "beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run build", "beforeBuildCommand": "bun run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"plugins": { "plugins": {
"fs": { "fs": {
"requireLiteralLeadingDot": false "requireLiteralLeadingDot": false
},
"deep-link": {
"desktop": {
"schemes": ["rainycolor"]
}
}
},
"app": {
"windows": [
{
"title": "STARTLINER",
"width": 600,
"height": 500,
"minWidth": 600,
"minHeight": 500
}
],
"security": {
"csp": {
"img-src": "'self' asset: https: blob: data:"
},
"assetProtocol": {
"enable": true,
"scope": ["**/*", "**/.*/**/*"]
}
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
} }
},
"app": {
"windows": [
{
"title": "STARTLINER",
"width": 600,
"height": 500,
"minWidth": 600,
"minHeight": 500
}
],
"security": {
"csp": {
"img-src": "'self' asset: https: blob: data:"
},
"assetProtocol": {
"enable": true,
"scope": ["**/*", "**/.*/**/*"]
}
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
} }

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Ref, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { updatePrimaryPalette } from '@primevue/themes';
import Button from 'primevue/button'; import Button from 'primevue/button';
import Tab from 'primevue/tab'; import Tab from 'primevue/tab';
import TabList from 'primevue/tablist'; import TabList from 'primevue/tablist';
@ -8,37 +7,23 @@ import TabPanel from 'primevue/tabpanel';
import TabPanels from 'primevue/tabpanels'; import TabPanels from 'primevue/tabpanels';
import Tabs from 'primevue/tabs'; import Tabs from 'primevue/tabs';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import ModList from './ModList.vue'; import ModList from './ModList.vue';
import ModStore from './ModStore.vue'; import ModStore from './ModStore.vue';
import Options from './Options.vue'; import Options from './Options.vue';
import { Profile } from '../types'; import { usePkgStore } from '../stores';
import { changePrimaryColor } from '../util';
const changePrimaryColor = (game: 'ongeki' | 'chunithm') => { const store = usePkgStore();
const color = game === 'ongeki' ? 'pink' : 'yellow'; store.setupListeners();
updatePrimaryPalette({ const currentTab = ref('3');
50: `{${color}.50}`,
100: `{${color}.100}`,
200: `{${color}.200}`,
300: `{${color}.300}`,
400: `{${color}.400}`,
500: `{${color}.500}`,
600: `{${color}.600}`,
700: `{${color}.700}`,
800: `{${color}.800}`,
900: `{${color}.900}`,
950: `{${color}.950}`,
});
};
let profile: Ref<Profile | null> = ref(null);
let key = ref(0);
const loadProfile = async () => { const loadProfile = async () => {
profile = await invoke('get_current_profile'); await store.reloadProfile();
if (profile === null) { if (store.profile === null) {
const file = await open({ const file = await open({
multiple: false, multiple: false,
directory: false, directory: false,
@ -50,33 +35,46 @@ const loadProfile = async () => {
], ],
}); });
if (file !== null) { if (file !== null) {
profile = await invoke('init_profile', { path: file }); await store.initProfile(file);
} }
} }
key.value += 1; if (store.profile !== null) {
changePrimaryColor(store.profile.game);
currentTab.value = '0';
}
await store.reloadAll();
}; };
const isDisabled = () => profile === null; const isProfileDisabled = computed(() => store.profile === null);
const startline = () => {
invoke('startline');
//startDisabled.value = true;
};
onOpenUrl((urls) => {
console.log('deep link:', urls);
});
onMounted(async () => { onMounted(async () => {
await loadProfile(); await loadProfile();
}); });
changePrimaryColor('ongeki');
</script> </script>
<template> <template>
<main> <main>
<Tabs lazy value="3" class="h-screen"> <Tabs lazy :value="currentTab" class="h-screen">
<div class="fixed w-full flex z-100"> <div class="fixed w-full flex z-100">
<TabList class="grow"> <TabList class="grow">
<Tab :disabled="isDisabled()" :key="key" value="0" <Tab :disabled="isProfileDisabled" value="0"
><div class="pi pi-list-check"></div ><div class="pi pi-list-check"></div
></Tab> ></Tab>
<Tab :disabled="isDisabled()" :key="key" value="1" <Tab :disabled="isProfileDisabled" value="1"
><div class="pi pi-download"></div ><div class="pi pi-download"></div
></Tab> ></Tab>
<Tab :disabled="isDisabled()" :key="key" value="2" <Tab :disabled="isProfileDisabled" value="2"
><div class="pi pi-cog"></div ><div class="pi pi-cog"></div
></Tab> ></Tab>
<Tab value="3" <Tab value="3"
@ -84,18 +82,19 @@ changePrimaryColor('ongeki');
></Tab> ></Tab>
<div class="grow"></div> <div class="grow"></div>
<Button <Button
disabled :disabled="false"
icon="pi pi-play" icon="pi pi-play"
label="START" label="START"
aria-label="start" aria-label="start"
size="small" size="small"
class="m-2.5" class="m-2.5"
@click="startline()"
/> />
</TabList> </TabList>
</div> </div>
<TabPanels class="w-full grow mt-[3rem]"> <TabPanels class="w-full grow mt-[3rem]">
<TabPanel value="0"> <TabPanel value="0">
<ModList :profile="profile!" /> <ModList />
</TabPanel> </TabPanel>
<TabPanel value="1"> <TabPanel value="1">
<ModStore /> <ModStore />
@ -106,6 +105,7 @@ changePrimaryColor('ongeki');
<TabPanel value="3"> <TabPanel value="3">
UNDER CONSTRUCTION<br /><br /> UNDER CONSTRUCTION<br /><br />
<Button <Button
:disabled="!isProfileDisabled"
label="Create profile" label="Create profile"
icon="pi pi-plus" icon="pi pi-plus"
aria-label="open-executable" aria-label="open-executable"

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import Button from 'primevue/button';
import { invoke } from '@tauri-apps/api/core';
import { Package } from '../types';
import { pkgKey } from '../util';
const props = defineProps({
pkg: Object as () => Package,
});
const install = async () => {
if (props.pkg === undefined) {
return;
}
await invoke('install_package', { key: pkgKey(props.pkg) });
//if (rv === 'Deferred') { /* download progress */ }
};
const remove = async () => {
if (props.pkg === undefined) {
return;
}
await invoke('delete_package', {
key: pkgKey(props.pkg),
});
};
</script>
<template>
<Button
v-if="pkg?.loc"
rounded
icon="pi pi-trash"
severity="danger"
aria-label="remove"
size="small"
class="self-center ml-4"
style="width: 2rem; height: 2rem"
:loading="false"
v-on:click="remove()"
/>
<Button
v-else
rounded
icon="pi pi-plus"
severity="success"
aria-label="install"
size="small"
class="self-center ml-4"
style="width: 2rem; height: 2rem"
:loading="false"
v-on:click="install()"
/>
</template>

View File

@ -1,32 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { Reactive, onMounted, reactive } from 'vue';
import Fieldset from 'primevue/fieldset'; import Fieldset from 'primevue/fieldset';
import { invoke } from '@tauri-apps/api/core';
import ModListEntry from './ModListEntry.vue'; import ModListEntry from './ModListEntry.vue';
import { ModEntry, Profile } from '../types'; import { usePkgStore } from '../stores';
import { Profile } from '../types';
const mods: Reactive<{ [key: string]: ModEntry[] }> = reactive({}); defineProps({
const props = defineProps({
profile: Object as () => Profile, profile: Object as () => Profile,
}); });
onMounted(async () => { const pkgs = usePkgStore();
const modsRaw: ModEntry[] = await invoke('get_packages');
modsRaw.forEach((m) => { const group = () => {
if (props.profile?.mods.includes(`${m.namespace}-${m.name}`)) { const a = Object.assign(
m.enabled = true; {},
} Object.groupBy(pkgs.allLocal, ({ namespace }) => namespace)
});
Object.assign(
mods,
Object.groupBy(modsRaw, ({ namespace }) => namespace)
); );
}); return a;
};
pkgs.reloadProfile();
</script> </script>
<template> <template>
<Fieldset v-for="(namespace, key) in mods" :legend="key.toString()"> <Fieldset v-for="(namespace, key) in group()" :legend="key.toString()">
<ModListEntry v-for="m in namespace" :mod="m" /> <ModListEntry v-for="p in namespace" :pkg="p" />
</Fieldset> </Fieldset>
</template> </template>

View File

@ -2,32 +2,33 @@
import Button from 'primevue/button'; 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 ModTitlecard from './ModTitlecard.vue'; import ModTitlecard from './ModTitlecard.vue';
import { ModEntry } from '../types'; import { usePkgStore } from '../stores';
import { Package } from '../types';
defineProps({ const store = usePkgStore();
mod: Object as () => ModEntry,
const props = defineProps({
pkg: Object as () => Package,
}); });
const toggle = (value: boolean) => {
store.toggle(props.pkg, value);
};
</script> </script>
<template> <template>
<div class="flex items-center"> <div class="flex items-center">
<ModTitlecard showVersion :localIcon="true" :mod="mod" /> <ModTitlecard showVersion :pkg="pkg" />
<ToggleSwitch <ToggleSwitch
class="scale-[1.33] shrink-0" class="scale-[1.33] shrink-0"
inputId="switch" inputId="switch"
:modelValue="mod?.enabled" :disabled="!pkg?.loc"
/> :modelValue="store.isEnabled(pkg)"
<Button v-on:value-change="toggle"
rounded
disabled
icon="pi pi-trash"
severity="danger"
aria-label="delete"
size="small"
class="ml-5 self-center shrink-0"
style="width: 2rem; height: 2rem"
/> />
<InstallButton />
<Button <Button
rounded rounded
icon="pi pi-folder" icon="pi pi-folder"
@ -36,7 +37,7 @@ defineProps({
size="small" size="small"
class="ml-2 shrink-0" class="ml-2 shrink-0"
style="width: 2rem; height: 2rem" style="width: 2rem; height: 2rem"
v-on:click="open(mod?.path ?? '')" v-on:click="pkg?.loc && open(pkg.loc.path ?? '')"
/> />
<Button <Button
rounded rounded
@ -46,7 +47,7 @@ defineProps({
size="small" size="small"
class="ml-2 shrink-0" class="ml-2 shrink-0"
style="width: 2rem; height: 2rem" style="width: 2rem; height: 2rem"
v-on:click="open(mod?.package_url ?? '')" v-on:click="pkg?.rmt && open(pkg.rmt.package_url ?? '')"
/> />
</div> </div>
</template> </template>

View File

@ -1,35 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { Reactive, onMounted, reactive } from 'vue'; import { onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import ModStoreEntry from './ModStoreEntry.vue'; import ModStoreEntry from './ModStoreEntry.vue';
import { ModEntry } from '../types'; import { usePkgStore } from '../stores';
const local: Reactive<{ [key: string]: ModEntry }> = reactive({}); const pkgs = usePkgStore();
const listings: Reactive<ModEntry[]> = reactive([]);
const reload = async () => { onMounted(() => {
const modsRaw: ModEntry[] = await invoke('get_packages'); pkgs.fetch();
Object.keys(local).forEach((key) => {
delete local[key];
});
for (const m of modsRaw) {
local[`${m.namespace}-${m.name}`] = m;
}
};
onMounted(async () => {
Object.assign(listings, await invoke('get_listings'));
reload();
}); });
</script> </script>
<template> <template>
<div v-for="l in listings" class="flex flex-row"> <div v-for="p in pkgs.allRemote" class="flex flex-row">
<ModStoreEntry <ModStoreEntry :pkg="p" />
:mod="l"
:isLocal="local[`${l.namespace}-${l.name}`] !== undefined"
v-on:updated="reload()"
/>
</div> </div>
</template> </template>

View File

@ -1,48 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import Button from 'primevue/button'; import Button from 'primevue/button';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-shell'; import { open } from '@tauri-apps/plugin-shell';
import InstallButton from './InstallButton.vue';
import ModTitlecard from './ModTitlecard.vue'; import ModTitlecard from './ModTitlecard.vue';
import { ModEntry } from '../types'; import { Package } from '../types';
const emit = defineEmits(['updated']); defineProps({
const props = defineProps({ pkg: Object as () => Package,
mod: Object as () => ModEntry,
isLocal: Boolean,
}); });
const isLoading = ref(false);
const handlePackageButton = async () => {
isLoading.value = true;
if (!props.isLocal) {
await invoke('download_package', { pkg: props.mod });
} else {
await invoke('delete_package', {
namespace: props.mod?.namespace,
name: props.mod?.name,
});
}
await invoke('reload_packages');
emit('updated');
isLoading.value = false;
};
</script> </script>
<template> <template>
<ModTitlecard :mod="mod" showNamespace /> <ModTitlecard :pkg="pkg" showNamespace />
<Button <InstallButton :pkg="pkg" />
rounded
:icon="isLocal ? 'pi pi-trash' : 'pi pi-plus'"
:severity="isLocal ? 'danger' : 'success'"
aria-label="install"
size="small"
class="self-center"
style="width: 2rem; height: 2rem"
:loading="isLoading"
v-on:click="handlePackageButton()"
/>
<Button <Button
rounded rounded
icon="pi pi-external-link" icon="pi pi-external-link"
@ -51,7 +21,8 @@ const handlePackageButton = async () => {
size="small" size="small"
class="self-center ml-2" class="self-center ml-2"
style="width: 2rem; height: 2rem" style="width: 2rem; height: 2rem"
v-on:click="open(mod?.package_url ?? '')" :disabled="!pkg?.rmt"
v-on:click="open(pkg?.rmt?.package_url ?? '')"
/> />
</template> </template>

View File

@ -1,19 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import { ModEntry } from '../types'; import { Package } from '../types';
defineProps({ const props = defineProps({
mod: Object as () => ModEntry, pkg: Object as () => Package,
modelValue: Boolean,
localIcon: Boolean,
showNamespace: Boolean, showNamespace: Boolean,
showVersion: Boolean, showVersion: Boolean,
}); });
const iconSrc = () => {
const icon = props.pkg?.icon;
if (icon === undefined) {
return '';
} else if (icon.startsWith('https://')) {
return icon;
} else {
return convertFileSrc(icon);
}
};
</script> </script>
<template> <template>
<img <img
:src="localIcon ? convertFileSrc(mod?.icon ?? '') : mod?.icon" :src="iconSrc()"
class="self-center rounded-sm" class="self-center rounded-sm"
width="32px" width="32px"
height="32px" height="32px"
@ -21,23 +31,23 @@ defineProps({
<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" for="switch">
<div> <div>
<span class="text-lg"> <span class="text-lg">
{{ mod?.name ?? 'Untitled' }} {{ pkg?.name ?? 'Untitled' }}
</span> </span>
<span <span
v-if="showNamespace && mod?.namespace" v-if="showNamespace && pkg?.namespace"
class="text-sm opacity-75" class="text-sm opacity-75"
> >
by&nbsp;{{ mod.namespace }} by&nbsp;{{ pkg.namespace }}
</span> </span>
<span <span
v-if="showVersion && mod?.version" v-if="showVersion && pkg?.loc?.version"
class="text-sm opacity-75 m-2" class="text-sm opacity-75 m-2"
> >
{{ mod.version ?? '?.?.?' }} {{ pkg?.loc?.version ?? '?.?.?' }}
</span> </span>
</div> </div>
<div class="text-sm opacity-75"> <div class="text-sm opacity-75">
{{ mod?.description ?? 'No description' }} {{ pkg?.description ?? 'No description' }}
</div> </div>
</label> </label>
</template> </template>

View File

@ -1,13 +1,16 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
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 App from './components/App.vue'; import App from './components/App.vue';
const pinia = createPinia();
const app = createApp(App); const app = createApp(App);
const Preset = definePreset(Theme, {}); const Preset = definePreset(Theme, {});
app.use(pinia);
app.use(PrimeVue, { app.use(PrimeVue, {
theme: { theme: {
preset: Preset, preset: Preset,

106
src/stores.ts Normal file
View File

@ -0,0 +1,106 @@
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';
type InstallStatus = {
pkg: string;
};
export const usePkgStore = defineStore('pkg', {
state: (): { pkg: { [key: string]: Package }; prf: Profile | null } => {
return {
pkg: {},
prf: null,
};
},
getters: {
fromDepString: (state) => (str: string) => state.pkg[str] ?? null,
fromName: (state) => (namespace: string, name: string) =>
state.pkg[`${namespace}-${name}`] ?? null,
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)),
},
actions: {
setupListeners() {
listen<InstallStatus>('install-start', async (ev) => {
await this.reload(ev.payload.pkg);
});
listen<InstallStatus>('install-end', async (ev) => {
await this.reload(ev.payload.pkg);
await this.reloadProfile();
});
},
async reloadAll() {
await invoke('reload_all_packages');
const data = (await invoke('get_all_packages')) as {
[key: string]: Package;
};
for (const k of Object.keys(this)) {
delete this.pkg[k];
}
Object.assign(this.pkg, data);
if (this.prf !== null)
for (const [k, v] of Object.entries(this)) {
if (this.prf.mods.includes(k)) {
if (v.loc) {
v.loc.enabled = true;
} else {
console.error(`${k} enabled but not present`);
}
}
}
},
async reload(pkgOrKey: string | Package) {
const key =
typeof pkgOrKey === 'string' ? pkgOrKey : pkgKey(pkgOrKey);
const rv: Package = await invoke('get_package', {
key,
});
if (this.pkg[key] === undefined) {
this.pkg[key] = {} as Package;
} else {
this.pkg[key].loc = null;
this.pkg[key].rmt = null;
}
Object.assign(this.pkg[key], rv);
},
async initProfile(path: string) {
this.prf = await invoke('init_profile', { path });
},
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) {
if (pkg === undefined) {
return;
}
await invoke('toggle_package', { key: pkgKey(pkg), enable });
await this.reload(pkg);
await this.saveProfile();
},
},
});

View File

@ -1,17 +1,25 @@
export interface ModEntry { export interface Package {
namespace: string; namespace: string;
name: string; name: string;
description: string; description: string;
package_url: string;
icon: string; icon: string;
path: string; loc: {
version: string; version: string;
version_available: string; path: string;
enabled: boolean; dependencies: string[];
} | null;
rmt: {
version: string;
package_url: string;
download_url: string;
deprecated: boolean;
} | null;
} }
export interface Profile { export interface Profile {
name: string; name: string;
game: 'ongeki' | 'chunithm'; game: 'Ongeki' | 'Chunithm';
mods: string[]; mods: string[];
} }
export type PackageC = Map<string, Package>;

22
src/util.ts Normal file
View File

@ -0,0 +1,22 @@
import { updatePrimaryPalette } from '@primevue/themes';
import { Package } from './types';
export const changePrimaryColor = (game: 'Ongeki' | 'Chunithm') => {
const color = game === 'Ongeki' ? 'pink' : 'yellow';
updatePrimaryPalette({
50: `{${color}.50}`,
100: `{${color}.100}`,
200: `{${color}.200}`,
300: `{${color}.300}`,
400: `{${color}.400}`,
500: `{${color}.500}`,
600: `{${color}.600}`,
700: `{${color}.700}`,
800: `{${color}.800}`,
900: `{${color}.900}`,
950: `{${color}.950}`,
});
};
export const pkgKey = (pkg: Package) => `${pkg.namespace}-${pkg.name}`;