feat: Implement async scan, progress bar, and improve Paper detection

This commit is contained in:
Rbanh 2025-03-29 01:37:50 -04:00
parent eb51afaea8
commit fc96a10397
4 changed files with 361 additions and 128 deletions

View File

@ -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

View File

@ -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<Vec<String>> {
/// 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<Plugin>,
}
// 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<ScanResult, String> {
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<ScanResult, String> {
let server_path = Path::new(path);
if !server_path.exists() {
@ -498,22 +579,57 @@ fn scan_server_directory(path: &str) -> Result<ScanResult, String> {
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<std::path::PathBuf> = 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) => {
jar_files_to_process.push(path);
}
}
}
}
Err(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 ---
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,
@ -532,62 +648,18 @@ fn scan_server_directory(path: &str) -> Result<ScanResult, String> {
file_path: meta.file_path,
file_hash: meta.file_hash,
};
plugins.push(plugin);
},
Err(e) => {
}
Ok(Err(e)) => {
// Log error but continue with other plugins
println!("Error reading plugin from {}: {}", path.display(), e);
println!("Error reading plugin from {}: {}", current_file, e);
// Optionally emit a specific plugin error event here
}
}
}
}
}
},
Err(e) => {
return Err(format!("Failed to read plugins directory: {}", e));
// This happens if the blocking task itself panics
println!("Task panicked for plugin {}: {}", current_file, e);
}
}
// 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()),
},
];
}
// Create server info
@ -595,7 +667,7 @@ fn scan_server_directory(path: &str) -> Result<ScanResult, String> {
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<Box<dyn RepositoryCrawle
}
}
// Command to search for plugins in specified repositories
#[command]
pub fn search_repository_plugins(query: &str, repositories: Vec<RepositorySource>) -> Result<Vec<RepositoryPlugin>, String> {
// Regular repository functions (not commands)
pub fn lib_search_plugins_in_repositories(query: &str, repositories: Vec<RepositorySource>) -> Result<Vec<RepositoryPlugin>, String> {
let mut results: Vec<RepositoryPlugin> = Vec::new();
// Try each requested repository
@ -725,9 +796,7 @@ pub fn search_repository_plugins(query: &str, repositories: Vec<RepositorySource
}
}
// Command to get plugin details from a specific repository
#[command]
pub fn get_repository_plugin_details(plugin_id: &str, repository: RepositorySource) -> Result<RepositoryPlugin, String> {
pub fn lib_get_plugin_details_from_repository(plugin_id: &str, repository: RepositorySource) -> Result<RepositoryPlugin, String> {
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<String, String> {
pub fn lib_download_plugin_from_repository(plugin_id: &str, version: &str, repository: RepositorySource, destination: &str) -> Result<String, String> {
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<String, String> {
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");
}

View File

@ -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;

View File

@ -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<string | null>(null);
const [selectedPlugin, setSelectedPlugin] = useState<Plugin | null>(null);
const [scanProgress, setScanProgress] = useState<ScanProgress | null>(null);
useEffect(() => {
let unlistenProgress: UnlistenFn | undefined;
let unlistenComplete: UnlistenFn | undefined;
let unlistenError: UnlistenFn | undefined;
const setupListeners = async () => {
unlistenProgress = await listen<ScanProgress>("scan_progress", (event) => {
setScanProgress(event.payload);
setError(null);
});
unlistenComplete = await listen<ScanResult>("scan_complete", (event) => {
setServerInfo(event.payload.server_info);
setPlugins(event.payload.plugins);
setIsScanning(false);
setScanComplete(true);
setScanProgress(null);
setError(null);
});
unlistenError = await listen<string>("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<ScanResult>("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"}
</button>
{isScanning && scanProgress && (
<div className="scan-progress">
<p>Scanning: {scanProgress.current_file}</p>
<progress value={scanProgress.processed} max={scanProgress.total}></progress>
<span>{scanProgress.processed} / {scanProgress.total}</span>
</div>
)}
{error && (
<div className="error-message">
{error}