891 lines
31 KiB
TypeScript
891 lines
31 KiB
TypeScript
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 { appDataDir } from '@tauri-apps/api/path'; // Import for data directory (if needed frontend side)
|
||
import "./App.css";
|
||
|
||
type ServerType =
|
||
| 'Paper'
|
||
| 'Spigot'
|
||
| 'Bukkit'
|
||
| 'Vanilla'
|
||
| 'Forge'
|
||
| 'Fabric'
|
||
| 'Velocity'
|
||
| 'BungeeCord'
|
||
| 'Waterfall'
|
||
| 'Unknown';
|
||
|
||
interface ServerInfo {
|
||
server_type: ServerType;
|
||
minecraft_version?: string;
|
||
plugins_directory: string;
|
||
plugins_count: number;
|
||
}
|
||
|
||
interface Plugin {
|
||
name: string;
|
||
version: string;
|
||
latest_version?: string;
|
||
description?: string;
|
||
authors: string[];
|
||
has_update: boolean;
|
||
api_version?: string;
|
||
main_class?: string;
|
||
depend?: string[] | null;
|
||
soft_depend?: string[] | null;
|
||
load_before?: string[] | null;
|
||
commands?: any;
|
||
permissions?: any;
|
||
file_path: string;
|
||
file_hash: string;
|
||
website?: string;
|
||
changelog?: string;
|
||
repository_source?: string; // Add repository source (string for simplicity)
|
||
repository_id?: string; // Add repository ID
|
||
repository_url?: string; // URL to the plugin's repository page
|
||
}
|
||
|
||
interface ScanResult {
|
||
server_info: ServerInfo;
|
||
plugins: Plugin[];
|
||
}
|
||
|
||
interface ScanProgress {
|
||
processed: number;
|
||
total: number;
|
||
current_file: string;
|
||
}
|
||
|
||
// --- New Interfaces for Update Events ---
|
||
interface BulkUpdateProgressPayload {
|
||
processed: number;
|
||
total: number;
|
||
current_plugin_name: string;
|
||
}
|
||
|
||
interface SingleUpdateResultPayload {
|
||
original_file_path: string;
|
||
plugin: Plugin | null; // Updated plugin state or null if check failed but wasn't a panic
|
||
error: string | null; // Error message if any
|
||
}
|
||
|
||
// Interface for potential plugin matches for ambiguous plugins
|
||
interface PotentialPluginMatch {
|
||
name: string;
|
||
version: string;
|
||
repository: string;
|
||
repository_id: string;
|
||
page_url: string;
|
||
description?: string;
|
||
minecraft_versions: string[];
|
||
download_count?: number;
|
||
}
|
||
|
||
// --- End New Interfaces ---
|
||
|
||
interface PluginDetailsProps {
|
||
plugin: Plugin;
|
||
onClose: () => void;
|
||
}
|
||
|
||
// Get server type icon
|
||
function getServerTypeIcon(serverType: ServerType): string {
|
||
switch (serverType) {
|
||
case 'Paper':
|
||
return '📄';
|
||
case 'Spigot':
|
||
return '🔌';
|
||
case 'Bukkit':
|
||
return '🪣';
|
||
case 'Vanilla':
|
||
return '🧊';
|
||
case 'Forge':
|
||
return '🔨';
|
||
case 'Fabric':
|
||
return '🧵';
|
||
case 'Velocity':
|
||
return '⚡';
|
||
case 'BungeeCord':
|
||
return '🔗';
|
||
case 'Waterfall':
|
||
return '🌊';
|
||
default:
|
||
return '❓';
|
||
}
|
||
}
|
||
|
||
// Get a formatted server type name for display
|
||
function getServerTypeName(serverType: ServerType): string {
|
||
return serverType === 'Unknown' ? 'Unknown Server' : serverType;
|
||
}
|
||
|
||
function PluginDetails({ plugin, onClose }: PluginDetailsProps) {
|
||
return (
|
||
<div className="plugin-details-modal">
|
||
<div className="plugin-details-content">
|
||
<button className="close-button" onClick={onClose}>×</button>
|
||
<h2>{plugin.name}</h2>
|
||
<div className="plugin-version-display">Version: {plugin.version} {plugin.latest_version && plugin.has_update && <span className="update-available-badge">(Update available: {plugin.latest_version})</span>}</div>
|
||
|
||
{plugin.description && (
|
||
<div className="plugin-description">{plugin.description}</div>
|
||
)}
|
||
|
||
{plugin.website && (
|
||
<div className="plugin-website">
|
||
<div className="section-label">Website:</div>
|
||
<a href={plugin.website} target="_blank" rel="noopener noreferrer">
|
||
{plugin.website}
|
||
</a>
|
||
</div>
|
||
)}
|
||
|
||
{plugin.authors && plugin.authors.length > 0 && (
|
||
<div className="plugin-authors">
|
||
<div className="section-label">Authors:</div>
|
||
<div>{plugin.authors.join(", ")}</div>
|
||
</div>
|
||
)}
|
||
|
||
{plugin.depend && plugin.depend.length > 0 && (
|
||
<div className="plugin-dependencies">
|
||
<div className="section-label">Dependencies:</div>
|
||
<div>{plugin.depend.join(", ")}</div>
|
||
</div>
|
||
)}
|
||
|
||
{plugin.soft_depend && plugin.soft_depend.length > 0 && (
|
||
<div className="plugin-soft-dependencies">
|
||
<div className="section-label">Soft Dependencies:</div>
|
||
<div>{plugin.soft_depend.join(", ")}</div>
|
||
</div>
|
||
)}
|
||
|
||
{plugin.changelog && plugin.has_update && (
|
||
<div className="plugin-changelog">
|
||
<div className="section-label">Changelog:</div>
|
||
<div className="changelog-content">{plugin.changelog}</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="plugin-file-info">
|
||
<div className="section-label">File Path:</div>
|
||
<div className="file-path">{plugin.file_path}</div>
|
||
|
||
<div className="section-label">File Hash (SHA-256):</div>
|
||
<div className="file-hash">{plugin.file_hash}</div>
|
||
</div>
|
||
|
||
{plugin.has_update && plugin.latest_version && (
|
||
<div className="update-actions">
|
||
<button
|
||
className="update-button detail-update-button"
|
||
onClick={() => window.dispatchEvent(new CustomEvent('update-plugin', { detail: plugin }))}
|
||
>
|
||
Update to version {plugin.latest_version}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Add this component after the PluginDetails component and before the App component
|
||
function ServerInfoDisplay({ serverInfo }: { serverInfo: ServerInfo | null }) {
|
||
if (!serverInfo) return null;
|
||
|
||
return (
|
||
<div className="server-info">
|
||
<h2>Server Information</h2>
|
||
<div className="server-type">
|
||
<span className="server-icon">{getServerTypeIcon(serverInfo.server_type)}</span>
|
||
<span className="server-type-name">{getServerTypeName(serverInfo.server_type)}</span>
|
||
</div>
|
||
<div className="minecraft-version">
|
||
<span className="version-label">Minecraft Version</span>
|
||
<span className="version-value">{serverInfo.minecraft_version || "Unknown"}</span>
|
||
</div>
|
||
<div className="plugins-path">
|
||
<span className="path-label">Plugins Directory</span>
|
||
<span className="path-value">{serverInfo.plugins_directory || "Unknown"}</span>
|
||
</div>
|
||
<div className="plugins-count">
|
||
<b>{serverInfo.plugins_count}</b> plugins found
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Add this new component after PluginDetails
|
||
function WarningModal({ message, onClose }: { message: string, onClose: () => void }) {
|
||
return (
|
||
<div className="modal-backdrop">
|
||
<div className="warning-modal">
|
||
<h3>⚠️ Warning</h3>
|
||
<p>{message}</p>
|
||
<button onClick={onClose}>Close</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Component for selecting the correct plugin from potential matches
|
||
function PluginMatchSelector({
|
||
plugin,
|
||
potentialMatches,
|
||
onSelect,
|
||
onCancel
|
||
}: {
|
||
plugin: Plugin,
|
||
potentialMatches: PotentialPluginMatch[],
|
||
onSelect: (match: PotentialPluginMatch) => void,
|
||
onCancel: () => void
|
||
}) {
|
||
return (
|
||
<div className="modal-backdrop">
|
||
<div className="plugin-match-modal">
|
||
<h3>Multiple Matches Found</h3>
|
||
<p>We found several potential matches for <strong>{plugin.name}</strong>. Please select the correct one:</p>
|
||
|
||
<div className="matches-list">
|
||
{potentialMatches.map((match, index) => (
|
||
<div key={`${match.repository}-${match.repository_id}`} className="match-item">
|
||
<div className="match-details">
|
||
<h4>{match.name}</h4>
|
||
<p className="match-version">Version: {match.version}</p>
|
||
{match.description && <p className="match-description">{match.description}</p>}
|
||
<div className="match-meta">
|
||
<span className="match-repo">Source: {match.repository}</span>
|
||
{match.download_count && <span className="match-downloads">Downloads: {match.download_count.toLocaleString()}</span>}
|
||
<span className="match-mc-version">MC: {match.minecraft_versions.join(', ')}</span>
|
||
</div>
|
||
</div>
|
||
<div className="match-actions">
|
||
<button className="select-match-button" onClick={() => onSelect(match)}>
|
||
Select This Match
|
||
</button>
|
||
<a
|
||
href={match.page_url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="view-page-link"
|
||
>
|
||
View Page
|
||
</a>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="match-selector-footer">
|
||
<button className="cancel-button" onClick={onCancel}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function App() {
|
||
const [serverPath, setServerPath] = useState("");
|
||
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
||
const [isScanning, setIsScanning] = useState(false);
|
||
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);
|
||
const [isCheckingUpdates, setIsCheckingUpdates] = useState(false);
|
||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||
// --- New State Variables ---
|
||
const [pluginLoadingStates, setPluginLoadingStates] = useState<Record<string, boolean>>({});
|
||
const [bulkUpdateProgress, setBulkUpdateProgress] = useState<BulkUpdateProgressPayload | null>(null);
|
||
const [warningMessage, setWarningMessage] = useState<string | null>(null);
|
||
const [potentialMatches, setPotentialMatches] = useState<PotentialPluginMatch[]>([]);
|
||
// --- New state for match selector ---
|
||
const [showMatchSelector, setShowMatchSelector] = useState(false);
|
||
const [pluginToDisambiguate, setPluginToDisambiguate] = useState<Plugin | null>(null);
|
||
// --- End New state for match selector ---
|
||
// --- End New State Variables ---
|
||
const [serverType, setServerType] = useState<ServerType>('Unknown');
|
||
|
||
useEffect(() => {
|
||
let unlistenScanStarted: UnlistenFn | undefined;
|
||
let unlistenScanProgress: UnlistenFn | undefined;
|
||
let unlistenScanCompleted: UnlistenFn | undefined;
|
||
let unlistenScanError: UnlistenFn | undefined;
|
||
let unlistenBulkUpdateStart: UnlistenFn | undefined;
|
||
let unlistenUpdateCheckProgress: UnlistenFn | undefined;
|
||
let unlistenSingleUpdateCheckStarted: UnlistenFn | undefined;
|
||
let unlistenSingleUpdateCheckCompleted: UnlistenFn | undefined;
|
||
|
||
const setupListeners = async () => {
|
||
unlistenScanStarted = await listen("scan_started", () => {
|
||
console.log("Scan started event received");
|
||
setIsScanning(true);
|
||
setScanProgress(null);
|
||
setError(null);
|
||
});
|
||
|
||
unlistenScanProgress = await listen<ScanProgress>("scan_progress", (event) => {
|
||
console.log("Scan progress event received:", event.payload);
|
||
setScanProgress(event.payload);
|
||
});
|
||
|
||
unlistenScanCompleted = await listen<ScanResult>("scan_completed", (event) => {
|
||
console.log("Scan completed event received with payload:", event.payload);
|
||
try {
|
||
console.log("Server info:", event.payload.server_info);
|
||
console.log("Plugins count:", event.payload.plugins.length);
|
||
|
||
// Update state in a specific order to ensure UI updates properly
|
||
setIsScanning(false);
|
||
setScanComplete(true);
|
||
setServerInfo(event.payload.server_info);
|
||
setPlugins(event.payload.plugins);
|
||
setServerType(event.payload.server_info.server_type);
|
||
|
||
// Add a slight delay and verify the state was updated
|
||
setTimeout(() => {
|
||
console.log("Verifying state updates after scan:");
|
||
console.log("- scanComplete:", scanComplete);
|
||
console.log("- serverInfo:", serverInfo);
|
||
console.log("- plugins count:", plugins.length);
|
||
|
||
// Force a state update if plugins length is still 0 but we got plugins
|
||
if (plugins.length === 0 && event.payload.plugins.length > 0) {
|
||
console.log("Forcing state update because plugins array is empty");
|
||
setPlugins([...event.payload.plugins]);
|
||
}
|
||
}, 100);
|
||
|
||
console.log("State updated after scan completion");
|
||
} catch (err) {
|
||
console.error("Error handling scan completion:", err);
|
||
setError(`Error handling scan completion: ${err}`);
|
||
setIsScanning(false);
|
||
}
|
||
});
|
||
|
||
unlistenScanError = await listen<string>("scan_error", (event) => {
|
||
console.log("Scan error event received:", event.payload);
|
||
setIsScanning(false);
|
||
setError(event.payload);
|
||
});
|
||
|
||
unlistenBulkUpdateStart = await listen<number>("bulk_update_start", (event) => {
|
||
console.log("Bulk update start event received, total plugins:", event.payload);
|
||
setIsCheckingUpdates(true);
|
||
setUpdateError(null);
|
||
setBulkUpdateProgress({
|
||
processed: 0,
|
||
total: event.payload,
|
||
current_plugin_name: "Starting update check..."
|
||
});
|
||
});
|
||
|
||
unlistenUpdateCheckProgress = await listen<BulkUpdateProgressPayload>("update_check_progress", (event) => {
|
||
console.log("Update check progress event received:", event.payload);
|
||
setBulkUpdateProgress(event.payload);
|
||
});
|
||
|
||
unlistenSingleUpdateCheckStarted = await listen<string>("single_update_check_started", (event) => {
|
||
console.log("Single update check started for:", event.payload);
|
||
});
|
||
|
||
unlistenSingleUpdateCheckCompleted = await listen<SingleUpdateResultPayload>("single_update_check_completed", (event) => {
|
||
console.log("Single update check completed, result:", event.payload);
|
||
const { original_file_path, plugin, error } = event.payload;
|
||
|
||
setPluginLoadingStates(prev => ({ ...prev, [original_file_path]: false }));
|
||
|
||
if (error) {
|
||
setUpdateError(`Error checking for updates: ${error}`);
|
||
return;
|
||
}
|
||
|
||
if (plugin) {
|
||
setPlugins(prevPlugins => prevPlugins.map(p => {
|
||
if (p.file_path === original_file_path) {
|
||
return plugin;
|
||
}
|
||
return p;
|
||
}));
|
||
|
||
if (serverPath) {
|
||
invoke("save_plugin_data", {
|
||
plugins: plugins.map(p => p.file_path === original_file_path ? plugin : p),
|
||
serverPath
|
||
}).catch(err => {
|
||
console.error("Error saving plugin data after single update:", err);
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
window.addEventListener('update-plugin', ((e: CustomEvent<Plugin>) => {
|
||
if (e.detail) {
|
||
updatePlugin(e.detail);
|
||
}
|
||
}) as EventListener);
|
||
};
|
||
|
||
setupListeners();
|
||
|
||
return () => {
|
||
unlistenScanStarted?.();
|
||
unlistenScanProgress?.();
|
||
unlistenScanCompleted?.();
|
||
unlistenScanError?.();
|
||
unlistenBulkUpdateStart?.();
|
||
unlistenUpdateCheckProgress?.();
|
||
unlistenSingleUpdateCheckStarted?.();
|
||
unlistenSingleUpdateCheckCompleted?.();
|
||
window.removeEventListener('update-plugin', (() => {}) as EventListener);
|
||
};
|
||
}, []);
|
||
|
||
async function selectDirectory() {
|
||
try {
|
||
const selected = await open({
|
||
directory: true,
|
||
multiple: false,
|
||
title: "Select Minecraft Server Folder",
|
||
});
|
||
|
||
if (selected && typeof selected === "string") {
|
||
console.log(`Directory selected: ${selected}`);
|
||
setServerPath(selected);
|
||
setServerInfo(null);
|
||
setPlugins([]);
|
||
setIsScanning(false);
|
||
setScanComplete(false);
|
||
setError(null);
|
||
setScanProgress(null);
|
||
setIsCheckingUpdates(false);
|
||
setUpdateError(null);
|
||
setPluginLoadingStates({});
|
||
setBulkUpdateProgress(null);
|
||
|
||
try {
|
||
console.log(`Attempting to load persisted data for: ${selected}`);
|
||
const loadedPlugins: Plugin[] = await invoke("load_plugin_data", { serverPath: selected });
|
||
if (loadedPlugins && loadedPlugins.length > 0) {
|
||
console.log(`Loaded ${loadedPlugins.length} plugins from persistence.`);
|
||
setPlugins(loadedPlugins);
|
||
setScanComplete(true);
|
||
setError(null);
|
||
} else {
|
||
console.log("No persisted plugin data found for this server.");
|
||
}
|
||
} catch (loadError) {
|
||
console.error("Error loading persisted plugin data:", loadError);
|
||
setError(`Failed to load previous plugin data: ${loadError}`);
|
||
}
|
||
} else {
|
||
console.log("Directory selection cancelled.");
|
||
}
|
||
} catch (err) {
|
||
console.error("Error selecting directory:", err);
|
||
setError(`Error selecting directory: ${err}`);
|
||
}
|
||
}
|
||
|
||
async function scanForPlugins() {
|
||
if (!serverPath || isScanning) return;
|
||
|
||
try {
|
||
console.log("Starting scan for plugins in:", serverPath);
|
||
setIsScanning(true);
|
||
setScanComplete(false);
|
||
setPlugins([]);
|
||
setServerInfo(null);
|
||
setScanProgress(null);
|
||
setError(null);
|
||
|
||
await invoke("scan_server_dir", { path: serverPath });
|
||
console.log("Scan server dir command invoked successfully");
|
||
|
||
} catch (err) {
|
||
console.error("Error invoking scan command:", err);
|
||
setError(`Failed to start scan: ${err as string}`);
|
||
setIsScanning(false);
|
||
}
|
||
}
|
||
|
||
async function checkForUpdates() {
|
||
if (!plugins.length || isCheckingUpdates) return;
|
||
|
||
setIsCheckingUpdates(true);
|
||
setUpdateError(null);
|
||
setBulkUpdateProgress(null);
|
||
console.log("Invoking bulk check_plugin_updates...");
|
||
|
||
try {
|
||
const repositoriesToCheck = ['SpigotMC', 'Modrinth', 'GitHub'];
|
||
|
||
const pluginsToSend = plugins.map(p => ({
|
||
name: p.name,
|
||
version: p.version,
|
||
authors: p.authors || [],
|
||
file_path: p.file_path,
|
||
file_hash: p.file_hash,
|
||
website: p.website,
|
||
description: p.description,
|
||
api_version: p.api_version,
|
||
main_class: p.main_class,
|
||
depend: p.depend,
|
||
soft_depend: p.soft_depend,
|
||
load_before: p.load_before,
|
||
commands: p.commands,
|
||
permissions: p.permissions,
|
||
has_update: p.has_update || false,
|
||
repository_source: p.repository_source,
|
||
repository_id: p.repository_id,
|
||
repository_url: p.repository_url,
|
||
}));
|
||
|
||
console.log("Sending plugin data to backend, count:", pluginsToSend.length);
|
||
|
||
const updatedPlugins = await invoke<Plugin[]>("check_plugin_updates", {
|
||
plugins: pluginsToSend,
|
||
repositories: repositoriesToCheck,
|
||
});
|
||
|
||
console.log("Bulk update check completed successfully, updating state.");
|
||
setPlugins(updatedPlugins);
|
||
|
||
if (serverPath) {
|
||
try {
|
||
console.log("[checkForUpdates] Saving plugin data...");
|
||
await invoke("save_plugin_data", { plugins: updatedPlugins, serverPath });
|
||
console.log("[checkForUpdates] Plugin data saved successfully.");
|
||
} catch (saveError) {
|
||
console.error("Error saving plugin data after bulk update:", saveError);
|
||
setUpdateError(`Update check complete, but failed to save plugin data: ${saveError}`);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
const errorMessage = `Error during bulk update check: ${err instanceof Error ? err.message : String(err)}`;
|
||
console.error(errorMessage);
|
||
setUpdateError(errorMessage);
|
||
} finally {
|
||
setIsCheckingUpdates(false);
|
||
setBulkUpdateProgress(null);
|
||
}
|
||
}
|
||
|
||
async function checkSinglePlugin(plugin: Plugin) {
|
||
if (isScanning || isCheckingUpdates || pluginLoadingStates[plugin.file_path]) return;
|
||
|
||
console.log(`Invoking single check for: ${plugin.name} (${plugin.file_path})`);
|
||
setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: true }));
|
||
setUpdateError(null);
|
||
|
||
try {
|
||
const repositoriesToCheck = ['SpigotMC', 'Modrinth', 'GitHub'];
|
||
|
||
const pluginToSend = {
|
||
name: plugin.name,
|
||
version: plugin.version,
|
||
authors: plugin.authors || [],
|
||
file_path: plugin.file_path,
|
||
file_hash: plugin.file_hash,
|
||
website: plugin.website,
|
||
description: plugin.description,
|
||
api_version: plugin.api_version,
|
||
main_class: plugin.main_class,
|
||
depend: plugin.depend,
|
||
soft_depend: plugin.soft_depend,
|
||
load_before: plugin.load_before,
|
||
commands: plugin.commands,
|
||
permissions: plugin.permissions,
|
||
has_update: plugin.has_update,
|
||
};
|
||
|
||
await invoke("check_single_plugin_update_command", {
|
||
plugin: pluginToSend,
|
||
repositoriesToCheck,
|
||
});
|
||
} catch (err) {
|
||
const errorMessage = `Error invoking single update command for ${plugin.name}: ${err instanceof Error ? err.message : String(err)}`;
|
||
console.error(errorMessage);
|
||
setUpdateError(errorMessage);
|
||
setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: false }));
|
||
}
|
||
}
|
||
|
||
async function updatePlugin(plugin: Plugin) {
|
||
if (!plugin.has_update || !plugin.latest_version || !plugin.repository_source || !plugin.repository_id) {
|
||
setUpdateError(`Cannot update ${plugin.name}: Missing required update information`);
|
||
return;
|
||
}
|
||
|
||
setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: true }));
|
||
setUpdateError(null);
|
||
|
||
try {
|
||
console.log(`Updating plugin: ${plugin.name} to version ${plugin.latest_version}`);
|
||
|
||
const newFilePath = await invoke<string>("replace_plugin", {
|
||
pluginId: plugin.repository_id,
|
||
version: plugin.latest_version,
|
||
repository: plugin.repository_source,
|
||
currentFilePath: plugin.file_path,
|
||
serverInfo: serverInfo
|
||
});
|
||
|
||
console.log(`Update successful for ${plugin.name}, new file path: ${newFilePath}`);
|
||
|
||
setPlugins(currentPlugins => currentPlugins.map(p => {
|
||
if (p.file_path === plugin.file_path) {
|
||
return {
|
||
...p,
|
||
version: p.latest_version || p.version,
|
||
has_update: false,
|
||
latest_version: p.latest_version,
|
||
file_path: newFilePath
|
||
};
|
||
}
|
||
return p;
|
||
}));
|
||
|
||
if (serverPath) {
|
||
await invoke("save_plugin_data", {
|
||
plugins: plugins.map(p => {
|
||
if (p.file_path === plugin.file_path) {
|
||
return {
|
||
...p,
|
||
version: p.latest_version || p.version,
|
||
has_update: false,
|
||
file_path: newFilePath
|
||
};
|
||
}
|
||
return p;
|
||
}),
|
||
serverPath
|
||
});
|
||
}
|
||
} catch (err) {
|
||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||
console.error(`Error updating ${plugin.name}:`, errorMessage);
|
||
|
||
if (errorMessage.includes("in use") ||
|
||
errorMessage.includes("server running") ||
|
||
errorMessage.includes("being used by another process")) {
|
||
setWarningMessage(`Cannot update ${plugin.name}: The Minecraft server appears to be running. Please stop your server before updating plugins.`);
|
||
} else if (errorMessage.includes("download failed")) {
|
||
setUpdateError(`Failed to download update for ${plugin.name}. Please check your internet connection and try again.`);
|
||
} else if (errorMessage.includes("Critical error")) {
|
||
setWarningMessage(`${errorMessage} A backup of your original plugin is available in the backups folder.`);
|
||
} else {
|
||
setUpdateError(`Error updating ${plugin.name}: ${errorMessage}`);
|
||
}
|
||
} finally {
|
||
setPluginLoadingStates(prev => ({ ...prev, [plugin.file_path]: false }));
|
||
}
|
||
}
|
||
|
||
const showPluginDetails = (plugin: Plugin) => {
|
||
setSelectedPlugin(plugin);
|
||
};
|
||
|
||
const closePluginDetails = () => {
|
||
setSelectedPlugin(null);
|
||
};
|
||
|
||
const handleSelectMatch = async (selectedMatch: PotentialPluginMatch) => {
|
||
if (!pluginToDisambiguate || !serverPath) return;
|
||
|
||
console.log(`User selected match: ${selectedMatch.name} from ${selectedMatch.repository}`);
|
||
setShowMatchSelector(false);
|
||
setPluginLoadingStates(prev => ({ ...prev, [pluginToDisambiguate.file_path]: true }));
|
||
|
||
try {
|
||
const updatedPlugin: Plugin = await invoke("set_plugin_repository", {
|
||
pluginFilePath: pluginToDisambiguate.file_path,
|
||
repository: selectedMatch.repository,
|
||
repositoryId: selectedMatch.repository_id,
|
||
pageUrl: selectedMatch.page_url,
|
||
serverPath: serverPath,
|
||
});
|
||
|
||
setPlugins(currentPlugins =>
|
||
currentPlugins.map(p =>
|
||
p.file_path === updatedPlugin.file_path ? updatedPlugin : p
|
||
)
|
||
);
|
||
console.log(`Successfully set repository source for ${updatedPlugin.name}`);
|
||
|
||
} catch (err) {
|
||
console.error("Error setting plugin repository source:", err);
|
||
setUpdateError(`Failed to set repository source for ${pluginToDisambiguate.name}: ${err}`);
|
||
} finally {
|
||
setPluginLoadingStates(prev => ({ ...prev, [pluginToDisambiguate.file_path]: false }));
|
||
setPluginToDisambiguate(null);
|
||
setPotentialMatches([]);
|
||
}
|
||
};
|
||
|
||
const handleCancelMatchSelection = () => {
|
||
setShowMatchSelector(false);
|
||
setPluginToDisambiguate(null);
|
||
setPotentialMatches([]);
|
||
if (pluginToDisambiguate) {
|
||
setPluginLoadingStates(prev => ({ ...prev, [pluginToDisambiguate.file_path]: false }));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="app-container">
|
||
<header className="app-header">
|
||
<h1>🔧 PlugSnatcher</h1>
|
||
<p>Minecraft Plugin Manager</p>
|
||
</header>
|
||
|
||
<main className="app-content">
|
||
<section className="server-selector">
|
||
<h2>Select Server Directory</h2>
|
||
<div className="input-group">
|
||
<input
|
||
type="text"
|
||
value={serverPath}
|
||
onChange={(e) => setServerPath(e.target.value)}
|
||
placeholder="Enter server directory path..."
|
||
/>
|
||
<button onClick={selectDirectory}>Browse</button>
|
||
</div>
|
||
<button
|
||
className="scan-button"
|
||
onClick={scanForPlugins}
|
||
disabled={isScanning || !serverPath}
|
||
>
|
||
{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}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{serverInfo && (
|
||
<ServerInfoDisplay serverInfo={serverInfo} />
|
||
)}
|
||
|
||
{serverInfo && plugins.length > 0 && (
|
||
<div className="update-controls">
|
||
<button
|
||
onClick={checkForUpdates}
|
||
disabled={isScanning || isCheckingUpdates || Object.values(pluginLoadingStates).some(isLoading => isLoading) || plugins.length === 0}
|
||
className="action-button update-check-button"
|
||
>
|
||
{isCheckingUpdates ? 'Checking All...' : 'Check All for Updates'}
|
||
</button>
|
||
{isCheckingUpdates && bulkUpdateProgress && (
|
||
<div className="bulk-update-progress">
|
||
Checking {bulkUpdateProgress.processed}/{bulkUpdateProgress.total}: {bulkUpdateProgress.current_plugin_name}
|
||
<progress value={bulkUpdateProgress.processed} max={bulkUpdateProgress.total}></progress>
|
||
</div>
|
||
)}
|
||
{updateError && (
|
||
<div className="error-message update-error">{updateError}</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{plugins.length > 0 && (
|
||
<section className="plugins-list">
|
||
<h2>Installed Plugins ({plugins.length})</h2>
|
||
<div className="plugins-header">
|
||
<span>Name</span>
|
||
<span>Current Version</span>
|
||
<span>Latest Version</span>
|
||
<span>Actions</span>
|
||
</div>
|
||
{plugins.map((plugin) => (
|
||
<div key={plugin.file_path} className={`plugin-item ${plugin.has_update ? 'has-update' : ''}`}>
|
||
<div className="plugin-name">{plugin.name}</div>
|
||
<div className="plugin-version">{plugin.version}</div>
|
||
<div className="plugin-latest-version">
|
||
{plugin.repository_url && plugin.latest_version ? (
|
||
<a href={plugin.repository_url} target="_blank" rel="noopener noreferrer" className="version-link">
|
||
{plugin.latest_version}
|
||
</a>
|
||
) : (
|
||
plugin.latest_version || 'N/A'
|
||
)}
|
||
</div>
|
||
<div className="plugin-actions">
|
||
<button
|
||
className="action-button check-single-button"
|
||
onClick={() => checkSinglePlugin(plugin)}
|
||
disabled={isScanning || isCheckingUpdates || pluginLoadingStates[plugin.file_path]}
|
||
>
|
||
{pluginLoadingStates[plugin.file_path] ? 'Checking...' : 'Check'}
|
||
</button>
|
||
{plugin.has_update && (
|
||
<button
|
||
className="update-button"
|
||
onClick={() => updatePlugin(plugin)}
|
||
disabled={isScanning || isCheckingUpdates || pluginLoadingStates[plugin.file_path]}
|
||
>
|
||
{pluginLoadingStates[plugin.file_path] ? 'Updating...' : 'Update'}
|
||
</button>
|
||
)}
|
||
<button className="info-button" onClick={() => showPluginDetails(plugin)}>Info</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</section>
|
||
)}
|
||
|
||
{scanComplete && plugins.length === 0 && (
|
||
<section className="plugins-list">
|
||
<h2>Installed Plugins (0)</h2>
|
||
<p>No plugins found in this directory.</p>
|
||
</section>
|
||
)}
|
||
|
||
{selectedPlugin && (
|
||
<PluginDetails plugin={selectedPlugin} onClose={closePluginDetails} />
|
||
)}
|
||
|
||
{warningMessage && (
|
||
<WarningModal
|
||
message={warningMessage}
|
||
onClose={() => setWarningMessage(null)}
|
||
/>
|
||
)}
|
||
|
||
{showMatchSelector && pluginToDisambiguate && (
|
||
<PluginMatchSelector
|
||
plugin={pluginToDisambiguate}
|
||
potentialMatches={potentialMatches}
|
||
onSelect={handleSelectMatch}
|
||
onCancel={handleCancelMatchSelection}
|
||
/>
|
||
)}
|
||
|
||
</main>
|
||
|
||
<footer className="app-footer">
|
||
<p>PlugSnatcher v0.1.0 - Developed with 💻 and ☕</p>
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|