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 44de8f3..38a8f27 100755 Binary files a/bun.lockb and b/bun.lockb differ 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 @@