Moved Main.rs to the src directory and renamed it to main.rs to follow Rust project conventions.
1056 lines
40 KiB
Rust
1056 lines
40 KiB
Rust
// Hide console window on Windows
|
|
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
|
|
|
|
use eframe::egui;
|
|
use reqwest::Client;
|
|
use serde::Deserialize;
|
|
use std::collections::{HashMap, VecDeque};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::mpsc::{channel, Receiver, Sender};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Instant;
|
|
use tokio::runtime::Runtime;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
enum Platform {
|
|
WinX86,
|
|
WinX64,
|
|
WinArm64,
|
|
MacOsIntel,
|
|
MacOsArm64,
|
|
}
|
|
|
|
impl Platform {
|
|
fn name(&self) -> &str {
|
|
match self {
|
|
Platform::WinX86 => "Windows x86",
|
|
Platform::WinX64 => "Windows x64",
|
|
Platform::WinArm64 => "Windows arm64",
|
|
Platform::MacOsIntel => "macOS intel",
|
|
Platform::MacOsArm64 => "macOS arm64",
|
|
}
|
|
}
|
|
|
|
fn path_template(&self) -> &str {
|
|
match self {
|
|
Platform::WinX86 => "win32-x86/spotify_installer-{version}-{number}.exe",
|
|
Platform::WinX64 => "win32-x86_64/spotify_installer-{version}-{number}.exe",
|
|
Platform::WinArm64 => "win32-arm64/spotify_installer-{version}-{number}.exe",
|
|
Platform::MacOsIntel => "osx-x86_64/spotify-autoupdate-{version}-{number}.tbz",
|
|
Platform::MacOsArm64 => "osx-arm64/spotify-autoupdate-{version}-{number}.tbz",
|
|
}
|
|
}
|
|
|
|
fn all() -> Vec<Platform> {
|
|
vec![
|
|
Platform::WinX86,
|
|
Platform::WinX64,
|
|
Platform::WinArm64,
|
|
Platform::MacOsIntel,
|
|
Platform::MacOsArm64,
|
|
]
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct VersionData {
|
|
fullversion: Option<String>,
|
|
}
|
|
|
|
struct UrlGenerator;
|
|
|
|
impl UrlGenerator {
|
|
const BASE_URL: &'static str = "https://upgrade.scdn.co/upgrade/client/";
|
|
|
|
fn generate_url(platform: Platform, version: &str, number: i32) -> String {
|
|
let path = platform
|
|
.path_template()
|
|
.replace("{version}", version)
|
|
.replace("{number}", &number.to_string());
|
|
format!("{}{}", Self::BASE_URL, path)
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn extract_base_version(version: &str) -> String {
|
|
let parts: Vec<&str> = version.split('.').collect();
|
|
if parts.len() >= 3 {
|
|
format!("{}.{}.{}", parts[0], parts[1], parts[2])
|
|
} else {
|
|
version.to_string()
|
|
}
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
fn should_use_win_x86(version: &str) -> bool {
|
|
let base_version = extract_base_version(version);
|
|
let parts: Vec<&str> = base_version.split('.').collect();
|
|
|
|
if parts.len() >= 3 {
|
|
if let (Ok(major), Ok(minor), Ok(patch)) = (
|
|
parts[0].parse::<u32>(),
|
|
parts[1].parse::<u32>(),
|
|
parts[2].parse::<u32>(),
|
|
) {
|
|
return (major, minor, patch) <= (1, 2, 53);
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn validate_version(version: &str) -> bool {
|
|
let re = regex::Regex::new(r"^\d+\.\d+\.\d+\.\d+\.g[0-9a-f]{8}$").unwrap();
|
|
re.is_match(version)
|
|
}
|
|
|
|
fn short_version(version: &str) -> String {
|
|
if let Some(pos) = version.find(".g") {
|
|
return version[..pos].to_string();
|
|
}
|
|
|
|
let parts: Vec<&str> = version.split('.').collect();
|
|
let take = parts.len().min(4);
|
|
parts[..take].join(".")
|
|
}
|
|
|
|
const MAX_CONNECTION_OPTIONS: [usize; 6] = [50, 100, 150, 200, 250, 300];
|
|
|
|
async fn check_url(client: &Client, url: String, platform: Platform) -> Option<(String, Platform)> {
|
|
match client.head(&url).send().await {
|
|
Ok(response) if response.status().is_success() => Some((url, platform)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn fetch_versions_json(client: &Client) -> HashMap<String, VersionData> {
|
|
let url =
|
|
"https://raw.githubusercontent.com/LoaderSpot/LoaderSpot/refs/heads/main/versions.json";
|
|
|
|
match client.get(url).send().await {
|
|
Ok(response) if response.status().is_success() => response
|
|
.json::<HashMap<String, VersionData>>()
|
|
.await
|
|
.unwrap_or_default(),
|
|
_ => HashMap::new(),
|
|
}
|
|
}
|
|
|
|
async fn submit_to_google_form(client: &Client, version: &str) {
|
|
let form_url = "https://docs.google.com/forms/u/0/d/e/1FAIpQLSdqIxSjqt2PcjBlQzhvwqc4QckfWuq5qqWsrdpoTidQHsPGpw/formResponse";
|
|
|
|
let params = [
|
|
("entry.1104502920", version),
|
|
("entry.1319854718", "from LoaderSpot"),
|
|
];
|
|
|
|
let _ = client.post(form_url).form(¶ms).send().await;
|
|
}
|
|
|
|
async fn check_version_and_submit(client: &Client, version: &str) {
|
|
let versions_json = fetch_versions_json(client).await;
|
|
|
|
let version_exists = versions_json
|
|
.values()
|
|
.any(|v| v.fullversion.as_ref().map_or(false, |fv| fv == version));
|
|
|
|
if !version_exists {
|
|
submit_to_google_form(client, version).await;
|
|
}
|
|
}
|
|
|
|
enum SearchMessage {
|
|
Progress(u64, u64),
|
|
Result(String, Platform),
|
|
Complete(String),
|
|
VersionStart(String, usize, usize),
|
|
CompleteAll,
|
|
}
|
|
|
|
async fn search_installers(
|
|
client: &Client,
|
|
version: &str,
|
|
start: i32,
|
|
end: i32,
|
|
platforms: Vec<Platform>,
|
|
max_connections: usize,
|
|
tx: Sender<SearchMessage>,
|
|
pause_flag: Arc<AtomicBool>,
|
|
cancel_flag: Arc<AtomicBool>,
|
|
) {
|
|
use tokio::sync::Semaphore;
|
|
|
|
let total = ((end - start + 1) * platforms.len() as i32) as u64;
|
|
let semaphore = Arc::new(Semaphore::new(max_connections));
|
|
let mut tasks = Vec::new();
|
|
let processed = Arc::new(Mutex::new(0u64));
|
|
|
|
for platform in platforms {
|
|
for number in start..=end {
|
|
let url = UrlGenerator::generate_url(platform, version, number);
|
|
let client = client.clone();
|
|
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
|
let tx_clone = tx.clone();
|
|
let processed_clone = processed.clone();
|
|
let pause_local = pause_flag.clone();
|
|
let cancel_local = cancel_flag.clone();
|
|
|
|
let task = tokio::spawn(async move {
|
|
if cancel_local.load(Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
|
|
while pause_local.load(Ordering::Relaxed) {
|
|
if cancel_local.load(Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
|
}
|
|
|
|
let result = check_url(&client, url, platform).await;
|
|
drop(permit);
|
|
|
|
let mut p = processed_clone.lock().unwrap();
|
|
*p += 1;
|
|
let current = *p;
|
|
drop(p);
|
|
|
|
let _ = tx_clone.send(SearchMessage::Progress(current, total));
|
|
|
|
if let Some((url, platform)) = result {
|
|
let _ = tx_clone.send(SearchMessage::Result(url, platform));
|
|
}
|
|
});
|
|
|
|
tasks.push(task);
|
|
}
|
|
}
|
|
|
|
for task in tasks {
|
|
let _ = task.await;
|
|
}
|
|
|
|
let _ = tx.send(SearchMessage::Complete(version.to_string()));
|
|
}
|
|
|
|
struct SpotifyFinderApp {
|
|
runtime: Runtime,
|
|
versions_input: String,
|
|
range_from: String,
|
|
range_to: String,
|
|
max_connections_index: usize,
|
|
|
|
platform_win_x86: bool,
|
|
platform_win_x64: bool,
|
|
platform_win_arm64: bool,
|
|
platform_macos_intel: bool,
|
|
platform_macos_arm64: bool,
|
|
|
|
report_unknown: bool,
|
|
|
|
search_results: String,
|
|
is_searching: bool,
|
|
is_paused: bool,
|
|
displayed_results: String,
|
|
current_version: Option<String>,
|
|
current_version_index: usize,
|
|
total_versions: usize,
|
|
reveal_queue: VecDeque<String>,
|
|
current_reveal: Option<String>,
|
|
reveal_pos: usize,
|
|
last_reveal: Instant,
|
|
reveal_speed_ms: u64,
|
|
progress: f32,
|
|
progress_text: String,
|
|
total_work: u64,
|
|
processed_global: u64,
|
|
|
|
rx: Option<Receiver<SearchMessage>>,
|
|
found_urls: HashMap<Platform, Vec<String>>,
|
|
pause_flag: Arc<AtomicBool>,
|
|
cancel_flag: Arc<AtomicBool>,
|
|
}
|
|
|
|
impl Default for SpotifyFinderApp {
|
|
fn default() -> Self {
|
|
Self {
|
|
runtime: Runtime::new().unwrap(),
|
|
versions_input: String::new(),
|
|
range_from: "0".to_string(),
|
|
range_to: "5000".to_string(),
|
|
max_connections_index: 1,
|
|
platform_win_x86: false,
|
|
platform_win_x64: false,
|
|
platform_win_arm64: false,
|
|
platform_macos_intel: false,
|
|
platform_macos_arm64: false,
|
|
report_unknown: false,
|
|
search_results: String::new(),
|
|
is_searching: false,
|
|
is_paused: false,
|
|
displayed_results: String::new(),
|
|
reveal_queue: VecDeque::new(),
|
|
current_reveal: None,
|
|
reveal_pos: 0,
|
|
last_reveal: Instant::now(),
|
|
reveal_speed_ms: 8,
|
|
progress: 0.0,
|
|
progress_text: String::new(),
|
|
total_work: 0,
|
|
processed_global: 0,
|
|
rx: None,
|
|
found_urls: HashMap::new(),
|
|
pause_flag: Arc::new(AtomicBool::new(false)),
|
|
cancel_flag: Arc::new(AtomicBool::new(false)),
|
|
current_version: None,
|
|
current_version_index: 0,
|
|
total_versions: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SpotifyFinderApp {
|
|
// Improved reveal logic: finish current item immediately and fast-forward the queue
|
|
fn advance_reveal(&mut self) {
|
|
if self.current_reveal.is_some() && !self.reveal_queue.is_empty() {
|
|
if let Some(cur) = self.current_reveal.take() {
|
|
if self.reveal_pos < cur.len() {
|
|
self.displayed_results.push_str(&cur[self.reveal_pos..]);
|
|
}
|
|
}
|
|
|
|
while self.reveal_queue.len() > 1 {
|
|
if let Some(line) = self.reveal_queue.pop_front() {
|
|
self.displayed_results.push_str(&line);
|
|
}
|
|
}
|
|
|
|
self.current_reveal = None;
|
|
self.reveal_pos = 0;
|
|
}
|
|
|
|
if self.current_reveal.is_none() {
|
|
if let Some(next) = self.reveal_queue.pop_front() {
|
|
self.current_reveal = Some(next);
|
|
self.reveal_pos = 0;
|
|
}
|
|
}
|
|
|
|
if let Some(cur) = &self.current_reveal {
|
|
let now = Instant::now();
|
|
let elapsed = now.duration_since(self.last_reveal);
|
|
|
|
let mut chars_to_show = (elapsed.as_millis() as u64) / (self.reveal_speed_ms.max(1));
|
|
|
|
if chars_to_show > 0 {
|
|
while chars_to_show > 0 && self.reveal_pos < cur.len() {
|
|
let next_char = cur[self.reveal_pos..].chars().next().unwrap();
|
|
self.displayed_results.push(next_char);
|
|
self.reveal_pos += next_char.len_utf8();
|
|
chars_to_show -= 1;
|
|
}
|
|
self.last_reveal = now;
|
|
}
|
|
|
|
if self.reveal_pos >= cur.len() {
|
|
self.current_reveal = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn get_selected_platforms(&self) -> Vec<Platform> {
|
|
let mut platforms = Vec::new();
|
|
if self.platform_win_x86 {
|
|
platforms.push(Platform::WinX86);
|
|
}
|
|
if self.platform_win_x64 {
|
|
platforms.push(Platform::WinX64);
|
|
}
|
|
if self.platform_win_arm64 {
|
|
platforms.push(Platform::WinArm64);
|
|
}
|
|
if self.platform_macos_intel {
|
|
platforms.push(Platform::MacOsIntel);
|
|
}
|
|
if self.platform_macos_arm64 {
|
|
platforms.push(Platform::MacOsArm64);
|
|
}
|
|
platforms
|
|
}
|
|
|
|
fn select_all_platforms(&mut self) {
|
|
self.platform_win_x86 = true;
|
|
self.platform_win_x64 = true;
|
|
self.platform_win_arm64 = true;
|
|
self.platform_macos_intel = true;
|
|
self.platform_macos_arm64 = true;
|
|
}
|
|
|
|
fn select_no_platforms(&mut self) {
|
|
self.platform_win_x86 = false;
|
|
self.platform_win_x64 = false;
|
|
self.platform_win_arm64 = false;
|
|
self.platform_macos_intel = false;
|
|
self.platform_macos_arm64 = false;
|
|
}
|
|
|
|
fn start_search(&mut self) {
|
|
let versions: Vec<String> = self
|
|
.versions_input
|
|
.lines()
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty() && validate_version(s))
|
|
.collect();
|
|
|
|
if versions.is_empty() {
|
|
self.displayed_results = "Error: No valid versions provided".to_string();
|
|
return;
|
|
}
|
|
|
|
let start = self.range_from.parse::<i32>().unwrap_or(0);
|
|
let end = self.range_to.parse::<i32>().unwrap_or(5000);
|
|
|
|
if end < start {
|
|
self.displayed_results = "Error: End range must be >= start range".to_string();
|
|
return;
|
|
}
|
|
|
|
let base_platforms = self.get_selected_platforms();
|
|
if base_platforms.is_empty() {
|
|
self.displayed_results = "Error: No platforms selected".to_string();
|
|
return;
|
|
}
|
|
|
|
let max_conn = MAX_CONNECTION_OPTIONS[self
|
|
.max_connections_index
|
|
.min(MAX_CONNECTION_OPTIONS.len() - 1)];
|
|
|
|
if base_platforms.len() == 1 && base_platforms[0] == Platform::WinX86 && versions.len() == 1
|
|
{
|
|
let user_version = &versions[0];
|
|
if !should_use_win_x86(user_version) {
|
|
self.displayed_results = "Warning: x86 architecture for Windows is no longer supported for versions newer than 1.2.53".to_string();
|
|
return;
|
|
}
|
|
}
|
|
|
|
self.is_searching = true;
|
|
self.progress = 0.0;
|
|
self.progress_text = "Starting...".to_string();
|
|
self.search_results.clear();
|
|
self.displayed_results.clear();
|
|
self.reveal_queue.clear();
|
|
self.current_reveal = None;
|
|
self.found_urls.clear();
|
|
|
|
let mut total_work_calc: u64 = 0;
|
|
for v in &versions {
|
|
let mut cnt = base_platforms.len();
|
|
if base_platforms.contains(&Platform::WinX86) && cnt > 1 && !should_use_win_x86(v) {
|
|
cnt -= 1;
|
|
}
|
|
let per_version = ((end - start + 1) * cnt as i32) as u64;
|
|
total_work_calc = total_work_calc.saturating_add(per_version);
|
|
}
|
|
self.total_work = total_work_calc;
|
|
self.processed_global = 0;
|
|
self.current_version = None;
|
|
self.current_version_index = 0;
|
|
self.total_versions = versions.len();
|
|
|
|
let (tx, rx) = channel();
|
|
self.rx = Some(rx);
|
|
|
|
self.pause_flag.store(false, Ordering::Relaxed);
|
|
self.cancel_flag.store(false, Ordering::Relaxed);
|
|
self.is_paused = false;
|
|
|
|
let versions_to_search = versions.clone();
|
|
let report_unknown = self.report_unknown;
|
|
let pause = self.pause_flag.clone();
|
|
let cancel = self.cancel_flag.clone();
|
|
let base_platforms_for_spawn = base_platforms.clone();
|
|
|
|
self.runtime.spawn(async move {
|
|
let client = Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
.unwrap();
|
|
|
|
let total_versions = versions_to_search.len();
|
|
for (i, version) in versions_to_search.into_iter().enumerate() {
|
|
let _ = tx.clone().send(SearchMessage::VersionStart(
|
|
version.clone(),
|
|
i + 1,
|
|
total_versions,
|
|
));
|
|
if report_unknown {
|
|
check_version_and_submit(&client, &version).await;
|
|
}
|
|
|
|
let mut platforms_for_version = base_platforms_for_spawn.clone();
|
|
if platforms_for_version.contains(&Platform::WinX86)
|
|
&& platforms_for_version.len() > 1
|
|
&& !should_use_win_x86(&version)
|
|
{
|
|
platforms_for_version.retain(|p| *p != Platform::WinX86);
|
|
}
|
|
|
|
if platforms_for_version.is_empty() {
|
|
let _ = tx
|
|
.clone()
|
|
.send(SearchMessage::Complete(version.to_string()));
|
|
continue;
|
|
}
|
|
|
|
search_installers(
|
|
&client,
|
|
&version,
|
|
start,
|
|
end,
|
|
platforms_for_version,
|
|
max_conn,
|
|
tx.clone(),
|
|
pause.clone(),
|
|
cancel.clone(),
|
|
)
|
|
.await;
|
|
|
|
if cancel.load(Ordering::Relaxed) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let _ = tx.send(SearchMessage::CompleteAll);
|
|
});
|
|
}
|
|
|
|
fn stop_search(&mut self) {
|
|
self.cancel_flag.store(true, Ordering::Relaxed);
|
|
self.pause_flag.store(false, Ordering::Relaxed);
|
|
self.is_paused = false;
|
|
self.is_searching = false;
|
|
self.rx = None;
|
|
self.progress_text = "Search stopped".to_string();
|
|
}
|
|
|
|
fn clear_results(&mut self) {
|
|
self.search_results.clear();
|
|
self.displayed_results.clear();
|
|
self.reveal_queue.clear();
|
|
self.current_reveal = None;
|
|
self.found_urls.clear();
|
|
self.progress = 0.0;
|
|
self.progress_text.clear();
|
|
self.total_work = 0;
|
|
self.processed_global = 0;
|
|
}
|
|
|
|
fn update_search_progress(&mut self) {
|
|
if let Some(rx_owned) = self.rx.take() {
|
|
let rx = rx_owned;
|
|
let mut completed = false;
|
|
|
|
while let Ok(msg) = rx.try_recv() {
|
|
match msg {
|
|
SearchMessage::Progress(_current, _total) => {
|
|
self.processed_global = self.processed_global.saturating_add(1);
|
|
let denom = if self.total_work == 0 {
|
|
1
|
|
} else {
|
|
self.total_work
|
|
};
|
|
self.progress = self.processed_global as f32 / denom as f32;
|
|
|
|
if let Some(v) = &self.current_version {
|
|
let short = short_version(v);
|
|
if self.total_versions > 1 {
|
|
self.progress_text = format!(
|
|
"Checking: {}/{}, Version: {}, No. {}/{}",
|
|
self.processed_global,
|
|
denom,
|
|
short,
|
|
self.current_version_index,
|
|
self.total_versions
|
|
);
|
|
} else {
|
|
self.progress_text = format!(
|
|
"Checking: {}/{}, Version: {}",
|
|
self.processed_global, denom, short
|
|
);
|
|
}
|
|
} else {
|
|
self.progress_text =
|
|
format!("Checking: {}/{}", self.processed_global, denom);
|
|
}
|
|
}
|
|
SearchMessage::Result(url, platform) => {
|
|
let entry = self.found_urls.entry(platform).or_insert_with(Vec::new);
|
|
let first = entry.is_empty();
|
|
entry.push(url.clone());
|
|
|
|
if first {
|
|
self.reveal_queue
|
|
.push_back(format!("\n{}:\n", platform.name()));
|
|
}
|
|
self.reveal_queue.push_back(format!("{}\n", url));
|
|
}
|
|
SearchMessage::VersionStart(version, idx, total) => {
|
|
self.current_version = Some(version);
|
|
self.current_version_index = idx;
|
|
self.total_versions = total;
|
|
}
|
|
SearchMessage::Complete(_version) => {}
|
|
SearchMessage::CompleteAll => {
|
|
self.is_searching = false;
|
|
self.progress = 1.0;
|
|
self.progress_text = "Search complete".to_string();
|
|
|
|
let mut found_any = false;
|
|
|
|
for platform in Platform::all() {
|
|
if let Some(urls) = self.found_urls.get(&platform) {
|
|
if !urls.is_empty() {
|
|
found_any = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
self.pause_flag.store(false, Ordering::Relaxed);
|
|
self.cancel_flag.store(false, Ordering::Relaxed);
|
|
self.is_paused = false;
|
|
if !found_any {
|
|
self.displayed_results =
|
|
"Nothing found, consider increasing the search range".to_string();
|
|
}
|
|
|
|
completed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !completed {
|
|
self.rx = Some(rx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl eframe::App for SpotifyFinderApp {
|
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
self.update_search_progress();
|
|
self.advance_reveal();
|
|
|
|
if self.is_searching || !self.reveal_queue.is_empty() || self.current_reveal.is_some() {
|
|
ctx.request_repaint();
|
|
}
|
|
|
|
egui::CentralPanel::default().show(ctx, |ui| {
|
|
ui.heading("Spotify Installer Finder");
|
|
ui.add_space(5.0);
|
|
|
|
ui.horizontal(|ui| {
|
|
egui::Frame::group(ui.style())
|
|
.fill(egui::Color32::from_gray(30))
|
|
.show(ui, |ui| {
|
|
ui.set_width(360.0);
|
|
ui.vertical(|ui| {
|
|
ui.label(egui::RichText::new("Spotify Versions:").strong());
|
|
ui.label(
|
|
egui::RichText::new("One per line. Example: 1.1.68.632.g2b11de83")
|
|
.size(12.0)
|
|
.color(egui::Color32::GRAY),
|
|
);
|
|
|
|
egui::Frame::group(ui.style())
|
|
.fill(egui::Color32::from_gray(20))
|
|
.show(ui, |ui| {
|
|
let desired = egui::Vec2::new(348.0, 106.0);
|
|
let (rect, _resp) =
|
|
ui.allocate_exact_size(desired, egui::Sense::click());
|
|
ui.painter().rect_filled(
|
|
rect,
|
|
4.0,
|
|
egui::Color32::from_gray(20),
|
|
);
|
|
#[allow(deprecated)]
|
|
ui.allocate_ui_at_rect(rect, |ui| {
|
|
#[allow(deprecated)]
|
|
egui::ScrollArea::vertical()
|
|
.id_salt("versions_scroll")
|
|
.show(ui, |ui| {
|
|
ui.add(
|
|
egui::TextEdit::multiline(
|
|
&mut self.versions_input,
|
|
)
|
|
.desired_width(rect.width())
|
|
.font(egui::TextStyle::Monospace)
|
|
.frame(false),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
ui.add_space(5.0);
|
|
|
|
ui.label(egui::RichText::new("Build Number Range:").strong());
|
|
ui.horizontal(|ui| {
|
|
ui.label("From:");
|
|
let desired = egui::Vec2::new(120.0, ui.spacing().interact_size.y);
|
|
let (rect, _resp) =
|
|
ui.allocate_exact_size(desired, egui::Sense::click());
|
|
ui.painter()
|
|
.rect_filled(rect, 4.0, egui::Color32::from_gray(20));
|
|
ui.painter().rect_stroke(
|
|
rect,
|
|
4.0,
|
|
egui::Stroke::new(1.0, egui::Color32::from_gray(80)),
|
|
);
|
|
ui.put(
|
|
rect,
|
|
egui::TextEdit::singleline(&mut self.range_from).frame(false),
|
|
);
|
|
|
|
ui.label("To:");
|
|
let desired = egui::Vec2::new(120.0, ui.spacing().interact_size.y);
|
|
let (rect, _resp) =
|
|
ui.allocate_exact_size(desired, egui::Sense::click());
|
|
ui.painter()
|
|
.rect_filled(rect, 4.0, egui::Color32::from_gray(20));
|
|
ui.painter().rect_stroke(
|
|
rect,
|
|
4.0,
|
|
egui::Stroke::new(1.0, egui::Color32::from_gray(80)),
|
|
);
|
|
ui.put(
|
|
rect,
|
|
egui::TextEdit::singleline(&mut self.range_to).frame(false),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
ui.add_space(5.0);
|
|
|
|
ui.vertical(|ui| {
|
|
egui::Frame::group(ui.style())
|
|
.fill(egui::Color32::from_gray(30))
|
|
.show(ui, |ui| {
|
|
ui.set_width(340.0);
|
|
ui.label(egui::RichText::new("Target Platforms:").strong());
|
|
|
|
ui.horizontal(|ui| {
|
|
let all_selected = self.platform_win_x86
|
|
&& self.platform_win_x64
|
|
&& self.platform_win_arm64
|
|
&& self.platform_macos_intel
|
|
&& self.platform_macos_arm64;
|
|
let toggle_size = egui::Vec2::new(35.0, 18.0);
|
|
let (rect, resp) =
|
|
ui.allocate_exact_size(toggle_size, egui::Sense::click());
|
|
let bg_off = egui::Color32::from_rgb(48, 48, 48);
|
|
let bg_on = egui::Color32::from_rgb(110, 110, 110);
|
|
let knob_color = egui::Color32::from_rgb(230, 230, 230);
|
|
|
|
let radius = toggle_size.y / 2.0;
|
|
ui.painter().rect_filled(
|
|
rect,
|
|
radius,
|
|
if all_selected { bg_on } else { bg_off },
|
|
);
|
|
|
|
if !all_selected {
|
|
let inset = rect.shrink(1.0);
|
|
ui.painter().rect_stroke(
|
|
inset,
|
|
radius,
|
|
egui::Stroke::new(1.0, egui::Color32::from_gray(40)),
|
|
);
|
|
}
|
|
|
|
let knob_radius = radius - 4.0;
|
|
let knob_x = if all_selected {
|
|
rect.right() - radius
|
|
} else {
|
|
rect.left() + radius
|
|
};
|
|
let knob_center = egui::pos2(knob_x, rect.center().y);
|
|
ui.painter()
|
|
.circle_filled(knob_center, knob_radius, knob_color);
|
|
ui.painter().circle_stroke(
|
|
knob_center,
|
|
knob_radius,
|
|
egui::Stroke::new(1.0, egui::Color32::from_gray(90)),
|
|
);
|
|
|
|
if resp.clicked() {
|
|
if all_selected {
|
|
self.select_no_platforms();
|
|
} else {
|
|
self.select_all_platforms();
|
|
}
|
|
}
|
|
|
|
ui.add_space(4.0);
|
|
ui.label(egui::RichText::new("All Platforms").size(12.0));
|
|
});
|
|
|
|
ui.add_space(5.0);
|
|
|
|
ui.horizontal(|ui| {
|
|
ui.vertical(|ui| {
|
|
ui.checkbox(&mut self.platform_win_x86, "Windows x86");
|
|
ui.checkbox(&mut self.platform_win_x64, "Windows x64");
|
|
ui.checkbox(&mut self.platform_win_arm64, "Windows arm64");
|
|
});
|
|
|
|
ui.add_space(10.0);
|
|
|
|
ui.vertical(|ui| {
|
|
ui.checkbox(&mut self.platform_macos_intel, "macOS intel");
|
|
ui.checkbox(&mut self.platform_macos_arm64, "macOS arm64");
|
|
});
|
|
});
|
|
});
|
|
|
|
ui.add_space(5.0);
|
|
|
|
egui::Frame::group(ui.style())
|
|
.fill(egui::Color32::from_gray(30))
|
|
.show(ui, |ui| {
|
|
ui.set_width(340.0);
|
|
ui.label(egui::RichText::new("Advanced:").strong());
|
|
|
|
ui.add_space(5.0);
|
|
|
|
ui.horizontal(|ui| {
|
|
ui.label("Max Connections:");
|
|
|
|
let value_label_w = 34.0;
|
|
let mut slider_w = ui.available_width() - value_label_w - 80.0;
|
|
if slider_w < 80.0 {
|
|
slider_w = 80.0;
|
|
}
|
|
let desired = egui::Vec2::new(
|
|
slider_w,
|
|
ui.spacing().interact_size.y.max(24.0),
|
|
);
|
|
let (rect, resp) =
|
|
ui.allocate_exact_size(desired, egui::Sense::click_and_drag());
|
|
|
|
let radius = 4.0;
|
|
let track_rect = egui::Rect::from_min_max(
|
|
egui::pos2(rect.left(), rect.center().y - 4.0),
|
|
egui::pos2(rect.right(), rect.center().y + 4.0),
|
|
);
|
|
ui.painter().rect_filled(
|
|
track_rect,
|
|
radius,
|
|
egui::Color32::from_gray(60),
|
|
);
|
|
|
|
let max_idx = (MAX_CONNECTION_OPTIONS.len() - 1) as f32;
|
|
let frac = if max_idx > 0.0 {
|
|
(self.max_connections_index as f32) / max_idx
|
|
} else {
|
|
0.0
|
|
};
|
|
let filled_w = (track_rect.width() * frac).max(0.0);
|
|
if filled_w > 0.0 {
|
|
let filled_rect = egui::Rect::from_min_max(
|
|
track_rect.min,
|
|
egui::pos2(track_rect.min.x + filled_w, track_rect.max.y),
|
|
);
|
|
let base_r = 255.0_f32;
|
|
let base_g = 200.0_f32;
|
|
let base_b = 0.0_f32;
|
|
let red_r = 255.0_f32;
|
|
let red_g = 0.0_f32;
|
|
let red_b = 0.0_f32;
|
|
let blend = frac.clamp(0.0, 1.0);
|
|
let r = (base_r * (1.0 - blend) + red_r * blend).round() as u8;
|
|
let g = (base_g * (1.0 - blend) + red_g * blend).round() as u8;
|
|
let b = (base_b * (1.0 - blend) + red_b * blend).round() as u8;
|
|
ui.painter().rect_filled(
|
|
filled_rect,
|
|
radius,
|
|
egui::Color32::from_rgb(r, g, b),
|
|
);
|
|
}
|
|
|
|
let max_idx_usize = max_idx as usize;
|
|
if max_idx_usize >= 2 {
|
|
for i in 1..max_idx_usize {
|
|
let fx = track_rect.left()
|
|
+ (i as f32 / max_idx) * track_rect.width();
|
|
let tick_min = egui::pos2(fx, track_rect.top() - 3.0);
|
|
let tick_max = egui::pos2(fx, track_rect.bottom() + 3.0);
|
|
ui.painter().line_segment(
|
|
[tick_min, tick_max],
|
|
egui::Stroke::new(1.0, egui::Color32::from_gray(120)),
|
|
);
|
|
}
|
|
}
|
|
|
|
let knob_x = track_rect.left() + frac * track_rect.width();
|
|
let knob_center = egui::pos2(knob_x, track_rect.center().y);
|
|
let knob_radius = 8.0;
|
|
ui.painter().circle_filled(
|
|
knob_center,
|
|
knob_radius,
|
|
egui::Color32::from_rgb(240, 240, 240),
|
|
);
|
|
ui.painter().circle_stroke(
|
|
knob_center,
|
|
knob_radius,
|
|
egui::Stroke::new(1.0, egui::Color32::from_gray(100)),
|
|
);
|
|
|
|
if let Some(pointer_pos) = resp.interact_pointer_pos() {
|
|
if resp.dragged() || resp.clicked() {
|
|
let local_x = (pointer_pos.x - track_rect.left())
|
|
.clamp(0.0, track_rect.width());
|
|
let new_frac = if track_rect.width() > 0.0 {
|
|
local_x / track_rect.width()
|
|
} else {
|
|
0.0
|
|
};
|
|
let max_idx_usize =
|
|
(MAX_CONNECTION_OPTIONS.len() - 1) as f32;
|
|
let idx_f = (new_frac * max_idx_usize).round();
|
|
self.max_connections_index = idx_f as usize;
|
|
}
|
|
}
|
|
|
|
ui.add_space(6.0);
|
|
|
|
let val_text = format!(
|
|
"{}",
|
|
MAX_CONNECTION_OPTIONS[self.max_connections_index]
|
|
);
|
|
ui.add_sized(
|
|
egui::Vec2::new(value_label_w, ui.spacing().interact_size.y),
|
|
egui::Label::new(val_text),
|
|
);
|
|
});
|
|
|
|
ui.add_space(5.0);
|
|
|
|
ui.checkbox(&mut self.report_unknown, "Report unknown versions");
|
|
});
|
|
});
|
|
});
|
|
|
|
ui.add_space(8.0);
|
|
|
|
ui.horizontal(|ui| {
|
|
let btn_size = egui::Vec2::new(120.0, 28.0);
|
|
|
|
if self.is_searching {
|
|
if self.is_paused {
|
|
if ui
|
|
.add_sized(btn_size, egui::Button::new("▶ Resume"))
|
|
.clicked()
|
|
{
|
|
self.is_paused = false;
|
|
self.pause_flag.store(false, Ordering::Relaxed);
|
|
}
|
|
} else {
|
|
if ui
|
|
.add_sized(btn_size, egui::Button::new("⏸ Pause"))
|
|
.clicked()
|
|
{
|
|
self.is_paused = true;
|
|
self.pause_flag.store(true, Ordering::Relaxed);
|
|
self.progress_text = "Paused".to_string();
|
|
}
|
|
}
|
|
|
|
if ui
|
|
.add_sized(btn_size, egui::Button::new("⏹ Stop"))
|
|
.clicked()
|
|
{
|
|
self.stop_search();
|
|
}
|
|
} else {
|
|
if ui
|
|
.add_sized(btn_size, egui::Button::new("▶ Start Search"))
|
|
.clicked()
|
|
{
|
|
self.start_search();
|
|
}
|
|
}
|
|
|
|
ui.add_enabled_ui(!self.is_searching, |ui| {
|
|
if ui
|
|
.add_sized(btn_size, egui::Button::new("🗑 Clear Results"))
|
|
.clicked()
|
|
{
|
|
self.clear_results();
|
|
}
|
|
});
|
|
|
|
if self.is_searching {
|
|
ui.separator();
|
|
if self.is_paused {
|
|
ui.label(
|
|
egui::RichText::new("● Paused")
|
|
.color(egui::Color32::from_rgb(255, 200, 0)),
|
|
);
|
|
} else {
|
|
ui.label(
|
|
egui::RichText::new("● Searching...")
|
|
.color(egui::Color32::from_rgb(0, 255, 0)),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
if self.is_searching || self.progress > 0.0 {
|
|
ui.add_space(5.0);
|
|
ui.add(egui::ProgressBar::new(self.progress).text(&self.progress_text));
|
|
}
|
|
|
|
ui.add_space(8.0);
|
|
|
|
ui.label(egui::RichText::new("Search Results").strong());
|
|
|
|
let available_height = ui.available_height() - 10.0;
|
|
|
|
egui::Frame::group(ui.style())
|
|
.fill(egui::Color32::from_gray(20))
|
|
.show(ui, |ui| {
|
|
egui::ScrollArea::vertical()
|
|
.max_height(available_height)
|
|
.show(ui, |ui| {
|
|
let mut read_only: &str = &self.displayed_results;
|
|
ui.add(
|
|
egui::TextEdit::multiline(&mut read_only)
|
|
.desired_width(f32::INFINITY)
|
|
.desired_rows(20)
|
|
.font(egui::TextStyle::Monospace)
|
|
.frame(false),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
fn main() -> Result<(), eframe::Error> {
|
|
let options = eframe::NativeOptions {
|
|
viewport: egui::ViewportBuilder::default()
|
|
.with_inner_size([750.0, 650.0])
|
|
.with_min_inner_size([750.0, 650.0])
|
|
.with_resizable(false)
|
|
.with_maximize_button(false)
|
|
.with_title("LoaderSpot"),
|
|
centered: true,
|
|
..Default::default()
|
|
};
|
|
|
|
eframe::run_native(
|
|
"LoaderSpot",
|
|
options,
|
|
Box::new(|_cc| Ok(Box::new(SpotifyFinderApp::default()))),
|
|
)
|
|
}
|