mirror of
https://github.com/IntQuant/noita_entangled_worlds.git
synced 2025-10-19 07:03:16 +00:00
Automatic mod install
This commit is contained in:
parent
8a79173997
commit
42e390cdb9
10 changed files with 1636 additions and 41 deletions
|
@ -14,6 +14,7 @@ There is a video guide/showcase of the mod by Gudyni (in russian): https://www.y
|
||||||
Discord server: https://discord.gg/RVmAzBNE
|
Discord server: https://discord.gg/RVmAzBNE
|
||||||
|
|
||||||
Special thanks to:
|
Special thanks to:
|
||||||
|
- Contributors.
|
||||||
- @EvaisaDev for allowing to use code from Noita Arena mod.
|
- @EvaisaDev for allowing to use code from Noita Arena mod.
|
||||||
- @dextered for NoitaPatcher.
|
- @dextered for NoitaPatcher.
|
||||||
- Creators of other libraries used in this project.
|
- Creators of other libraries used in this project.
|
||||||
|
|
1001
noita-proxy/Cargo.lock
generated
1001
noita-proxy/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -14,7 +14,7 @@ eframe = { version="0.27.2", features = ["persistence", "glow", "default_fonts"]
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||||
tracing = "0.1.40"
|
tracing = "0.1.40"
|
||||||
tangled = { path = "tangled" }
|
tangled = { path = "tangled" }
|
||||||
serde = { version = "1.0.199", features = ["serde_derive"] }
|
serde = { version = "1.0.199", features = ["serde_derive", "derive"] }
|
||||||
bitcode = "0.6.0"
|
bitcode = "0.6.0"
|
||||||
lz4_flex = { version = "0.11.3", default_features = false, features = ["std"]}
|
lz4_flex = { version = "0.11.3", default_features = false, features = ["std"]}
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
|
@ -22,6 +22,12 @@ steamworks = "0.11.0"
|
||||||
crossbeam = { version = "0.8.4", features = ["crossbeam-channel"] }
|
crossbeam = { version = "0.8.4", features = ["crossbeam-channel"] }
|
||||||
clipboard = "0.5.0"
|
clipboard = "0.5.0"
|
||||||
socket2 = { version = "0.5.7", features = ["all"] }
|
socket2 = { version = "0.5.7", features = ["all"] }
|
||||||
|
egui-file-dialog = "0.5.0"
|
||||||
|
reqwest = { version = "0.12.4", features = ["blocking", "json"] }
|
||||||
|
serde_json = "1.0.117"
|
||||||
|
thiserror = "1.0.61"
|
||||||
|
poll-promise = "0.3.0"
|
||||||
|
zip = "1.3.1"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1
|
opt-level = 1
|
||||||
|
|
|
@ -4,12 +4,16 @@ use std::{
|
||||||
|
|
||||||
use bitcode::{Decode, Encode};
|
use bitcode::{Decode, Encode};
|
||||||
use clipboard::{ClipboardContext, ClipboardProvider};
|
use clipboard::{ClipboardContext, ClipboardProvider};
|
||||||
use eframe::egui::{self, Color32, Layout};
|
use eframe::egui::{self, Align2, Color32, Layout};
|
||||||
|
use mod_manager::{Modmanager, ModmanagerSettings};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use steamworks::{LobbyId, SteamAPIInitError};
|
use steamworks::{LobbyId, SteamAPIInitError};
|
||||||
use tangled::Peer;
|
use tangled::Peer;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
mod mod_manager;
|
||||||
|
pub mod releases;
|
||||||
|
|
||||||
#[derive(Debug, Decode, Encode, Clone)]
|
#[derive(Debug, Decode, Encode, Clone)]
|
||||||
pub struct GameSettings {
|
pub struct GameSettings {
|
||||||
|
@ -20,7 +24,8 @@ pub struct GameSettings {
|
||||||
pub mod net;
|
pub mod net;
|
||||||
|
|
||||||
enum AppState {
|
enum AppState {
|
||||||
Init,
|
Connect,
|
||||||
|
ModManager,
|
||||||
Netman { netman: Arc<net::NetManager> },
|
Netman { netman: Arc<net::NetManager> },
|
||||||
Error { message: String },
|
Error { message: String },
|
||||||
}
|
}
|
||||||
|
@ -34,7 +39,8 @@ impl SteamState {
|
||||||
if env::var_os("NP_DISABLE_STEAM").is_some() {
|
if env::var_os("NP_DISABLE_STEAM").is_some() {
|
||||||
return Err(SteamAPIInitError::FailedGeneric("Disabled by env variable".to_string()))
|
return Err(SteamAPIInitError::FailedGeneric("Disabled by env variable".to_string()))
|
||||||
}
|
}
|
||||||
let (client, single) = steamworks::Client::init_app(881100)?;
|
let app_id = env::var("NP_APPID").ok().and_then(|x| x.parse().ok());
|
||||||
|
let (client, single) = steamworks::Client::init_app(app_id.unwrap_or(881100))?;
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
info!("Spawned steam callback thread");
|
info!("Spawned steam callback thread");
|
||||||
loop {
|
loop {
|
||||||
|
@ -46,15 +52,51 @@ impl SteamState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
state: AppState,
|
struct AppSavedState {
|
||||||
steam_state: Result<SteamState, SteamAPIInitError>,
|
|
||||||
addr: String,
|
addr: String,
|
||||||
debug_mode: bool,
|
debug_mode: bool,
|
||||||
use_constant_seed: bool,
|
use_constant_seed: bool,
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for AppSavedState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
addr: "127.0.0.1:5123".to_string(),
|
||||||
|
debug_mode: false,
|
||||||
|
use_constant_seed: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
state: AppState,
|
||||||
|
modmanager: Modmanager,
|
||||||
|
steam_state: Result<SteamState, SteamAPIInitError>,
|
||||||
|
saved_state: AppSavedState,
|
||||||
|
modmanager_settings: ModmanagerSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODMANAGER: &str = "modman";
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
|
let saved_state = cc.storage.and_then(|storage| eframe::get_value(storage, eframe::APP_KEY))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let modmanager_settings = cc.storage.and_then(|storage| eframe::get_value(storage, MODMANAGER))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
info!("Creating the app...");
|
||||||
|
Self {
|
||||||
|
state: AppState::ModManager,
|
||||||
|
modmanager: Modmanager::default(),
|
||||||
|
steam_state: SteamState::new(),saved_state,
|
||||||
|
modmanager_settings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn start_server(&mut self) {
|
fn start_server(&mut self) {
|
||||||
let bind_addr = "0.0.0.0:5123".parse().unwrap();
|
let bind_addr = "0.0.0.0:5123".parse().unwrap();
|
||||||
let peer = Peer::host(bind_addr, None).unwrap();
|
let peer = Peer::host(bind_addr, None).unwrap();
|
||||||
|
@ -66,8 +108,8 @@ impl App {
|
||||||
|
|
||||||
fn set_netman_settings(&mut self, netman: &Arc<net::NetManager>) {
|
fn set_netman_settings(&mut self, netman: &Arc<net::NetManager>) {
|
||||||
let mut settings = netman.settings.lock().unwrap();
|
let mut settings = netman.settings.lock().unwrap();
|
||||||
settings.debug_mode = self.debug_mode;
|
settings.debug_mode = self.saved_state.debug_mode;
|
||||||
if !self.use_constant_seed {
|
if !self.saved_state.use_constant_seed {
|
||||||
settings.seed = rand::random();
|
settings.seed = rand::random();
|
||||||
}
|
}
|
||||||
netman.accept_local.store(true, Ordering::SeqCst);
|
netman.accept_local.store(true, Ordering::SeqCst);
|
||||||
|
@ -107,34 +149,21 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for App {
|
|
||||||
fn default() -> Self {
|
|
||||||
info!("Creating the app...");
|
|
||||||
Self {
|
|
||||||
state: AppState::Init,
|
|
||||||
addr: "127.0.0.1:5123".to_string(),
|
|
||||||
debug_mode: false,
|
|
||||||
use_constant_seed: false,
|
|
||||||
steam_state: SteamState::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl eframe::App for App {
|
impl eframe::App for App {
|
||||||
fn update(&mut self, ctx: &eframe::egui::Context, _frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &eframe::egui::Context, _frame: &mut eframe::Frame) {
|
||||||
ctx.request_repaint_after(Duration::from_secs(1));
|
ctx.request_repaint_after(Duration::from_secs(1));
|
||||||
match &self.state {
|
match &self.state {
|
||||||
AppState::Init => {
|
AppState::Connect => {
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
ui.heading("Noita Entangled Worlds proxy");
|
ui.heading("Noita Entangled Worlds proxy");
|
||||||
ui.checkbox(&mut self.debug_mode, "Debug mode");
|
ui.checkbox(&mut self.saved_state.debug_mode, "Debug mode");
|
||||||
ui.checkbox(&mut self.use_constant_seed, "Use specified seed");
|
ui.checkbox(&mut self.saved_state.use_constant_seed, "Use specified seed");
|
||||||
if ui.button("Host").clicked() {
|
if ui.button("Host").clicked() {
|
||||||
self.start_server();
|
self.start_server();
|
||||||
}
|
}
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.text_edit_singleline(&mut self.addr);
|
ui.text_edit_singleline(&mut self.saved_state.addr);
|
||||||
let addr = self.addr.parse();
|
let addr = self.saved_state.addr.parse();
|
||||||
ui.add_enabled_ui(addr.is_ok(), |ui| {
|
ui.add_enabled_ui(addr.is_ok(), |ui| {
|
||||||
if ui.button("Connect").clicked() {
|
if ui.button("Connect").clicked() {
|
||||||
if let Ok(addr) = addr {
|
if let Ok(addr) = addr {
|
||||||
|
@ -221,9 +250,23 @@ impl eframe::App for App {
|
||||||
})
|
})
|
||||||
.inner
|
.inner
|
||||||
{
|
{
|
||||||
self.state = AppState::Init;
|
self.state = AppState::Connect;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppState::ModManager => {
|
||||||
|
egui::Window::new("Mod manager").auto_sized().anchor(Align2::CENTER_CENTER, [0.0, 0.0])
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
self.modmanager.update(ctx, ui, &mut self.modmanager_settings, self.steam_state.as_mut().ok())
|
||||||
|
});
|
||||||
|
if self.modmanager.is_done() {
|
||||||
|
self.state = AppState::Connect;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||||
|
eframe::set_value(storage, eframe::APP_KEY, &self.saved_state);
|
||||||
|
eframe::set_value(storage, MODMANAGER, &self.modmanager_settings);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use eframe::NativeOptions;
|
use eframe::{egui::ViewportBuilder, NativeOptions};
|
||||||
use noita_proxy::App;
|
use noita_proxy::App;
|
||||||
use tracing::level_filters::LevelFilter;
|
use tracing::level_filters::LevelFilter;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
@ -14,7 +14,11 @@ fn main() -> Result<(), eframe::Error> {
|
||||||
tracing::subscriber::set_global_default(my_subscriber).expect("setting tracing default failed");
|
tracing::subscriber::set_global_default(my_subscriber).expect("setting tracing default failed");
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"Noita Proxy",
|
"Noita Proxy",
|
||||||
NativeOptions::default(),
|
NativeOptions {
|
||||||
Box::new(|_cc| Box::new(App::default())),
|
viewport: ViewportBuilder::default().with_min_inner_size([800.0, 600.0]),
|
||||||
|
follow_system_theme: false,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Box::new(|cc| Box::new(App::new(cc))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
298
noita-proxy/src/mod_manager.rs
Normal file
298
noita-proxy/src/mod_manager.rs
Normal file
|
@ -0,0 +1,298 @@
|
||||||
|
use std::{
|
||||||
|
fs::{self, File},
|
||||||
|
io,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use eframe::egui::{Align2, Context, Ui};
|
||||||
|
use egui_file_dialog::{DialogState, FileDialog};
|
||||||
|
use poll_promise::Promise;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use steamworks::AppId;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
releases::{get_release_by_tag, Downloader, ReleasesError, Version},
|
||||||
|
SteamState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
enum State {
|
||||||
|
#[default]
|
||||||
|
JustStarted,
|
||||||
|
IsAutomaticPathOk,
|
||||||
|
SelectPath,
|
||||||
|
PreCheckMod,
|
||||||
|
InvalidPath,
|
||||||
|
CheckMod,
|
||||||
|
Done,
|
||||||
|
DownloadMod(Promise<Result<Downloader, ReleasesError>>),
|
||||||
|
Error(io::Error),
|
||||||
|
ReleasesError(ReleasesError),
|
||||||
|
UnpackMod(Promise<Result<(), ReleasesError>>),
|
||||||
|
ConfirmInstall,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Modmanager {
|
||||||
|
state: State,
|
||||||
|
file_dialog: FileDialog,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Modmanager {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
state: Default::default(),
|
||||||
|
file_dialog: FileDialog::default()
|
||||||
|
.anchor(Align2::CENTER_CENTER, [0.0, 0.0])
|
||||||
|
.title("Select path to noita.exe"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||||
|
pub struct ModmanagerSettings {
|
||||||
|
game_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModmanagerSettings {
|
||||||
|
fn try_find_game_path(&mut self, steam_state: Option<&mut SteamState>) {
|
||||||
|
info!("Trying to find game path");
|
||||||
|
if let Some(state) = steam_state {
|
||||||
|
let apps = state.client.apps();
|
||||||
|
let app_id = AppId::from(881100);
|
||||||
|
if apps.is_app_installed(app_id) {
|
||||||
|
let app_install_dir = apps.app_install_dir(app_id);
|
||||||
|
self.game_path = PathBuf::from(app_install_dir).join("noita.exe");
|
||||||
|
info!("Found game path with steam: {}", self.game_path.display())
|
||||||
|
} else {
|
||||||
|
info!("App not installed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn mod_path(&self) -> PathBuf {
|
||||||
|
let mut path = self.game_path.clone();
|
||||||
|
path.pop();
|
||||||
|
path.push("mods");
|
||||||
|
path.push("quant.ew");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Modmanager {
|
||||||
|
pub fn update(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context,
|
||||||
|
ui: &mut Ui,
|
||||||
|
settings: &mut ModmanagerSettings,
|
||||||
|
steam_state: Option<&mut SteamState>,
|
||||||
|
) {
|
||||||
|
if let State::JustStarted = self.state {
|
||||||
|
if check_path_valid(&settings.game_path) {
|
||||||
|
info!("Path is valid, checking mod now");
|
||||||
|
self.state = State::PreCheckMod;
|
||||||
|
} else {
|
||||||
|
settings.try_find_game_path(steam_state);
|
||||||
|
let could_find_automatically = check_path_valid(&settings.game_path);
|
||||||
|
if could_find_automatically {
|
||||||
|
self.state = State::IsAutomaticPathOk;
|
||||||
|
} else {
|
||||||
|
self.select_noita_file();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match &self.state {
|
||||||
|
State::JustStarted => unreachable!(),
|
||||||
|
State::IsAutomaticPathOk => {
|
||||||
|
ui.heading("Found a path automatically:");
|
||||||
|
ui.label(settings.game_path.display().to_string());
|
||||||
|
if ui.button("Use this one").clicked() {
|
||||||
|
self.state = State::PreCheckMod;
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
if ui.button("Select manually").clicked() {
|
||||||
|
self.select_noita_file();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::SelectPath => {
|
||||||
|
if let Some(path) = self.file_dialog.update(ctx).selected() {
|
||||||
|
settings.game_path = path.to_path_buf();
|
||||||
|
if !check_path_valid(&settings.game_path) {
|
||||||
|
self.state = State::InvalidPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.file_dialog.state() == DialogState::Cancelled {
|
||||||
|
// self.select_noita_file()
|
||||||
|
self.state = State::JustStarted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::InvalidPath => {
|
||||||
|
ui.label("This path is not valid");
|
||||||
|
if ui.button("Select again").clicked() {
|
||||||
|
self.select_noita_file();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::PreCheckMod => {
|
||||||
|
// settings.game_path = PathBuf::new();
|
||||||
|
// self.state = State::JustStarted;
|
||||||
|
ui.label("Will check mod install now...");
|
||||||
|
self.state = State::CheckMod;
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
State::CheckMod => {
|
||||||
|
ctx.request_repaint();
|
||||||
|
let mod_path = settings.mod_path();
|
||||||
|
info!("Mod path: {}", mod_path.display());
|
||||||
|
|
||||||
|
self.state = match is_mod_ok(&mod_path) {
|
||||||
|
Ok(true) => State::Done,
|
||||||
|
Ok(false) => State::ConfirmInstall,
|
||||||
|
Err(err) => {
|
||||||
|
error!("Could not check if mod is ok: {}", err);
|
||||||
|
State::Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::ConfirmInstall => {
|
||||||
|
let mod_path = settings.mod_path();
|
||||||
|
ui.label(format!(
|
||||||
|
"Proxy will install the mod to {}",
|
||||||
|
mod_path.display()
|
||||||
|
));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Confirm").clicked() {
|
||||||
|
let download_path = PathBuf::from("mod.zip");
|
||||||
|
let tag = Version::current().into();
|
||||||
|
let promise = Promise::spawn_thread("release-request", move || {
|
||||||
|
mod_downloader_for(tag, download_path)
|
||||||
|
});
|
||||||
|
// Make sure we are deleting the right thing
|
||||||
|
assert!(mod_path.ends_with("quant.ew"));
|
||||||
|
fs::remove_dir_all(mod_path).ok();
|
||||||
|
info!("Current mod deleted");
|
||||||
|
|
||||||
|
self.state = State::DownloadMod(promise)
|
||||||
|
}
|
||||||
|
if ui.button("Select a different path").clicked() {
|
||||||
|
self.select_noita_file()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
State::DownloadMod(promise) => {
|
||||||
|
ui.label("Downloading mod...");
|
||||||
|
match promise.ready() {
|
||||||
|
Some(Ok(downloader)) => {
|
||||||
|
downloader.show_progress(ui);
|
||||||
|
match downloader.ready() {
|
||||||
|
Some(Ok(_)) => {
|
||||||
|
let path = downloader.path().to_path_buf();
|
||||||
|
let directory = settings.mod_path();
|
||||||
|
let promise: Promise<Result<(), ReleasesError>> =
|
||||||
|
Promise::spawn_thread("unpack", move || {
|
||||||
|
extract_and_remove_zip(path, directory)
|
||||||
|
});
|
||||||
|
self.state = State::UnpackMod(promise);
|
||||||
|
}
|
||||||
|
Some(Err(err)) => self.state = State::ReleasesError(err.clone()),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Err(err)) => self.state = State::ReleasesError(err.clone()),
|
||||||
|
None => {
|
||||||
|
ui.label("Receiving release info...");
|
||||||
|
ui.spinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::UnpackMod(promise) => match promise.ready() {
|
||||||
|
Some(Ok(_)) => {
|
||||||
|
ui.label("Mod has been installed!");
|
||||||
|
if ui.button("Continue").clicked() {
|
||||||
|
self.state = State::Done;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Some(Err(err)) => {
|
||||||
|
self.state = State::ReleasesError(err.clone());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
ui.label("Unpacking mod");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
State::Error(err) => {
|
||||||
|
ui.label(format!("Encountered an error: {}", err));
|
||||||
|
if ui.button("Retry").clicked() {
|
||||||
|
self.state = State::JustStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::ReleasesError(err) => {
|
||||||
|
ui.label(format!("Encountered an error: {}", err));
|
||||||
|
if ui.button("Retry").clicked() {
|
||||||
|
self.state = State::JustStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::Done => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_noita_file(&mut self) {
|
||||||
|
self.state = State::SelectPath;
|
||||||
|
self.file_dialog.select_file();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_done(&self) -> bool {
|
||||||
|
if let State::Done = self.state {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mod_downloader_for(
|
||||||
|
tag: crate::releases::Tag,
|
||||||
|
download_path: PathBuf,
|
||||||
|
) -> Result<Downloader, ReleasesError> {
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(None)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
get_release_by_tag(&client, tag)
|
||||||
|
.and_then(|release| release.get_release_assets(&client))
|
||||||
|
.and_then(|asset_list| asset_list.find_by_name("quant.ew.zip").cloned())
|
||||||
|
.and_then(|asset| asset.download(&client, &download_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_and_remove_zip(zip_file: PathBuf, extact_to: PathBuf) -> Result<(), ReleasesError> {
|
||||||
|
let reader = File::open(&zip_file)?;
|
||||||
|
let mut zip = zip::ZipArchive::new(reader)?;
|
||||||
|
info!("Extracting zip file");
|
||||||
|
zip.extract(extact_to)?;
|
||||||
|
info!("Zip file extracted");
|
||||||
|
fs::remove_file(&zip_file).ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_mod_ok(mod_path: &Path) -> io::Result<bool> {
|
||||||
|
if !mod_path.try_exists()? {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let version_path = mod_path.join("files/version.lua");
|
||||||
|
let version = fs::read_to_string(&version_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| Version::parse_from_mod(&v));
|
||||||
|
|
||||||
|
info!("Mod version: {:?}", version);
|
||||||
|
|
||||||
|
if Some(Version::current()) != version {
|
||||||
|
info!("Mod version differs");
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Mod is ok");
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_path_valid(game_path: &Path) -> bool {
|
||||||
|
game_path.ends_with("noita.exe") && game_path.exists()
|
||||||
|
}
|
250
noita-proxy/src/releases.rs
Normal file
250
noita-proxy/src/releases.rs
Normal file
|
@ -0,0 +1,250 @@
|
||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
io::{self, Read, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{atomic::AtomicU64, Arc},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use eframe::egui::{self, Ui};
|
||||||
|
use poll_promise::Promise;
|
||||||
|
use reqwest::blocking::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
use zip::result::ZipError;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone)]
|
||||||
|
pub enum ReleasesError {
|
||||||
|
#[error("Could not complete request: {0}")]
|
||||||
|
Request(Arc<reqwest::Error>),
|
||||||
|
#[error("Asset not found")]
|
||||||
|
AssetNotFound,
|
||||||
|
#[error("Io error: {0}")]
|
||||||
|
Io(Arc<io::Error>),
|
||||||
|
#[error("Zip error: {0}")]
|
||||||
|
Zip(Arc<ZipError>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for ReleasesError {
|
||||||
|
fn from(value: reqwest::Error) -> Self {
|
||||||
|
Self::Request(value.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<io::Error> for ReleasesError {
|
||||||
|
fn from(value: io::Error) -> Self {
|
||||||
|
Self::Io(value.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ZipError> for ReleasesError {
|
||||||
|
fn from(value: ZipError) -> Self {
|
||||||
|
Self::Zip(value.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Release {
|
||||||
|
pub tag_name: String,
|
||||||
|
assets_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct Asset {
|
||||||
|
url: String,
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Asset {
|
||||||
|
pub fn download(&self, client: &Client, path: &Path) -> Result<Downloader, ReleasesError> {
|
||||||
|
let shared = Arc::new(DownloaderSharedState {
|
||||||
|
progress: AtomicU64::new(0),
|
||||||
|
});
|
||||||
|
let client = client.clone();
|
||||||
|
let url = self.url.clone();
|
||||||
|
let file = File::create(path)?;
|
||||||
|
let handle = {
|
||||||
|
let shared = shared.clone();
|
||||||
|
Promise::spawn_thread("downloader", move || {
|
||||||
|
download_thread(client, url, shared, file)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let downloader = Ok(Downloader {
|
||||||
|
shared,
|
||||||
|
handle,
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
size: self.size,
|
||||||
|
});
|
||||||
|
downloader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_thread(
|
||||||
|
client: Client,
|
||||||
|
url: String,
|
||||||
|
shared: Arc<DownloaderSharedState>,
|
||||||
|
mut file: File,
|
||||||
|
) -> Result<(), ReleasesError> {
|
||||||
|
let mut response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("Accept", "application/octet-stream")
|
||||||
|
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
.header("User-agent", "noita proxy")
|
||||||
|
.send()?;
|
||||||
|
let mut buf = [0; 4096];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let len = response.read(&mut buf)?;
|
||||||
|
shared
|
||||||
|
.progress
|
||||||
|
.fetch_add(len as u64, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
if len == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
file.write_all(&buf[..len])?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DownloaderSharedState {
|
||||||
|
progress: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Downloader {
|
||||||
|
shared: Arc<DownloaderSharedState>,
|
||||||
|
size: u64,
|
||||||
|
handle: Promise<Result<(), ReleasesError>>,
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Downloader {
|
||||||
|
pub fn progress(&self) -> (u64, u64) {
|
||||||
|
let written = self
|
||||||
|
.shared
|
||||||
|
.progress
|
||||||
|
.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
(written, self.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_progress(&self, ui: &mut Ui) {
|
||||||
|
let (current, max) = self.progress();
|
||||||
|
ui.label(format!("{} out of {} bytes", current, max));
|
||||||
|
ui.add(egui::ProgressBar::new(current as f32 / max as f32));
|
||||||
|
ui.ctx().request_repaint_after(Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ready(&self) -> Option<&Result<(), ReleasesError>> {
|
||||||
|
self.handle.ready()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AssetList(Vec<Asset>);
|
||||||
|
|
||||||
|
impl AssetList {
|
||||||
|
pub fn find_by_name(&self, name: &str) -> Result<&Asset, ReleasesError> {
|
||||||
|
self.0
|
||||||
|
.iter()
|
||||||
|
.find(|asset| asset.name == name)
|
||||||
|
.ok_or(ReleasesError::AssetNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Tag(String);
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct Version {
|
||||||
|
pub major: u32,
|
||||||
|
pub minor: u32,
|
||||||
|
pub patch: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Version {
|
||||||
|
pub fn parse_from_mod(version: &str) -> Option<Self> {
|
||||||
|
let strip_suffix = version.strip_prefix("return \"")?.strip_suffix("\"")?;
|
||||||
|
Self::parse_from_string(strip_suffix)
|
||||||
|
}
|
||||||
|
fn parse_from_string(version: &str) -> Option<Self> {
|
||||||
|
let mut nums = version.split(".");
|
||||||
|
let major = nums.next()?.parse().ok()?;
|
||||||
|
let minor = nums.next()?.parse().ok()?;
|
||||||
|
let patch = nums.next()?.parse().ok()?;
|
||||||
|
Some(Self {
|
||||||
|
major,
|
||||||
|
minor,
|
||||||
|
patch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn current() -> Self {
|
||||||
|
Self::parse_from_string(env!("CARGO_PKG_VERSION")).expect("can always parse crate version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Version> for Tag {
|
||||||
|
fn from(value: Version) -> Self {
|
||||||
|
Self(format!("v{}.{}.{}", value.major, value.minor, value.patch))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Release {
|
||||||
|
pub fn get_release_assets(&self, client: &Client) -> Result<AssetList, ReleasesError> {
|
||||||
|
let response = client
|
||||||
|
.get(&self.assets_url)
|
||||||
|
.header("Accept", "application/vnd.github+json")
|
||||||
|
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
.header("User-agent", "noita proxy")
|
||||||
|
.send()?;
|
||||||
|
|
||||||
|
Ok(response.json()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_latest_release(client: &Client) -> Result<Release, ReleasesError> {
|
||||||
|
let response = client
|
||||||
|
.get("https://api.github.com/repos/IntQuant/noita_entangled_worlds/releases/latest")
|
||||||
|
.header("Accept", "application/vnd.github+json")
|
||||||
|
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
.header("User-agent", "noita proxy")
|
||||||
|
.send()?;
|
||||||
|
|
||||||
|
Ok(response.json()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_release_by_tag(client: &Client, tag: Tag) -> Result<Release, ReleasesError> {
|
||||||
|
let response = client
|
||||||
|
.get(format!(
|
||||||
|
"https://api.github.com/repos/IntQuant/noita_entangled_worlds/releases/tags/{}",
|
||||||
|
tag.0
|
||||||
|
))
|
||||||
|
.header("Accept", "application/vnd.github+json")
|
||||||
|
.header("X-GitHub-Api-Version", "2022-11-28")
|
||||||
|
.header("User-agent", "noita proxy")
|
||||||
|
.send()?;
|
||||||
|
|
||||||
|
Ok(response.json()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use crate::releases::{get_release_by_tag, Tag};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_assets() {
|
||||||
|
let client = reqwest::blocking::Client::new();
|
||||||
|
// let release = get_latest_release(&client).unwrap();
|
||||||
|
let release = get_release_by_tag(&client, Tag("v0.4.1".to_string())).unwrap();
|
||||||
|
let assets = release.get_release_assets(&client).unwrap();
|
||||||
|
println!("{:?}", release);
|
||||||
|
println!("{:?}", assets);
|
||||||
|
let mod_asset = assets.find_by_name("quant.ew.zip").unwrap();
|
||||||
|
println!("{:?}", mod_asset);
|
||||||
|
assert_eq!(mod_asset.name, "quant.ew.zip")
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,12 @@ import subprocess
|
||||||
from zipfile import ZipFile, ZIP_DEFLATED as COMPRESS_TYPE
|
from zipfile import ZipFile, ZIP_DEFLATED as COMPRESS_TYPE
|
||||||
import shutil
|
import shutil
|
||||||
import os
|
import os
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
cargo_manifest = tomllib.load(open("noita-proxy/Cargo.toml", "rb"))
|
||||||
|
version = cargo_manifest["package"]["version"]
|
||||||
|
|
||||||
|
print("Current version: ", version)
|
||||||
|
|
||||||
def try_remove(path):
|
def try_remove(path):
|
||||||
try:
|
try:
|
||||||
|
@ -47,3 +53,6 @@ with ZipFile("target/noita-proxy-linux.zip", "w") as release:
|
||||||
print("Writing mod release...")
|
print("Writing mod release...")
|
||||||
|
|
||||||
shutil.make_archive("target/quant.ew", "zip", "quant.ew")
|
shutil.make_archive("target/quant.ew", "zip", "quant.ew")
|
||||||
|
|
||||||
|
with ZipFile("target/quant.ew.zip", "a") as release:
|
||||||
|
release.writestr("files/version.lua", f'return "{version}"')
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
return "dev-build"
|
|
|
@ -21,7 +21,7 @@ local enemy_sync = ctx.dofile_and_add_hooks("mods/quant.ew/files/src/enemy_sync.
|
||||||
local world_sync = ctx.dofile_and_add_hooks("mods/quant.ew/files/src/world_sync.lua")
|
local world_sync = ctx.dofile_and_add_hooks("mods/quant.ew/files/src/world_sync.lua")
|
||||||
local item_sync = ctx.dofile_and_add_hooks("mods/quant.ew/files/src/item_sync.lua")
|
local item_sync = ctx.dofile_and_add_hooks("mods/quant.ew/files/src/item_sync.lua")
|
||||||
|
|
||||||
local version = dofile_once("mods/quant.ew/files/version.lua")
|
local version = dofile_once("mods/quant.ew/files/version.lua") or "unknown (dev build)"
|
||||||
|
|
||||||
ModLuaFileAppend("data/scripts/gun/gun.lua", "mods/quant.ew/files/append/gun.lua")
|
ModLuaFileAppend("data/scripts/gun/gun.lua", "mods/quant.ew/files/append/gun.lua")
|
||||||
ModLuaFileAppend("data/scripts/gun/gun_actions.lua", "mods/quant.ew/files/append/action_fix.lua")
|
ModLuaFileAppend("data/scripts/gun/gun_actions.lua", "mods/quant.ew/files/append/action_fix.lua")
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue