From a29bce22273a03728d1a51dd615808a8d50cd9fd Mon Sep 17 00:00:00 2001 From: akanyan Date: Sun, 23 Feb 2025 05:12:21 +0100 Subject: [PATCH] feat: phase 2 Newfound motivation --- .gitignore | 4 +- .prettierrc | 2 + README.md | 16 ++- bun.lockb | Bin 141651 -> 146775 bytes package.json | 2 + rust/Cargo.lock | 204 +++++++++++++++++++++++++++++-- rust/Cargo.toml | 7 ++ rust/capabilities/default.json | 3 +- rust/src/appdata.rs | 33 +++++ rust/src/cmd.rs | 135 +++++++++++--------- rust/src/download_handler.rs | 57 +++++++++ rust/src/lib.rs | 152 +++++++++++++++-------- rust/src/liner.rs | 50 ++++++++ rust/src/model/local.rs | 4 +- rust/src/model/misc.rs | 32 +---- rust/src/model/mod.rs | 22 +--- rust/src/model/rainy.rs | 29 +---- rust/src/pkg.rs | 168 +++++++++++++++++++++++++ rust/src/pkg_local.rs | 106 ---------------- rust/src/pkg_remote.rs | 105 ---------------- rust/src/pkg_store.rs | 204 +++++++++++++++++++++++++++++++ rust/src/profile.rs | 45 +++++-- rust/src/start.rs | 16 +++ rust/src/util.rs | 31 ++++- rust/tauri.conf.json | 95 +++++++------- src/components/App.vue | 70 +++++------ src/components/InstallButton.vue | 58 +++++++++ src/components/ModList.vue | 34 +++--- src/components/ModListEntry.vue | 35 +++--- src/components/ModStore.vue | 31 ++--- src/components/ModStoreEntry.vue | 45 ++----- src/components/ModTitlecard.vue | 34 ++++-- src/main.ts | 3 + src/stores.ts | 106 ++++++++++++++++ src/types.ts | 22 ++-- src/util.ts | 22 ++++ 36 files changed, 1367 insertions(+), 615 deletions(-) create mode 100644 rust/src/appdata.rs create mode 100644 rust/src/download_handler.rs create mode 100644 rust/src/liner.rs create mode 100644 rust/src/pkg.rs delete mode 100644 rust/src/pkg_local.rs delete mode 100644 rust/src/pkg_remote.rs create mode 100644 rust/src/pkg_store.rs create mode 100644 rust/src/start.rs create mode 100644 src/components/InstallButton.vue create mode 100644 src/stores.ts create mode 100644 src/util.ts diff --git a/.gitignore b/.gitignore index 7cc02db..212a339 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -# Logs logs *.log npm-debug.log* @@ -12,7 +11,6 @@ dist dist-ssr *.local -# Editor directories and files .vscode .idea .DS_Store @@ -21,3 +19,5 @@ dist-ssr *.njsproj *.sln *.sw? + +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 7a9c591..a10ca89 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,9 +2,11 @@ "trailingComma": "es5", "tabWidth": 4, "semi": true, + "printWidth": 80, "singleQuote": true, "importOrder": [ "^vue$", + "^pinia$", "^@?primevue(.*)$", "^@tauri-apps/(.*)$", "^(.*)vue$", diff --git a/README.md b/README.md index f55e057..6f83db4 100644 --- a/README.md +++ b/README.md @@ -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). -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)). 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. +### Features + +- Clean data modding +- Multi-platform + ### Todo - Updates and auto-updates -- CLI +- Run from CLI - Support CHUNITHM -- Support opts -- `unwrap()unwrap()unwrap()unwrap()unwrap()unwrap()unwrap()` +- Support segatools as a special package +- Progress bars +- Only rebuild the profile when needed ## 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). diff --git a/bun.lockb b/bun.lockb index 44de8f3cee6f0d79c5d14dc14d44ab7d757e82b9..38a8f277a2e95e79e36bd7e2afdd313983d8de03 100755 GIT binary patch delta 26592 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 B7z+RZ delta 24077 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 R<0P8jW~B>GoMw|!{tp$15&8fC diff --git a/package.json b/package.json index 67a394b..080e986 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,12 @@ "@primevue/themes": "^4.2.5", "@tailwindcss/vite": "^4.0.6", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-shell": "~2", "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "pinia": "^3.0.1", "primeicons": "^7.0.0", "primevue": "^4.2.5", "roboto-fontface": "*", diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 91ecb0d..b396814 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -109,12 +109,23 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener", + "event-listener 5.4.0", "event-listener-strategy", "futures-core", "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]] name = "async-channel" version = "2.3.1" @@ -165,6 +176,21 @@ dependencies = [ "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]] name = "async-io" version = "2.4.0" @@ -190,7 +216,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" dependencies = [ - "event-listener", + "event-listener 5.4.0", "event-listener-strategy", "pin-project-lite", ] @@ -201,14 +227,14 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" dependencies = [ - "async-channel", + "async-channel 2.3.1", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener", + "event-listener 5.4.0", "futures-lite", "rustix", "tracing", @@ -243,6 +269,32 @@ dependencies = [ "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]] name = "async-task" version = "4.7.1" @@ -367,7 +419,7 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" dependencies = [ - "async-channel", + "async-channel 2.3.1", "async-task", "futures-io", "futures-lite", @@ -583,6 +635,12 @@ dependencies = [ "inout", ] +[[package]] +name = "closure" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6173fd61b610d15a7566dd7b7620775627441c4ab9dac8906e17cb93a24b782" + [[package]] name = "cocoa" version = "0.26.0" @@ -941,6 +999,27 @@ dependencies = [ "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]] name = "digest" version = "0.10.7" @@ -1185,6 +1264,12 @@ dependencies = [ "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]] name = "event-listener" version = "5.4.0" @@ -1202,7 +1287,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" dependencies = [ - "event-listener", + "event-listener 5.4.0", "pin-project-lite", ] @@ -1655,6 +1740,18 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "gobject-sys" version = "0.18.0" @@ -2286,6 +2383,15 @@ dependencies = [ "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]] name = "lazy_static" version = "1.5.0" @@ -2375,6 +2481,9 @@ name = "log" version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +dependencies = [ + "value-bag", +] [[package]] name = "lzma-rs" @@ -3622,7 +3731,7 @@ dependencies = [ "wasm-streams", "web-sys", "webpki-roots", - "windows-registry", + "windows-registry 0.2.0", ] [[package]] @@ -3853,7 +3962,7 @@ checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" dependencies = [ "bitflags 1.3.2", "cssparser", - "derive_more", + "derive_more 0.99.19", "fxhash", "log", "matches", @@ -4201,7 +4310,10 @@ version = "0.1.0" dependencies = [ "anyhow", "async-compression", + "async-std", + "closure", "derive_builder", + "derive_more 2.0.1", "directories", "flate2", "futures", @@ -4216,9 +4328,11 @@ dependencies = [ "simple_logger", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-shell", + "tauri-plugin-single-instance", "tokio", "zip", ] @@ -4539,6 +4653,26 @@ dependencies = [ "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]] name = "tauri-plugin-dialog" version = "2.2.0" @@ -4623,6 +4757,22 @@ dependencies = [ "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]] name = "tauri-runtime" version = "2.3.0" @@ -5159,6 +5309,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -5217,6 +5373,12 @@ dependencies = [ "serde", ] +[[package]] +name = "value-bag" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" + [[package]] name = "vcpkg" version = "0.2.15" @@ -5621,7 +5783,7 @@ dependencies = [ "windows-implement", "windows-interface", "windows-result", - "windows-strings", + "windows-strings 0.1.0", "windows-targets 0.52.6", ] @@ -5654,7 +5816,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ "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", ] @@ -5677,6 +5850,15 @@ dependencies = [ "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]] name = "windows-sys" version = "0.45.0" @@ -6128,7 +6310,7 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener", + "event-listener 5.4.0", "futures-core", "futures-lite", "hex", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 40e4b38..bbecd4c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -34,4 +34,11 @@ regex = "1.11.1" zip = "2.2.2" tauri-plugin-dialog = "2" 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"] } diff --git a/rust/capabilities/default.json b/rust/capabilities/default.json index 31c1904..845bdc1 100644 --- a/rust/capabilities/default.json +++ b/rust/capabilities/default.json @@ -14,6 +14,7 @@ "shell:default", "opener:default", "dialog:default", - "dialog:default" + "dialog:default", + "deep-link:default" ] } \ No newline at end of file diff --git a/rust/src/appdata.rs b/rust/src/appdata.rs new file mode 100644 index 0000000..9cd34b3 --- /dev/null +++ b/rust/src/appdata.rs @@ -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, + 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(()) + } +} \ No newline at end of file diff --git a/rust/src/cmd.rs b/rust/src/cmd.rs index 37bed1d..1aa3f6c 100644 --- a/rust/src/cmd.rs +++ b/rust/src/cmd.rs @@ -1,81 +1,108 @@ use log; +use std::collections::HashMap; use std::path::PathBuf; use tokio::sync::Mutex; -use crate::pkg_remote; -use crate::pkg_local; +use crate::pkg::{Package, PkgKey}; +use crate::pkg_store::InstallResult; use crate::profile::Profile; -use crate::AppData; -use crate::model::Package; +use crate::appdata::AppData; +use crate::{liner, start}; use tauri::State; #[tauri::command] -pub async fn startline( - state: State<'_, Mutex>, -) -> Result, ()> { +pub async fn startline(state: State<'_, Mutex>) -> Result<(), String> { log::debug!("invoke: startline"); let appd = state.lock().await; - Ok(appd.profile.clone()) -} - -#[tauri::command] -pub async fn download_package(pkg: Package) -> Result<(), String> { - log::debug!("invoke: download_package"); - - 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>) -> 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()) - } + if let Some(p) = &appd.profile { + // TODO if p.needsUpdate + liner::line_up(p).await.expect("Line-up failed"); + start::start(p).map_err(|e| e.to_string()).map(|_| ()) + //Ok(()) + } else { + Err("No profile".to_owned()) } } #[tauri::command] -pub async fn get_packages(state: State<'_, Mutex>) -> Result, ()> { - log::debug!("invoke: get_packages"); +pub async fn install_package(state: State<'_, tokio::sync::Mutex>, key: PkgKey) -> Result { + 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>, 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>, key: PkgKey) -> Result { + 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>, 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>) -> 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>) -> Result, ()> { + log::debug!("invoke: get_all_packages"); let appd = state.lock().await; - Ok(appd.mods_local.clone()) + Ok(appd.pkgs.get_all()) } #[tauri::command] -pub async fn get_listings(state: State<'_, Mutex>) -> Result, String> { - log::debug!("invoke: get_listings"); +pub async fn fetch_listings(state: State<'_, Mutex>) -> Result<(), String> { + log::debug!("invoke: fetch_listings"); let mut appd = state.lock().await; - match pkg_remote::get_listings(&mut appd).await { - Ok(l) => Ok(l.clone()), - Err(e) => Err(e.to_string()) - } + appd.pkgs.fetch_listings().await + .map_err(|e| e.to_string()) } #[tauri::command] -pub async fn get_current_profile( - state: State<'_, Mutex>, -) -> Result, ()> { +pub async fn get_current_profile(state: State<'_, Mutex>) -> Result, ()> { log::debug!("invoke: get_current_profile"); let appd = state.lock().await; @@ -83,9 +110,7 @@ pub async fn get_current_profile( } #[tauri::command] -pub async fn save_profile( - state: State<'_, Mutex> -) -> Result<(), ()> { +pub async fn save_profile(state: State<'_, Mutex>) -> Result<(), ()> { log::debug!("invoke: save_profile"); let appd = state.lock().await; @@ -101,7 +126,7 @@ pub async fn save_profile( #[tauri::command] pub async fn init_profile( state: State<'_, Mutex>, - path: PathBuf + path: PathBuf, ) -> Result { log::debug!("invoke: init_profile"); @@ -112,4 +137,4 @@ pub async fn init_profile( appd.profile = Some(new_profile.clone()); Ok(new_profile) -} \ No newline at end of file +} diff --git a/rust/src/download_handler.rs b/rust/src/download_handler.rs new file mode 100644 index 0000000..b3e186d --- /dev/null +++ b/rust/src/download_handler.rs @@ -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, + 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(()) + } +} \ No newline at end of file diff --git a/rust/src/lib.rs b/rust/src/lib.rs index cfffe4c..1c1e55a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,30 +1,34 @@ -mod cfg; -mod model; mod cmd; -mod pkg_remote; -mod pkg_local; -mod util; +mod model; +mod pkg; +mod pkg_store; mod profile; +mod util; +mod start; +mod liner; +mod download_handler; +mod appdata; -use tokio::{fs, try_join}; -use tokio::sync::Mutex; -use model::Package; -use tauri::Manager; +use closure::closure; +use appdata::AppData; +use pkg::PkgKey; +use pkg_store::PackageStore; use profile::Profile; - -struct AppData { - profile: Option, - mods_local: Vec, - mods_store: Vec, -} +use tauri::{Listener, Manager}; +use tauri_plugin_deep_link::DeepLinkExt; +use tokio::{sync::Mutex, fs, try_join}; #[cfg_attr(mobile, tauri::mobile_entry_point)] -pub async fn run(args: Vec) { - simple_logger::init_with_env().unwrap(); +pub async fn run(_args: Vec) { + simple_logger::init_with_env() + .expect("Unable to initialize the logger"); log::info!( "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!( @@ -33,35 +37,87 @@ pub async fn run(args: Vec) { fs::create_dir_all(util::cache_dir()) ).expect("Unable to create working directories"); - let app_data = AppData { - profile: pkg_local::load_config(), - mods_local: pkg_local::walk_packages(true).await.expect("Unable to scan local packages"), - mods_store: [].to_vec(), - }; + tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + let _ = app + .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::>(); + 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 { - tauri::Builder::default() - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_shell::init()) - .plugin(tauri_plugin_opener::init()) - .setup(|app| { - app.manage(Mutex::new(app_data)); - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - cmd::get_packages, - cmd::reload_packages, - cmd::get_listings, - cmd::download_package, - cmd::delete_package, - cmd::get_current_profile, - cmd::init_profile, - cmd::save_profile, - cmd::startline - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); - } else { - panic!("Not implemented"); - } + app.manage(Mutex::new(app_data)); + app.deep_link().register_all()?; + + let apph = app.handle(); + + app.listen("download-end", closure!(clone apph, |ev| { + let raw = ev.payload(); + let key = PkgKey(raw[1..raw.len()-1].to_owned()); + let apph = apph.clone(); + tauri::async_runtime::spawn(async move { + let mutex = apph.state::>(); + let mut appd = mutex.lock().await; + _ = appd.pkgs.install_package(&key, true).await; + }); + })); + + app.listen("install-end", closure!(clone apph, |ev| { + let payload = serde_json::from_str::(&ev.payload()); + let apph = apph.clone(); + tauri::async_runtime::spawn(async move { + let mutex = apph.state::>(); + let mut appd = mutex.lock().await; + _ = appd.toggle_package(payload.unwrap().pkg, true); + }); + })); + + 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"); } diff --git a/rust/src/liner.rs b/rust/src/liner.rs new file mode 100644 index 0000000..c36cf55 --- /dev/null +++ b/rust/src/liner.rs @@ -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(()) +} diff --git a/rust/src/model/local.rs b/rust/src/model/local.rs index 93a3362..f2fdcfb 100644 --- a/rust/src/model/local.rs +++ b/rust/src/model/local.rs @@ -1,4 +1,5 @@ use serde::Deserialize; +use crate::pkg::PkgKeyVersion; // manifest.json @@ -8,4 +9,5 @@ pub struct PackageManifest { pub name: String, pub version_number: String, pub description: String, -} \ No newline at end of file + pub dependencies: Vec +} diff --git a/rust/src/model/misc.rs b/rust/src/model/misc.rs index a3e94d2..cf772ef 100644 --- a/rust/src/model/misc.rs +++ b/rust/src/model/misc.rs @@ -1,33 +1,7 @@ -use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Serialize}; -#[derive(Clone)] +#[derive(Clone, Serialize, Deserialize)] pub enum Game { Ongeki, Chunithm, -} - -impl Serialize for Game { - fn serialize(&self, serializer: S) -> Result - 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(deserializer: D) -> Result - 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")), - } - } -} +} \ No newline at end of file diff --git a/rust/src/model/mod.rs b/rust/src/model/mod.rs index 1a40f20..70866bd 100644 --- a/rust/src/model/mod.rs +++ b/rust/src/model/mod.rs @@ -1,23 +1,3 @@ pub mod local; pub mod misc; -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, -} +pub mod rainy; \ No newline at end of file diff --git a/rust/src/model/rainy.rs b/rust/src/model/rainy.rs index b4787b5..87715c1 100644 --- a/rust/src/model/rainy.rs +++ b/rust/src/model/rainy.rs @@ -1,4 +1,5 @@ use serde::Deserialize; +use crate::pkg::PkgKeyVersion; // /c/{game}/api/v1/package @@ -14,34 +15,10 @@ pub struct V1Package { #[derive(Deserialize)] #[allow(dead_code)] pub struct V1Version { - // no namespace pub name: String, pub description: String, pub version_number: String, pub icon: String, - pub dependencies: Vec, + pub dependencies: Vec, 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, - pub download_url: String, -} +} \ No newline at end of file diff --git a/rust/src/pkg.rs b/rust/src/pkg.rs new file mode 100644 index 0000000..8c666f0 --- /dev/null +++ b/rust/src/pkg.rs @@ -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, + pub rmt: Option +} + +#[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, + 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 +} + +impl Package { + pub fn from_rainy(mut p: rainy::V1Package) -> Option { + 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 { + 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 { + let (key, _) = Self::parse_dir_name(dir)?; + Ok(key) + } + + pub fn dir_to_namespace(dir: &Path) -> Result { + let (_, n) = Self::parse_dir_name(dir)?; + Ok(n) + } + + fn manifest(dir: &Path) -> Result { + 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) -> Vec { + 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 = unsafe { std::mem::transmute(deps) }; + rv + } +} \ No newline at end of file diff --git a/rust/src/pkg_local.rs b/rust/src/pkg_local.rs deleted file mode 100644 index 9955b5d..0000000 --- a/rust/src/pkg_local.rs +++ /dev/null @@ -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 { - 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> { - 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(()) - } -} \ No newline at end of file diff --git a/rust/src/pkg_remote.rs b/rust/src/pkg_remote.rs deleted file mode 100644 index a51bcda..0000000 --- a/rust/src/pkg_remote.rs +++ /dev/null @@ -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> { - 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 = serde_json::from_str(&data).expect("Fuck2"); - - let mut res: Vec = [].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> { - 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 { - 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(()) -} \ No newline at end of file diff --git a/rust/src/pkg_store.rs b/rust/src/pkg_store.rs new file mode 100644 index 0000000..c6909e0 --- /dev/null +++ b/rust/src/pkg_store.rs @@ -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, + 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 { + 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 = 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 { + 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); + } +} diff --git a/rust/src/profile.rs b/rust/src/profile.rs index 7727ec5..d35b4ca 100644 --- a/rust/src/profile.rs +++ b/rust/src/profile.rs @@ -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 tokio::fs; -use crate::{model::misc, util}; // {game}-profile-{name}.json @@ -12,22 +12,53 @@ pub struct Profile { pub game: misc::Game, pub path: PathBuf, pub name: String, - pub mods: Vec, + pub mods: HashSet, + pub wine_runtime: Option, + pub wine_prefix: Option, } impl Profile { pub fn new(path: PathBuf) -> Profile { Profile { game: misc::Game::Ongeki, - path: path, + path: path.parent().unwrap().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 { + 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) { - 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(); fs::write(&path, s).await.unwrap(); log::info!("Written to {}", path.to_string_lossy()); } -} \ No newline at end of file +} diff --git a/rust/src/start.rs b/rust/src/start.rs new file mode 100644 index 0000000..eac41db --- /dev/null +++ b/rust/src/start.rs @@ -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 { + 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()?) +} \ No newline at end of file diff --git a/rust/src/util.rs b/rust/src/util.rs index 080db8d..4e75b65 100644 --- a/rust/src/util.rs +++ b/rust/src/util.rs @@ -1,5 +1,7 @@ -use std::path::PathBuf; use directories::ProjectDirs; +use std::path::{Path, PathBuf}; + +use crate::profile::Profile; pub fn get_dirs() -> ProjectDirs { ProjectDirs::from("org", "7EVENDAYSHOLIDAYS", "STARTLINER") @@ -14,6 +16,31 @@ pub fn pkg_dir() -> PathBuf { 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 { get_dirs().cache_dir().to_owned() -} \ No newline at end of file +} + +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(()) +} diff --git a/rust/tauri.conf.json b/rust/tauri.conf.json index a589226..86fdd9c 100644 --- a/rust/tauri.conf.json +++ b/rust/tauri.conf.json @@ -1,48 +1,53 @@ { - "$schema": "https://schema.tauri.app/config/2", - "productName": "STARTLINER", - "version": "0.1.0", - "identifier": "moe.tendokyu.akanyan.startliner", - "build": { - "beforeDevCommand": "bun run dev", - "devUrl": "http://localhost:1420", - "beforeBuildCommand": "bun run build", - "frontendDist": "../dist" - }, - "plugins": { - "fs": { - "requireLiteralLeadingDot": false + "$schema": "https://schema.tauri.app/config/2", + "productName": "STARTLINER", + "version": "0.1.0", + "identifier": "moe.tendokyu.akanyan.startliner", + "build": { + "beforeDevCommand": "bun run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "bun run build", + "frontendDist": "../dist" + }, + "plugins": { + "fs": { + "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" - ] - } } diff --git a/src/components/App.vue b/src/components/App.vue index e4a22ac..6ec25f3 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -1,6 +1,5 @@