From fc96a10397f02d8b05f2977d3f4e832d1d622fe8 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sat, 29 Mar 2025 01:37:50 -0400 Subject: [PATCH] feat: Implement async scan, progress bar, and improve Paper detection --- ROADMAP.md | 8 +- src-tauri/src/lib.rs | 386 ++++++++++++++++++++++++++++++++----------- src/App.css | 22 --- src/App.tsx | 73 +++++++- 4 files changed, 361 insertions(+), 128 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 20035ea..bdae66d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,10 +10,11 @@ - [x] Setup SQLite or JSON storage for plugin data - [x] Create core data models - [x] Build server/plugin directory scanner (basic implementation) +- [x] Implement asynchronous server scanning (non-blocking) - [x] Implement JAR file parser for plugin.yml extraction -## Plugin Discovery (In Progress) -- [x] Create server type detection (Paper, Spigot, etc.) +## Plugin Discovery (Needs Refinement) +- [ ] Improve server type detection (Paper/Spigot distinction, etc.) - [x] Implement plugins folder detection logic - [x] Design plugin metadata extraction system - [x] Build plugin hash identification system @@ -35,7 +36,8 @@ ## UI Development (In Progress) - [x] Design and implement main dashboard -- [x] Create plugin list view with version indicators +- [x] Refine plugin list view styling and layout +- [x] Add progress indicator for server scanning - [x] Build server folder selection interface - [x] Implement plugin detail view - [x] Add update notification system diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e190bad..e0ec370 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,13 +3,14 @@ use serde::{Serialize, Deserialize}; use std::path::Path; use std::fs; use std::io::Read; -use tauri::command; +use tauri::{command, Emitter}; use zip::ZipArchive; use yaml_rust::{YamlLoader, Yaml}; use std::fs::File; use sha2::{Sha256, Digest}; use reqwest; use std::error::Error; +use tauri::AppHandle; // Add the crawlers module mod crawlers; @@ -323,11 +324,27 @@ fn yaml_str_array(yaml: &Yaml, key: &str) -> Option> { /// Detect the server type based on files in the server directory fn detect_server_type(server_path: &Path) -> ServerType { - // Check for Paper - if server_path.join("cache").join("patched_1.19.2.jar").exists() || - server_path.join("paper.yml").exists() { + // --- Check for Paper --- (Check before Spigot/Bukkit as Paper includes their files) + // Primary indicator: config/paper-global.yml (or similar variants) + if server_path.join("config").join("paper-global.yml").exists() || + server_path.join("config").join("paper.yml").exists() { // Also check for paper.yml in config, just in case return ServerType::Paper; } + // Secondary indicator: paper.yml in root (less common, but check) + if server_path.join("paper.yml").exists() { + return ServerType::Paper; + } + // Tertiary indicator: Look for a jar file starting with "paper-" in root + if let Ok(entries) = fs::read_dir(server_path) { + for entry in entries.filter_map(Result::ok) { + if let Some(filename) = entry.file_name().to_str() { + if filename.starts_with("paper-") && filename.ends_with(".jar") { + return ServerType::Paper; + } + } + } + } + // --- End Paper Check --- // Check for Spigot if server_path.join("spigot.yml").exists() { @@ -389,6 +406,36 @@ fn detect_minecraft_version(server_path: &Path, server_type: &ServerType) -> Opt } } + // --- Try parsing paper-global.yml for Paper servers --- + if server_type == &ServerType::Paper { + let paper_global_path = server_path.join("config").join("paper-global.yml"); + if paper_global_path.exists() { + if let Ok(content) = fs::read_to_string(&paper_global_path) { + // Use yaml_rust::YamlLoader here + match yaml_rust::YamlLoader::load_from_str(&content) { + Ok(docs) if !docs.is_empty() => { + let doc = &docs[0]; + // Common location for MC version, might differ + if let Some(version) = doc["misc"]["paper-version"].as_str() { + // Often includes build number, try to extract base MC version + let mc_version = version.split('-').next().unwrap_or(version); + return Some(mc_version.to_string()); + } + // Fallback check, some older versions might store it differently + if let Some(version) = doc["settings"]["minecraft-version"].as_str() { + return Some(version.to_string()); + } + } + Err(e) => { + println!("Failed to parse paper-global.yml: {}", e); + } + _ => { /* Empty or invalid YAML */ } + } + } + } + } + // --- End Paper version check --- + // Try from the server jar name pattern if let Ok(entries) = fs::read_dir(server_path) { for entry in entries { @@ -470,14 +517,48 @@ fn get_plugins_directory(server_path: &Path, server_type: &ServerType) -> String } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct ScanResult { server_info: ServerInfo, plugins: Vec, } +// Payload for progress events +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ScanProgress { + processed: usize, + total: usize, + current_file: String, +} + #[command] -fn scan_server_directory(path: &str) -> Result { +async fn scan_server_directory(app_handle: AppHandle, path: String) -> Result<(), String> { + // Spawn the scanning logic into a background thread + tauri::async_runtime::spawn(async move { + // Bring Manager trait into scope for this block + let result = perform_scan(&app_handle, &path).await; + + match result { + Ok(scan_result) => { + // Emit completion event + if let Err(e) = app_handle.emit("scan_complete", scan_result) { + eprintln!("Failed to emit scan_complete event: {}", e); + } + } + Err(e) => { + // Emit error event + if let Err(emit_err) = app_handle.emit("scan_error", e.to_string()) { + eprintln!("Failed to emit scan_error event: {}", emit_err); + } + } + } + }); + + Ok(()) // Return immediately +} + +// The actual scanning logic, moved to a separate async function +async fn perform_scan(app_handle: &AppHandle, path: &str) -> Result { let server_path = Path::new(path); if !server_path.exists() { @@ -498,96 +579,87 @@ fn scan_server_directory(path: &str) -> Result { let plugins_dir = Path::new(&plugins_dir_str); if !plugins_dir.exists() { - return Err(format!("Plugins directory not found at: {}", plugins_dir.display())); + return Err(format!( + "Plugins directory not found at: {}", + plugins_dir.display() + )); } - // Scan for JAR files in the plugins directory - let mut plugins = Vec::new(); - + // --- Progress Reporting Setup --- + let mut jar_files_to_process: Vec = Vec::new(); match fs::read_dir(&plugins_dir) { Ok(entries) => { for entry in entries { if let Ok(entry) = entry { let path = entry.path(); - - // Check if this is a JAR file if path.is_file() && path.extension().map_or(false, |ext| ext.eq_ignore_ascii_case("jar")) { - match extract_plugin_metadata(&path) { - Ok(meta) => { - // Create a Plugin from PluginMeta - let plugin = Plugin { - name: meta.name, - version: meta.version, - latest_version: None, // Will be filled by update checker - description: meta.description, - authors: meta.authors, - has_update: false, // Will be determined by update checker - api_version: meta.api_version, - main_class: meta.main_class, - depend: meta.depend, - soft_depend: meta.soft_depend, - load_before: meta.load_before, - commands: meta.commands, - permissions: meta.permissions, - file_path: meta.file_path, - file_hash: meta.file_hash, - }; - - plugins.push(plugin); - }, - Err(e) => { - // Log error but continue with other plugins - println!("Error reading plugin from {}: {}", path.display(), e); - } - } + jar_files_to_process.push(path); } } } - }, + } Err(e) => { - return Err(format!("Failed to read plugins directory: {}", e)); + return Err(format!("Failed to read plugins directory initially: {}", e)); } } + let total_plugins = jar_files_to_process.len(); + let mut processed_plugins = 0; + // --- End Progress Reporting Setup --- - // If no plugins were found, fall back to mock data for testing - if plugins.is_empty() && server_type == ServerType::Unknown { - // For testing only - in production, we'd just return an empty list - plugins = vec![ - Plugin { - name: "EssentialsX".to_string(), - version: "2.19.0".to_string(), - latest_version: Some("2.20.0".to_string()), - description: Some("Essential server tools for Minecraft".to_string()), - authors: vec!["md_5".to_string(), "SupaHam".to_string()], - has_update: true, - api_version: Some("1.13".to_string()), - main_class: Some("com.earth2me.essentials.Essentials".to_string()), - depend: None, - soft_depend: None, - load_before: None, - commands: None, - permissions: None, - file_path: "EssentialsX.jar".to_string(), - file_hash: calculate_file_hash("EssentialsX.jar").unwrap_or_else(|_| "unknown".to_string()), - }, - Plugin { - name: "WorldEdit".to_string(), - version: "7.2.8".to_string(), - latest_version: Some("7.2.8".to_string()), - description: Some("In-game map editor".to_string()), - authors: vec!["sk89q".to_string(), "wizjany".to_string()], - has_update: false, - api_version: Some("1.13".to_string()), - main_class: Some("com.sk89q.worldedit.bukkit.WorldEditPlugin".to_string()), - depend: None, - soft_depend: None, - load_before: None, - commands: None, - permissions: None, - file_path: "WorldEdit.jar".to_string(), - file_hash: calculate_file_hash("WorldEdit.jar").unwrap_or_else(|_| "unknown".to_string()), - }, - ]; + let mut plugins = Vec::new(); + + for path in jar_files_to_process { + processed_plugins += 1; + let current_file = path.file_name().unwrap_or_default().to_string_lossy().to_string(); + + // Emit progress + let progress = ScanProgress { + processed: processed_plugins, + total: total_plugins, + current_file: current_file.clone(), + }; + if let Err(e) = app_handle.emit("scan_progress", progress) { + eprintln!("Failed to emit scan_progress event: {}", e); + // Continue processing even if event emission fails + } + + // Use spawn_blocking for CPU/IO-bound tasks within the async fn + let meta_result = tauri::async_runtime::spawn_blocking(move || { + extract_plugin_metadata(&path) + }).await; + + match meta_result { + Ok(Ok(meta)) => { + // Create a Plugin from PluginMeta + let plugin = Plugin { + name: meta.name, + version: meta.version, + latest_version: None, // Will be filled by update checker + description: meta.description, + authors: meta.authors, + has_update: false, // Will be determined by update checker + api_version: meta.api_version, + main_class: meta.main_class, + depend: meta.depend, + soft_depend: meta.soft_depend, + load_before: meta.load_before, + commands: meta.commands, + permissions: meta.permissions, + file_path: meta.file_path, + file_hash: meta.file_hash, + }; + plugins.push(plugin); + } + Ok(Err(e)) => { + // Log error but continue with other plugins + println!("Error reading plugin from {}: {}", current_file, e); + // Optionally emit a specific plugin error event here + } + Err(e) => { + // This happens if the blocking task itself panics + println!("Task panicked for plugin {}: {}", current_file, e); + } + } } // Create server info @@ -595,7 +667,7 @@ fn scan_server_directory(path: &str) -> Result { server_type, minecraft_version, plugins_directory: plugins_dir_str, - plugins_count: plugins.len(), + plugins_count: plugins.len(), // Use the count of successfully processed plugins }; Ok(ScanResult { @@ -696,9 +768,8 @@ fn get_crawler(repository: &RepositorySource) -> Option) -> Result, String> { +// Regular repository functions (not commands) +pub fn lib_search_plugins_in_repositories(query: &str, repositories: Vec) -> Result, String> { let mut results: Vec = Vec::new(); // Try each requested repository @@ -725,9 +796,7 @@ pub fn search_repository_plugins(query: &str, repositories: Vec Result { +pub fn lib_get_plugin_details_from_repository(plugin_id: &str, repository: RepositorySource) -> Result { if let Some(crawler) = get_crawler(&repository) { crawler.get_plugin_details(plugin_id).map_err(|e| e.to_string()) } else { @@ -735,9 +804,7 @@ pub fn get_repository_plugin_details(plugin_id: &str, repository: RepositorySour } } -// Command to download a plugin from a repository -#[command] -pub fn download_repository_plugin(plugin_id: &str, version: &str, repository: RepositorySource, destination: &str) -> Result { +pub fn lib_download_plugin_from_repository(plugin_id: &str, version: &str, repository: RepositorySource, destination: &str) -> Result { if let Some(crawler) = get_crawler(&repository) { crawler .download_plugin(plugin_id, version, Path::new(destination)) @@ -747,6 +814,138 @@ pub fn download_repository_plugin(plugin_id: &str, version: &str, repository: Re } } +// A very simple proxy command +#[command] +fn plugin_proxy(action: &str, json_args: &str) -> Result { + match action { + "search" => { + // Parse the JSON arguments + let args: serde_json::Value = serde_json::from_str(json_args) + .map_err(|e| format!("Invalid JSON: {}", e))?; + + let query = args["query"].as_str() + .ok_or_else(|| "Missing query parameter".to_string())?; + + // Convert repositories to RepositorySource + let repo_array = args["repositories"].as_array() + .ok_or_else(|| "Missing repositories parameter".to_string())?; + + let mut repositories = Vec::new(); + for repo in repo_array { + let repo_str = repo.as_str() + .ok_or_else(|| "Repository must be a string".to_string())?; + + let repo_source = match repo_str { + "HangarMC" => RepositorySource::HangarMC, + "SpigotMC" => RepositorySource::SpigotMC, + "Modrinth" => RepositorySource::Modrinth, + "GitHub" => RepositorySource::GitHub, + "BukkitDev" => RepositorySource::BukkitDev, + _ => { + // Handle custom repositories + if repo_str.starts_with("Custom:") { + let url = repo_str.trim_start_matches("Custom:"); + RepositorySource::Custom(url.to_string()) + } else { + return Err(format!("Unknown repository: {}", repo_str)); + } + } + }; + + repositories.push(repo_source); + } + + // Call the implementation + match lib_search_plugins_in_repositories(query, repositories) { + Ok(results) => { + // Convert results to JSON + serde_json::to_string(&results) + .map_err(|e| format!("Failed to serialize results: {}", e)) + } + Err(e) => Err(e), + } + }, + "details" => { + // Parse the JSON arguments + let args: serde_json::Value = serde_json::from_str(json_args) + .map_err(|e| format!("Invalid JSON: {}", e))?; + + let plugin_id = args["plugin_id"].as_str() + .ok_or_else(|| "Missing plugin_id parameter".to_string())?; + + let repo_str = args["repository"].as_str() + .ok_or_else(|| "Missing repository parameter".to_string())?; + + // Convert repository to RepositorySource + let repository = match repo_str { + "HangarMC" => RepositorySource::HangarMC, + "SpigotMC" => RepositorySource::SpigotMC, + "Modrinth" => RepositorySource::Modrinth, + "GitHub" => RepositorySource::GitHub, + "BukkitDev" => RepositorySource::BukkitDev, + _ => { + // Handle custom repositories + if repo_str.starts_with("Custom:") { + let url = repo_str.trim_start_matches("Custom:"); + RepositorySource::Custom(url.to_string()) + } else { + return Err(format!("Unknown repository: {}", repo_str)); + } + } + }; + + // Call the implementation + match lib_get_plugin_details_from_repository(plugin_id, repository) { + Ok(details) => { + // Convert details to JSON + serde_json::to_string(&details) + .map_err(|e| format!("Failed to serialize details: {}", e)) + } + Err(e) => Err(e), + } + }, + "download" => { + // Parse the JSON arguments + let args: serde_json::Value = serde_json::from_str(json_args) + .map_err(|e| format!("Invalid JSON: {}", e))?; + + let plugin_id = args["plugin_id"].as_str() + .ok_or_else(|| "Missing plugin_id parameter".to_string())?; + + let version = args["version"].as_str() + .ok_or_else(|| "Missing version parameter".to_string())?; + + let destination = args["destination"].as_str() + .ok_or_else(|| "Missing destination parameter".to_string())?; + + let repo_str = args["repository"].as_str() + .ok_or_else(|| "Missing repository parameter".to_string())?; + + // Convert repository to RepositorySource + let repository = match repo_str { + "HangarMC" => RepositorySource::HangarMC, + "SpigotMC" => RepositorySource::SpigotMC, + "Modrinth" => RepositorySource::Modrinth, + "GitHub" => RepositorySource::GitHub, + "BukkitDev" => RepositorySource::BukkitDev, + _ => { + // Handle custom repositories + if repo_str.starts_with("Custom:") { + let url = repo_str.trim_start_matches("Custom:"); + RepositorySource::Custom(url.to_string()) + } else { + return Err(format!("Unknown repository: {}", repo_str)); + } + } + }; + + // Call the implementation + lib_download_plugin_from_repository(plugin_id, version, repository, destination) + }, + _ => Err(format!("Unknown action: {}", action)), + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -754,10 +953,9 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ greet, scan_server_directory, - search_repository_plugins, - get_repository_plugin_details, - download_repository_plugin + plugin_proxy ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } + diff --git a/src/App.css b/src/App.css index 422a119..3ef7968 100644 --- a/src/App.css +++ b/src/App.css @@ -134,35 +134,13 @@ body { } .plugins-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); - gap: 1rem; margin-top: 1.5rem; } .plugins-list h2 { - grid-column: 1 / -1; margin-bottom: 1rem; } -.plugin-card { - background-color: var(--surface-color); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 1rem; - cursor: pointer; - transition: transform 0.2s, box-shadow 0.2s; - display: flex; - flex-direction: column; - height: 100%; - min-height: 120px; -} - -.plugin-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); -} - .plugin-name { font-size: 1.1rem; font-weight: bold; diff --git a/src/App.tsx b/src/App.tsx index dac500a..c869665 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; +import { listen, UnlistenFn } from "@tauri-apps/api/event"; import "./App.css"; type ServerType = @@ -45,6 +46,12 @@ interface ScanResult { plugins: Plugin[]; } +interface ScanProgress { + processed: number; + total: number; + current_file: string; +} + interface PluginDetailsProps { plugin: Plugin; onClose: () => void; @@ -160,6 +167,45 @@ function App() { const [scanComplete, setScanComplete] = useState(false); const [error, setError] = useState(null); const [selectedPlugin, setSelectedPlugin] = useState(null); + const [scanProgress, setScanProgress] = useState(null); + + useEffect(() => { + let unlistenProgress: UnlistenFn | undefined; + let unlistenComplete: UnlistenFn | undefined; + let unlistenError: UnlistenFn | undefined; + + const setupListeners = async () => { + unlistenProgress = await listen("scan_progress", (event) => { + setScanProgress(event.payload); + setError(null); + }); + + unlistenComplete = await listen("scan_complete", (event) => { + setServerInfo(event.payload.server_info); + setPlugins(event.payload.plugins); + setIsScanning(false); + setScanComplete(true); + setScanProgress(null); + setError(null); + }); + + unlistenError = await listen("scan_error", (event) => { + console.error("Scan Error:", event.payload); + setError(event.payload); + setIsScanning(false); + setScanProgress(null); + setScanComplete(false); + }); + }; + + setupListeners(); + + return () => { + unlistenProgress?.(); + unlistenComplete?.(); + unlistenError?.(); + }; + }, []); async function selectDirectory() { try { @@ -188,20 +234,21 @@ function App() { } async function scanForPlugins() { + if (!serverPath || isScanning) return; + try { setIsScanning(true); + setScanComplete(false); + setPlugins([]); + setServerInfo(null); + setScanProgress(null); setError(null); - // Call the Rust backend - const result = await invoke("scan_server_directory", { path: serverPath }); + await invoke("scan_server_directory", { path: serverPath }); - setServerInfo(result.server_info); - setPlugins(result.plugins); - setIsScanning(false); - setScanComplete(true); } catch (err) { - console.error("Error scanning for plugins:", err); - setError(err as string); + console.error("Error invoking scan command:", err); + setError(`Failed to start scan: ${err as string}`); setIsScanning(false); } } @@ -241,6 +288,14 @@ function App() { {isScanning ? "Scanning..." : "Scan for Plugins"} + {isScanning && scanProgress && ( +
+

Scanning: {scanProgress.current_file}

+ + {scanProgress.processed} / {scanProgress.total} +
+ )} + {error && (
{error}