Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "collapseloader",
"private": true,
"version": "0.2.9",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "collapseloader"
version = "0.2.9"
version = "0.3.0"
description = "CollapseLoader"
authors = ["dest4590"]
edition = "2021"
Expand Down Expand Up @@ -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" }
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/src/core/clients/client/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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);
Comment on lines +295 to +297
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a new first-time access to the global API via fetch_server_ads() during client launch. API is a LazyLock<Option<Api>> derived from SERVERS.selected_api at initialization time (see core/network/api.rs), while SERVERS.check_servers() runs in a background task during startup. If a user launches a client before the initial server check completes and this becomes the first API access, API can be initialized to None and remain unavailable for the rest of the process. Consider avoiding the API LazyLock here (construct an Api from SERVERS after wait_for_initial_check()), or explicitly await SERVERS.wait_for_initial_check() before fetching ads.

Copilot uses AI. Check for mistakes.

log_debug!("Spawning client process: {}", self.name);

let mut child = cmd
Expand Down
152 changes: 139 additions & 13 deletions src-tauri/src/core/network/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -142,6 +144,130 @@ impl Api {
}
}
}

pub async fn json_async<T: DeserializeOwned>(
&self,
path: &str,
) -> Result<T, Box<dyn std::error::Error>> {
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<serde_json::Value> = 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::<ApiResponse>(&body) {
Ok(api_data) => {
if api_data.success.is_none() || api_data.data.is_none() {
return Err(
"API response does not match required ApiResponse<T> 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 {
Comment on lines +230 to +241
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json_async requires both success and data to be present before it even checks whether success is false. If the API returns an error payload like { success: false, error: ... } without data, this will be reported as a format mismatch and will drop the server-provided error message. Consider checking success first: require data only when success == true, and otherwise use the error field when available.

Suggested change
if api_data.success.is_none() || api_data.data.is_none() {
return Err(
"API response does not match required ApiResponse<T> 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 {
// First, ensure the `success` field is present.
let success = match api_data.success {
Some(s) => s,
None => {
return Err(
"API response missing required `success` field"
.to_string(),
);
}
};
if success {
// For successful responses, `data` must be present.
let data_value = match api_data.data {
Some(d) => d,
None => {
return Err(
"API response missing `data` for successful result"
.to_string(),
);
}
};
*SERVERS.selected_api.write().unwrap() = Some(server.clone());
return Ok(data_value);
} else {
// For error responses, use the server-provided `error` message
// when available, without requiring `data` to be present.

Copilot uses AI. Check for mistakes.
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<Option<Api>> = LazyLock::new(|| {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/core/network/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading