From 21a6b3d6dac99907d6e1c8b2fecb2a8e068e938e Mon Sep 17 00:00:00 2001 From: Foxelnay Date: Sun, 22 Mar 2026 22:18:13 +0300 Subject: [PATCH 1/3] Update ru.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Исправление ошибки в слове --- src/i18n/locales/ru.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f17b241..9a489b3 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -476,7 +476,7 @@ "filters": { "installed": "Установленные" }, - "clear_filters": "Отчистить фильтры" + "clear_filters": "Очистить фильтры" }, "theme": { "actions": { From 9d87f51e4f477c413a9ba18b8a949e9f3b9fce8d Mon Sep 17 00:00:00 2001 From: dest4590 Date: Tue, 24 Mar 2026 12:07:15 +0200 Subject: [PATCH 2/3] fix: fixed vertical offset for sidebar (when it on top) --- src/components/layout/Sidebar.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/Sidebar.vue b/src/components/layout/Sidebar.vue index a0ab986..a56b73d 100644 --- a/src/components/layout/Sidebar.vue +++ b/src/components/layout/Sidebar.vue @@ -199,7 +199,7 @@ const sidebarClasses = computed(() => { } if (pos === "top") { - return `${base} w-full h-20 left-0 top-0 flex-row px-6 border-b`; + return `${base} w-full h-20 left-0 ${verticalOffset} flex-row px-6 border-b`; } if (pos === "bottom") { From 7837bac0a19355efdef7e88d03d0be0c1271ad5a Mon Sep 17 00:00:00 2001 From: dest4590 Date: Sun, 29 Mar 2026 21:00:39 +0300 Subject: [PATCH 3/3] feat: bump to 0.3.0 Major --- package.json | 2 +- src-tauri/Cargo.lock | 3 +- src-tauri/Cargo.toml | 3 +- src-tauri/src/core/clients/client/launch.rs | 6 +- src-tauri/src/core/network/api.rs | 152 ++++++++- src-tauri/src/core/network/mod.rs | 1 + src-tauri/src/core/network/server_ads.rs | 334 ++++++++++++++++++++ src-tauri/src/core/utils/globals.rs | 2 +- src-tauri/tauri.conf.json | 2 +- 9 files changed, 486 insertions(+), 19 deletions(-) create mode 100644 src-tauri/src/core/network/server_ads.rs diff --git a/package.json b/package.json index 0942f2f..f00aded 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "collapseloader", "private": true, - "version": "0.2.9", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3105327..e10ea49 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -542,13 +542,14 @@ dependencies = [ [[package]] name = "collapseloader" -version = "0.2.9" +version = "0.3.0" dependencies = [ "base64 0.22.1", "chrono", "colored", "discord-rich-presence", "dotenvy", + "flate2", "futures-util", "md5", "native-dialog", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a342170..4c11d25 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "collapseloader" -version = "0.2.9" +version = "0.3.0" description = "CollapseLoader" authors = ["dest4590"] edition = "2021" @@ -50,6 +50,7 @@ sysinfo = "0.38.4" socket2 = "0.6.3" sha2 = "0.10.9" regex = "1.12.3" +flate2 = "1.1.9" [target.'cfg(windows)'.dependencies] winreg = { version = "0.55.0" } diff --git a/src-tauri/src/core/clients/client/launch.rs b/src-tauri/src/core/clients/client/launch.rs index e934300..8a39d19 100644 --- a/src-tauri/src/core/clients/client/launch.rs +++ b/src-tauri/src/core/clients/client/launch.rs @@ -15,7 +15,7 @@ use crate::core::{ clients::{ internal::agent_overlay::AgentArguments, log_checker::LogChecker, manager::ClientManager, }, - network::analytics::Analytics, + network::{analytics::Analytics, server_ads}, storage::{accounts::ACCOUNT_MANAGER, data::DATA, settings::SETTINGS}, utils::{ globals::{ @@ -292,6 +292,10 @@ impl Client { cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + let servers_dat_path = client_folder.join("servers.dat"); + let ads = server_ads::fetch_server_ads().await; + server_ads::inject_servers_dat(&servers_dat_path, &ads); + log_debug!("Spawning client process: {}", self.name); let mut child = cmd diff --git a/src-tauri/src/core/network/api.rs b/src-tauri/src/core/network/api.rs index 88ac1b0..fef63b8 100644 --- a/src-tauri/src/core/network/api.rs +++ b/src-tauri/src/core/network/api.rs @@ -60,21 +60,23 @@ impl Api { ); } - let response = - match client.get(&url).send() { - Ok(res) => res, - Err(e) => { - if attempt < API_MAX_RETRIES { - std::thread::sleep(Duration::from_secs(attempt as u64)); - continue; - } - log_warn!( + let response = match client.get(&url).send() { + Ok(res) => res, + Err(e) => { + if attempt < API_MAX_RETRIES { + std::thread::sleep(Duration::from_secs(attempt as u64)); + continue; + } + log_warn!( "Failed to reach API server {} for path: {} after {} attempts: {}", - server.url, path, API_MAX_RETRIES, e + server.url, + path, + API_MAX_RETRIES, + e ); - break; - } - }; + break; + } + }; let status = response.status(); if !status.is_success() { @@ -142,6 +144,130 @@ impl Api { } } } + + pub async fn json_async( + &self, + path: &str, + ) -> Result> { + let cache_dir = DATA.root_dir.lock().unwrap().join(API_CACHE_DIR); + cache::ensure_cache_dir(&cache_dir); + + let cache_file_path = cache::cache_file_path(&cache_dir, path); + let cached_data: Option = cache::read_cached_json(&cache_file_path); + + let fetch_network = async { + let client = super::create_client(Duration::from_secs(5)); + + let mut apis = SERVERS.apis.clone(); + if apis.is_empty() { + apis.push(self.api_server.clone()); + } + + let preferred = SERVERS.selected_api.read().unwrap().clone(); + let start_index = preferred + .as_ref() + .and_then(|ps| apis.iter().position(|s| s.url == ps.url)) + .or_else(|| apis.iter().position(|s| s.url == self.api_server.url)) + .unwrap_or(0); + + for server in apis.iter().cycle().skip(start_index).take(apis.len()) { + let url = format!("{}api/{}/{}", server.url, API_VERSION, path); + + for attempt in 1..=API_MAX_RETRIES { + if attempt > 1 { + log_info!( + "Retrying API request (attempt {}/{}) for path: {} on server {}", + attempt, + API_MAX_RETRIES, + path, + server.url + ); + } + + let response = match client.get(&url).send().await { + Ok(res) => res, + Err(e) => { + if attempt < API_MAX_RETRIES { + tokio::time::sleep(Duration::from_secs(attempt as u64)).await; + continue; + } + log_warn!( + "Failed to reach API server {} for path: {} after {} attempts: {}", + server.url, + path, + API_MAX_RETRIES, + e + ); + break; + } + }; + + let status = response.status(); + if !status.is_success() { + log_warn!( + "API returned non-success status {} for path: {} (attempt {}/{})", + status, + path, + attempt, + API_MAX_RETRIES + ); + if (status.is_server_error() || status.as_u16() == 429) + && attempt < API_MAX_RETRIES + { + tokio::time::sleep(Duration::from_secs(attempt as u64)).await; + continue; + } + return Err(format!("API returned status {}", status)); + } + + let body = response.text().await.unwrap_or_default(); + if body.is_empty() { + return Err("API returned empty response".to_string()); + } + + match serde_json::from_str::(&body) { + Ok(api_data) => { + if api_data.success.is_none() || api_data.data.is_none() { + return Err( + "API response does not match required ApiResponse format" + .to_string(), + ); + } + + if api_data.success.unwrap_or(false) { + let data_value = api_data.data.unwrap(); + *SERVERS.selected_api.write().unwrap() = Some(server.clone()); + return Ok(data_value); + } else { + let err_msg = api_data + .error + .unwrap_or_else(|| "Unknown API error".to_string()); + log_warn!("API returned error for path {}: {}", path, err_msg); + return Err(format!("API error: {}", err_msg)); + } + } + Err(e) => return Err(e.to_string()), + } + } + } + + Err("Exceeded maximum API retry attempts across all servers".to_string()) + }; + + match fetch_network.await { + Ok(data_value) => { + cache::write_cache_if_changed(&cache_file_path, &data_value, &cached_data); + Ok(serde_json::from_value(data_value)?) + } + Err(err_msg) => { + if let Some(cached) = cached_data { + Ok(serde_json::from_value(cached)?) + } else { + Err(format!("{} and no cache available", err_msg).into()) + } + } + } + } } pub static API: LazyLock> = LazyLock::new(|| { diff --git a/src-tauri/src/core/network/mod.rs b/src-tauri/src/core/network/mod.rs index b40f244..3d2192e 100644 --- a/src-tauri/src/core/network/mod.rs +++ b/src-tauri/src/core/network/mod.rs @@ -2,6 +2,7 @@ pub mod analytics; pub mod api; pub mod cache; pub mod downloader; +pub mod server_ads; pub mod servers; use crate::log_error; diff --git a/src-tauri/src/core/network/server_ads.rs b/src-tauri/src/core/network/server_ads.rs new file mode 100644 index 0000000..7e04ade --- /dev/null +++ b/src-tauri/src/core/network/server_ads.rs @@ -0,0 +1,334 @@ +use serde::Deserialize; +use std::path::Path; + +use crate::core::network::api::API; +use crate::{log_error, log_info, log_warn}; + +const SERVER_ADS_URL: &str = "server-ads"; + +#[derive(Debug, Clone, Deserialize)] +pub struct ServerAdData { + pub name: String, + pub ip: String, +} + +pub async fn fetch_server_ads() -> Vec { + let Some(api) = API.as_ref() else { + log_warn!("API not available, skipping server ads fetch"); + return vec![]; + }; + + match api.json_async::>(SERVER_ADS_URL).await { + Ok(ads) => { + log_info!("Fetched {} server ad(s)", ads.len()); + ads + } + Err(e) => { + log_warn!("Failed to fetch server ads: {}", e); + vec![] + } + } +} + +pub fn inject_servers_dat(path: &Path, ads: &[ServerAdData]) { + if ads.is_empty() { + return; + } + + let existing = if path.exists() { + read_existing_servers(path) + } else { + vec![] + }; + + let ad_ips: std::collections::HashSet<&str> = ads.iter().map(|a| a.ip.as_str()).collect(); + + let user_servers: Vec<(String, String)> = existing + .into_iter() + .filter(|(_, ip)| !ad_ips.contains(ip.as_str())) + .collect(); + + let mut all_servers: Vec<(String, String)> = + ads.iter().map(|a| (a.name.clone(), a.ip.clone())).collect(); + all_servers.extend(user_servers); + + match write_servers_dat(path, &all_servers) { + Ok(_) => log_info!("Injected {} server(s) into servers.dat", ads.len()), + Err(e) => log_error!("Failed to write servers.dat: {}", e), + } +} + +fn read_existing_servers(path: &Path) -> Vec<(String, String)> { + use flate2::read::GzDecoder; + use std::io::Read; + + let file = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + log_warn!("Could not read existing servers.dat: {}", e); + return vec![]; + } + }; + + // Check if the file starts with the GZIP magic number (0x1f, 0x8b) + // This allows us to recover data if it was incorrectly compressed by previous runs + if file.len() >= 2 && file[0] == 0x1f && file[1] == 0x8b { + let mut decoder = GzDecoder::new(file.as_slice()); + let mut nbt = Vec::new(); + if decoder.read_to_end(&mut nbt).is_ok() { + return parse_nbt_servers(&nbt); + } else { + log_warn!("servers.dat has gzip magic but failed to decode, will overwrite"); + return vec![]; + } + } + + // Minecraft natively uses uncompressed NBT for servers.dat + parse_nbt_servers(&file) +} + +fn parse_nbt_servers(data: &[u8]) -> Vec<(String, String)> { + let mut pos = 0usize; + + if data.len() < 3 || data[pos] != 10 { + return vec![]; + } + pos += 1; + let root_name_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += 2 + root_name_len; + + loop { + if pos >= data.len() { + return vec![]; + } + let tag_type = data[pos]; + pos += 1; + + if tag_type == 0 { + return vec![]; + } + if pos + 2 > data.len() { + return vec![]; + } + let name_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += 2; + if pos + name_len > data.len() { + return vec![]; + } + let name = std::str::from_utf8(&data[pos..pos + name_len]).unwrap_or(""); + pos += name_len; + + if tag_type == 9 && name == "servers" { + if pos + 5 > data.len() { + return vec![]; + } + let _elem_type = data[pos]; + pos += 1; + let count = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) + as usize; + pos += 4; + + let mut servers = Vec::with_capacity(count); + for _ in 0..count { + let (entry, next_pos) = parse_compound_entry(data, pos); + pos = next_pos; + if let Some((n, ip)) = entry { + servers.push((n, ip)); + } + } + return servers; + } + + pos = skip_tag_payload(data, pos, tag_type); + } +} + +fn parse_compound_entry(data: &[u8], mut pos: usize) -> (Option<(String, String)>, usize) { + let mut name: Option = None; + let mut ip: Option = None; + + loop { + if pos >= data.len() { + break; + } + let tag_type = data[pos]; + pos += 1; + + if tag_type == 0 { + break; + } + + if pos + 2 > data.len() { + break; + } + let name_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += 2; + if pos + name_len > data.len() { + break; + } + let key = std::str::from_utf8(&data[pos..pos + name_len]) + .unwrap_or("") + .to_string(); + pos += name_len; + + if tag_type == 8 { + if pos + 2 > data.len() { + break; + } + let val_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += 2; + if pos + val_len > data.len() { + break; + } + let val = std::str::from_utf8(&data[pos..pos + val_len]) + .unwrap_or("") + .to_string(); + pos += val_len; + + match key.as_str() { + "name" => name = Some(val), + "ip" => ip = Some(val), + _ => {} + } + } else { + pos = skip_tag_payload(data, pos, tag_type); + } + } + + let entry = match (name, ip) { + (Some(n), Some(i)) => Some((n, i)), + _ => None, + }; + (entry, pos) +} + +fn skip_tag_payload(data: &[u8], mut pos: usize, tag_type: u8) -> usize { + match tag_type { + 1 => pos + 1, + 2 => pos + 2, + 3 => pos + 4, + 4 => pos + 8, + 5 => pos + 4, + 6 => pos + 8, + 7 => { + if pos + 4 <= data.len() { + let len = + i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) + as usize; + pos + 4 + len + } else { + data.len() + } + } + 8 => { + if pos + 2 <= data.len() { + let len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos + 2 + len + } else { + data.len() + } + } + 9 => { + if pos + 5 <= data.len() { + let elem_type = data[pos]; + let count = i32::from_be_bytes([ + data[pos + 1], + data[pos + 2], + data[pos + 3], + data[pos + 4], + ]) as usize; + pos += 5; + for _ in 0..count { + pos = skip_tag_payload(data, pos, elem_type); + } + pos + } else { + data.len() + } + } + 10 => { + loop { + if pos >= data.len() { + break; + } + let inner_type = data[pos]; + pos += 1; + if inner_type == 0 { + break; + } + if pos + 2 > data.len() { + break; + } + let name_len = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize; + pos += 2 + name_len; + pos = skip_tag_payload(data, pos, inner_type); + } + pos + } + 11 => { + if pos + 4 <= data.len() { + let len = + i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) + as usize; + pos + 4 + len * 4 + } else { + data.len() + } + } + 12 => { + if pos + 4 <= data.len() { + let len = + i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) + as usize; + pos + 4 + len * 8 + } else { + data.len() + } + } + _ => data.len(), + } +} + +fn write_servers_dat(path: &Path, servers: &[(String, String)]) -> std::io::Result<()> { + let nbt = build_nbt(servers); + + // Writes the file as uncompressed NBT which Minecraft demands + std::fs::write(path, nbt) +} + +fn build_nbt(servers: &[(String, String)]) -> Vec { + let mut buf = Vec::new(); + + push_tag_header(&mut buf, 10, ""); + + push_tag_header(&mut buf, 9, "servers"); + buf.push(10); + let count = servers.len() as i32; + buf.extend_from_slice(&count.to_be_bytes()); + + for (name, ip) in servers { + push_tag_header(&mut buf, 8, "name"); + push_nbt_string(&mut buf, name); + + push_tag_header(&mut buf, 8, "ip"); + push_nbt_string(&mut buf, ip); + + buf.push(0); + } + + buf.push(0); + + buf +} + +fn push_tag_header(buf: &mut Vec, tag_type: u8, name: &str) { + buf.push(tag_type); + push_nbt_string(buf, name); +} + +fn push_nbt_string(buf: &mut Vec, s: &str) { + let bytes = s.as_bytes(); + let len = bytes.len() as u16; + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(bytes); +} diff --git a/src-tauri/src/core/utils/globals.rs b/src-tauri/src/core/utils/globals.rs index 812eeba..6186d71 100644 --- a/src-tauri/src/core/utils/globals.rs +++ b/src-tauri/src/core/utils/globals.rs @@ -2,7 +2,7 @@ use std::{fs, path::PathBuf, sync::LazyLock}; use crate::{core::network::servers::Server, log_debug, log_info}; -pub static CODENAME: &str = "TLS"; +pub static CODENAME: &str = "Major"; pub static API_VERSION: &str = "v1"; pub static GITHUB_REPO_OWNER: &str = "dest4590"; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 781a2b7..85934ed 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CollapseLoader", - "version": "0.2.9", + "version": "0.3.0", "identifier": "org.collapseloader", "build": { "beforeDevCommand": "npm run dev",