Compare commits

...

18 Commits

Author SHA1 Message Date
0240ab9c50 Enhance app version management and improve plugin list filtering with advanced search capabilities 2025-04-01 01:37:55 -04:00
61becf8d22 Add React components and complete project structure for PlugSnatcher 2025-04-01 00:09:51 -04:00
340ce3d834 Migrate to Tauri v2 and fix configuration issues 2025-04-01 00:08:58 -04:00
7b772bb1bb Update ROADMAP.md to reflect recent progress 2025-03-30 20:11:04 -04:00
057bba0c56 Add premium plugin detection and user guidance for manual downloads 2025-03-30 19:55:33 -04:00
a5e7b766ac Fix SpigotMC plugin downloads by using direct URLs 2025-03-30 19:52:47 -04:00
1ebb16c15f Refactor imports in various modules to streamline code and remove unused dependencies 2025-03-30 19:47:52 -04:00
ae885f3780 Improve version display to show Up to Date status and update roadmap 2025-03-30 19:47:19 -04:00
975258d70e Improve plugin updates to preserve version in filename 2025-03-30 19:44:11 -04:00
29f06b197e Fix Modrinth plugin download to work with unknown server types 2025-03-30 19:38:58 -04:00
2b8821bc77 Fix plugin update command name and parameter 2025-03-30 19:37:10 -04:00
b5af6e7e2c Fix parameter name for single plugin update command and update roadmap 2025-03-30 19:34:54 -04:00
43dafb57da Fix single plugin update command parameter and enable GitHub integration 2025-03-30 19:33:17 -04:00
83be780243 feat: Implement backend commands for plugin data management and enhance frontend synchronization with updated plugin interfaces 2025-03-30 19:31:00 -04:00
78f22f65f4 Fix Modrinth version number display issues and improve plugin matching algorithm 2025-03-30 19:30:33 -04:00
4adb291592 feat: Enhance Minecraft version detection with JAR file analysis and improved fallback mechanisms 2025-03-29 01:42:58 -04:00
fc96a10397 feat: Implement async scan, progress bar, and improve Paper detection 2025-03-29 01:37:50 -04:00
eb51afaea8 Add web crawler infrastructure and HangarMC implementation 2025-03-29 01:00:49 -04:00
130 changed files with 15740 additions and 1273 deletions

View File

@ -37,7 +37,7 @@ PlugSnatcher/
### 1. Feature Planning
1. **Consult Roadmap**: Check the `ROADMAP.md` file to identify the next feature to implement
1. **Consult Roadmap**: ALWAYS Check the [ROADMAP.md](mdc:ROADMAP.md) file to identify the next feature to implement
2. **Define Scope**: Clearly define what the feature will do and identify any dependencies
3. **Update Roadmap**: Mark features as "In Progress" when starting work

View File

@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: true
---
You must update your current roadmap file before/after EVERY Change.

View File

@ -0,0 +1,211 @@
# PlugSnatcher Frontend Refactoring Plan
## Overview
This document outlines a comprehensive plan to refactor the PlugSnatcher frontend from a monolithic structure into a well-organized, modular React application. The refactoring will focus on component separation, state management optimization, and establishing a scalable architecture.
## Current Issues
- All React components are in a single 1300+ line file (`App.tsx`)
- State management is centralized and lacks separation of concerns
- No clear component hierarchy or organization
- Complex UI logic mixed with event handling and API calls
- Poor testability due to tightly coupled components
- Difficulty maintaining and extending the codebase
## Refactoring Goals
- Separate components into individual files with clear responsibilities
- Implement a logical folder structure
- Improve state management using React context or other solutions
- Extract common functionality into hooks and utilities
- Make components more reusable
- Improve code maintainability and readability
- Establish patterns for future development
## Project Structure
```
src/
├── assets/ # Images, icons, and other static files
├── components/ # UI components
│ ├── common/ # Reusable UI components
│ │ ├── Button/
│ │ ├── Modal/
│ │ ├── ProgressBar/
│ │ └── ...
│ ├── layout/ # Layout components
│ │ ├── Header/
│ │ ├── Footer/
│ │ └── ...
│ ├── plugins/ # Plugin-related components
│ │ ├── PluginList/
│ │ ├── PluginItem/
│ │ ├── PluginDetails/
│ │ └── ...
│ ├── server/ # Server-related components
│ │ ├── ServerSelector/
│ │ ├── ServerInfo/
│ │ └── ...
│ └── updates/ # Update-related components
│ ├── UpdateControls/
│ ├── CompatibilityCheck/
│ └── ...
├── context/ # React context providers
│ ├── ServerContext/
│ ├── PluginContext/
│ └── ...
├── hooks/ # Custom React hooks
│ ├── usePluginActions.ts
│ ├── useServerActions.ts
│ ├── useEventListeners.ts
│ └── ...
├── services/ # API and service functions
│ ├── tauriInvoke.ts
│ ├── eventListeners.ts
│ └── ...
├── types/ # TypeScript type definitions
│ ├── plugin.types.ts
│ ├── server.types.ts
│ └── ...
├── utils/ # Utility functions
│ ├── formatters.ts
│ ├── validators.ts
│ └── ...
├── App.tsx # Main App component (refactored)
└── main.tsx # Entry point
```
## Implementation Checklist
### Phase 1: Setup Project Structure and Types ✅
- [x] Create folder structure as outlined above
- [x] Extract TypeScript interfaces to separate files
- [x] Create `types/plugin.types.ts` for Plugin interfaces
- [x] Create `types/server.types.ts` for Server interfaces
- [x] Create `types/events.types.ts` for Event payloads
### Phase 2: Extract Utility Functions ✅
- [x] Create utility files
- [x] `utils/serverUtils.ts` - Server type icons and helpers
- [x] `utils/formatters.ts` - Text formatting functions
- [x] `utils/validators.ts` - Data validation functions
### Phase 3: Extract Common UI Components ✅
- [x] Create common UI components
- [x] `components/common/Modal/Modal.tsx` - Base modal component
- [x] `components/common/ProgressBar/ProgressBar.tsx` - Progress indicator
- [x] `components/common/Button/Button.tsx` - Styled button component
- [x] `components/common/Badge/Badge.tsx` - For platform compatibility badges
### Phase 4: Implement Context for State Management ✅
- [x] Create server context
- [x] `context/ServerContext/ServerContext.tsx` - Server state provider
- [x] `context/ServerContext/useServerContext.ts` - Server context hook
- [x] Create plugin context
- [x] `context/PluginContext/PluginContext.tsx` - Plugin state provider
- [x] `context/PluginContext/usePluginContext.ts` - Plugin context hook
- [x] Create UI context
- [x] `context/UIContext/UIContext.tsx` - UI state provider (modals, errors)
- [x] `context/UIContext/useUIContext.ts` - UI context hook
### Phase 5: Create Custom Hooks ✅
- [x] Create event listener hooks
- [x] `hooks/useEventListeners.ts` - Tauri event listener setup
- [x] Create server action hooks
- [x] `hooks/useServerActions.ts` - Server-related actions
- [x] Create plugin action hooks
- [x] `hooks/usePluginActions.ts` - Plugin-related actions
- [x] `hooks/useUpdateActions.ts` - Update-related actions
### Phase 6: Implement UI Components
#### Layout Components:
- ✅ Footer - Display application version and GitHub link
- ✅ MainContent - Main application content container
- ✅ ServerSelector - For selecting and managing server paths
- ✅ ServerInfo - Display information about the selected server
- ✅ ScanProgress - Show progress during server scanning
#### Plugin Components:
- ✅ PluginList - Container for listing all plugins
- ✅ PluginItem - Individual plugin display with actions
- ✅ PluginDetails - Detailed view of a plugin
- ✅ NoPluginsMessage - Message shown when no plugins are found
#### Update Components:
- ✅ UpdateControls - Controls for managing plugin updates
- ✅ BulkUpdateProgress - Shows progress during bulk updates
- ✅ CompatibilityCheckDialog - Confirms compatibility before update
- ✅ PremiumPluginModal - Info for premium plugins
- ✅ DownloadProgressIndicator - Shows download progress
- ✅ PluginMatchSelector - For selecting from potential plugin matches
- ✅ WarningModal - For displaying important warnings to users
#### Additional Steps:
- ✅ Fixed linter errors related to Tauri API imports
- ✅ Added TypeScript declarations for Tauri API modules
### Phase 7: Refactor Main App Component ✅
- ✅ Streamline App.tsx to use new components and contexts
- ✅ Remove direct state management from App.tsx
- ✅ Implement provider wrapping in the component tree
- ✅ Verify all functionality works as before
### Phase 8: Performance Optimizations
- [x] Add React.memo() to prevent unnecessary renders
- [ ] Optimize context usage to prevent unnecessary re-renders
- [x] Use callback and memoization for expensive operations
- [ ] Review and optimize state update patterns
### Phase 9: Testing and Documentation
- [ ] Add component documentation
- [ ] Add JSDoc comments for functions and hooks
- [ ] Set up unit testing framework
- [ ] Write tests for critical components
### Phase 10: Final Review and Cleanup
- [ ] Remove unused code and comments
- [ ] Ensure consistent naming conventions
- [ ] Verify all features work correctly
- [ ] Review for any performance issues
- [ ] Finalize documentation
## Implementation Strategy
### Approach
1. **Incremental Migration**: Refactor incrementally, keeping the app functional at each step
2. **Component by Component**: Start with smaller, leaf components and work upward
3. **Test Frequently**: Verify functionality after each significant change
4. **State Migration**: Move state management to context providers gradually
### Best Practices
- Keep components focused on a single responsibility
- Use TypeScript interfaces for all component props
- Maintain clear separation between UI components and business logic
- Follow consistent naming conventions
- Use React's composition pattern for component reuse
- Document complex logic with comments
## Future Enhancements
- Consider adding a state management library if complexity increases (Redux, Zustand, etc.)
- Implement React Router for potential multi-page navigation
- Add i18n for internationalization
- Implement error boundary components for better error handling
- Set up automated testing workflows
## Conclusion
This refactoring plan provides a comprehensive roadmap for transforming the PlugSnatcher frontend into a maintainable, modular React application. By following this plan, we will address the current issues and establish patterns that support future development and scaling of the application.

View File

@ -79,6 +79,8 @@ Description
Authors
Website (from plugin.yml)
API version
Source URL (if embedded or in plugin.yml)
@ -202,3 +204,30 @@ No consistent plugin metadata across sites (we'll brute force it)
API rate limits (user token support for GitHub)
## Configuration
### GitHub API Token (Optional)
To avoid GitHub API rate limits when checking for updates for plugins hosted on GitHub, you can provide a [Personal Access Token (PAT)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-tokens).
1. **Create a PAT:** Go to your GitHub settings > Developer settings > Personal access tokens > Tokens (classic). Generate a new token (classic). No specific scopes are required for reading public repository information.
2. **Set Environment Variable:** Set the `GITHUB_API_TOKEN` environment variable to the value of your generated token before running PlugSnatcher.
* **Windows (PowerShell):**
```powershell
$env:GITHUB_API_TOKEN="your_github_pat_here"
npm run tauri dev
```
* **Windows (Command Prompt):**
```cmd
set GITHUB_API_TOKEN=your_github_pat_here
npm run tauri dev
```
* **Linux/macOS:**
```bash
export GITHUB_API_TOKEN="your_github_pat_here"
npm run tauri dev
```
If this environment variable is not set, PlugSnatcher will still attempt to check GitHub, but you may encounter `403 Forbidden` errors if you check many plugins frequently.

View File

@ -4,43 +4,104 @@
- [x] Create project roadmap
- [x] Initialize Tauri + React project
- [x] Setup basic project structure
- [ ] Create GitHub repository (optional)
- [x] Create Remote repository (optional)
## Core Infrastructure (In Progress)
## Core Infrastructure (Completed) ✅
- [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 (Completed) ✅
- [x] Improve server type detection (Paper/Spigot distinction, etc.)
- [x] Implement plugins folder detection logic
- [x] Design plugin metadata extraction system
- [x] Extract basic fields (name, version, author, etc.)
- [x] Extract website field from plugin.yml
- [x] Build plugin hash identification system
- [x] Create plugin matching algorithm
## Web Crawler Development (Upcoming)
- [ ] Create base web crawler architecture
- [ ] Implement HangarMC crawler
- [ ] Implement SpigotMC crawler
- [ ] Implement Modrinth crawler
- [ ] Implement GitHub releases crawler
- [ ] Create plugin matching algorithm
## Web Crawler Development (Mostly Completed)
- [x] Create base web crawler architecture
- [x] Implement HangarMC crawler
- [x] Implement SpigotMC data source using SpiGet API
- [x] Basic implementation
- [x] Fix version information retrieval issues
- [ ] Reduce version warning logs and improve caching
- [x] Handle rate limiting and 403 errors
- [x] Implement Modrinth crawler
- [x] Basic implementation
- [x] Version compatibility checking
- [x] Fix version number display issues
- [ ] Handle rate limit issues
- [x] Implement GitHub releases crawler
- [x] Basic implementation
- [x] Enable GitHub search functionality
- [ ] Add GitHub API authentication for higher rate limits
## Update Management (Upcoming)
- [ ] Build update detection system
- [ ] Implement changelog extraction
- [ ] Create plugin backup functionality
- [ ] Develop plugin replacement logic
- [ ] Add server restart capabilities (optional)
## Update Management (Mostly Completed)
- [x] Build update detection system (Basic logic)
- [x] Improve update detection reliability
- [x] Correctly fetch detailed version information from repositories
- [x] Implement robust version comparison logic (handling non-SemVer, suffixes, etc.)
- [x] Implement proper request headers (User-Agent, etc.) to mimic browser behavior
- [x] Add delays between API requests to mitigate rate limiting
- [x] Implement automatic retry logic for rate limits (429) and network errors
- [x] Implement API response caching (reduce requests, improve performance)
- [x] Implement intelligent name variation searches for plugins
- [x] Fix SpigotMC version retrieval issues
- [x] Fix update check progress getting stuck
- [x] Complete Modrinth integration
- [x] Fix incorrect plugin matching issues
- [x] Improve matching algorithm to reduce false positives
- [x] Complete GitHub integration
- [ ] Add GitHub API authentication for higher rate limits (environment variable support exists)
- [x] Fix command parameter naming issues for update checks
- [x] Fix plugin update checking functionality after UI refactor
- [x] Fix serverPath not being passed correctly to PluginContext
- [ ] Optimize duplicate plugin search results (e.g., ViaVersion plugin)
- [x] Implement changelog extraction
- [x] Create plugin backup functionality
- [x] Develop plugin replacement logic
- [x] Implement plugin source persistence (store identified repo/ID to avoid searching)
- [ ] Allow manual setting/clearing of persistent source
- [x] Add plugin update UI integration
- [x] Add update buttons to plugin listing and details view
- [x] Show update progress with loading indicators
- [x] Handle file access errors gracefully
- [x] Ensure correct version numbering in filenames
- [x] Display up-to-date version information for all plugins
- [x] Handle premium plugins with user guidance for manual downloads
- [ ] Present multiple potential matches for ambiguous plugins
- [x] Make version numbers clickable links to repository sources
- [ ] Allow user selection of correct plugin match when multiple are found
- [x] Server platform compatibility matching
- [x] Detect server platform and version accurately (Paper, Spigot, Forge, NeoForge, Fabric, etc.)
- [x] Filter plugin updates to match the server platform
- [x] Prevent incompatible version updates
- [x] Add compatibility indicators in the code for available updates
- [x] Add platform indicators in the UI for available updates
## 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
- [x] Add per-plugin update check button & loading indicator
- [x] Add visual feedback for bulk update check (progress, loading state)
- [x] Implement error handling in UI with user-friendly messages
- [x] Add premium plugin detection & manual download guidance
- [ ] Create settings panel
- [x] Implement dark mode
- [ ] Implement plugin matching disambiguation UI
- [x] Add clickable version links to repository pages
- [ ] Add error recovery UI for failed updates
- [ ] Add detailed progress logging in UI for debugging
- [x] Implement application update checking functionality
- [x] Display application update notifications
## Security Features (Upcoming)
- [ ] Implement sandboxing for network requests
@ -49,14 +110,18 @@
## Testing & Refinement (Upcoming)
- [ ] Comprehensive testing with various server setups
- [ ] Performance optimization
- [ ] Error handling improvements
- [ ] Performance optimization (especially concurrent requests)
- [ ] Error handling improvements (parsing, network, etc.)
- [ ] User acceptance testing
- [ ] Add automated tests for critical functionality
- [ ] Add error telemetry (optional)
## Documentation (Upcoming)
- [ ] Create user documentation
- [ ] Write developer documentation
- [ ] Create installation guide
- [ ] Add troubleshooting guide
- [ ] Document known limitations and workarounds
## Deployment (Upcoming)
- [ ] Prepare release process
@ -72,4 +137,6 @@
- [ ] Scheduled plugin update checks
- [ ] Rollback system
- [ ] Plugin recommendation system
- [ ] Discord webhook integration
- [ ] Discord webhook integration
- [ ] Plugin search and browse functionality
- [ ] Plugin install from repository

View File

@ -0,0 +1,24 @@
# UI/UX Improvement Task List
## Visual Consistency and Aesthetics
- [ ] Ensure consistent use of colors for buttons, backgrounds, and text.
- [x] Use a consistent font size and weight for headings, subheadings, and body text.
- [x] Ensure consistent padding and margins for elements.
## User Interaction
- [x] Add subtle hover effects to buttons and interactive elements.
- [x] Ensure the layout adapts well to different screen sizes.
- [x] Use spinners or progress bars to indicate loading states.
## Accessibility
- [x] Ensure sufficient contrast between text and background.
- [x] Ensure all interactive elements are accessible via keyboard.
- [x] Use ARIA attributes to improve screen reader support.
## Feedback and Notifications
- [x] Clearly display messages for successful actions or errors.
- [ ] Provide tooltips for buttons and icons.
## Performance Optimization
- [ ] Implement lazy loading for components not immediately visible.
- [x] Use React.memo and useCallback to prevent unnecessary re-renders.

6
code-styling.mdc Normal file
View File

@ -0,0 +1,6 @@
---
description:
globs:
alwaysApply: false
---

View File

@ -0,0 +1,57 @@
# Server Platform Compatibility in PlugSnatcher
## Overview
PlugSnatcher now has enhanced platform compatibility checking to ensure that plugins you download and update are compatible with your specific server type. This feature prevents potential issues like downloading a NeoForge version of a plugin for a Paper server or vice versa.
## Supported Server Types
PlugSnatcher automatically detects and supports the following server types:
- **Paper** - Can use plugins built for Paper, Spigot, and Bukkit
- **Spigot** - Can use plugins built for Spigot and Bukkit
- **Bukkit** - Can use plugins built for Bukkit only
- **Forge** - Can use plugins built for Forge
- **Fabric** - Can use plugins built for Fabric
- **Velocity** - Can use plugins built for Velocity
- **BungeeCord** - Can use plugins built for BungeeCord
- **Waterfall** - Can use plugins built for Waterfall and BungeeCord
## How It Works
1. **Server Type Detection**: When you scan a server directory, PlugSnatcher automatically determines the server type based on file patterns and configuration files.
2. **Platform-Aware Updates**: When checking for updates, PlugSnatcher filters plugin versions based on your server type. For example, if you're running a Paper server, PlugSnatcher will prioritize Paper/Spigot/Bukkit versions and avoid Forge/Fabric versions.
3. **Compatible Version Selection**: When multiple versions of a plugin are available (e.g., Paper, Forge, and Fabric versions), PlugSnatcher automatically selects the one compatible with your server.
4. **Warning System**: If no compatible version is found, PlugSnatcher will warn you before allowing you to proceed with an update that might not work on your server.
## Examples
- **Plugin with multiple versions**: For plugins like LuckPerms that have versions for various platforms (Paper, Forge, NeoForge, Fabric), PlugSnatcher will automatically select the version that matches your server type.
- **Universal plugins**: Some plugins work across multiple platforms without specific builds. In these cases, PlugSnatcher will consider them compatible with all server types.
## Technical Details
This feature primarily affects plugins downloaded from repositories that provide platform information, such as:
- **Modrinth**: Provides detailed platform compatibility information through "loaders" metadata
- **Hangar**: Includes platform tags for each plugin version
- **GitHub**: May or may not include platform information depending on the repository structure
For repositories without explicit platform tagging (like SpigotMC), PlugSnatcher will use the general repository focus (e.g., SpigotMC is for Bukkit/Spigot/Paper servers) to determine compatibility.
## Current Limitations
- Some repositories don't provide structured platform compatibility information
- Plugin naming conventions aren't always consistent, making it difficult to determine platform compatibility from filenames alone
- Some custom/niche server types may not be properly detected or matched
## Future Improvements
- Allow manual override of detected server type
- Add compatibility visualization in the UI
- Improve detection of platform from plugin filename patterns
- Support for more server types and platforms

38
doc/Refactor_Checklist.md Normal file
View File

@ -0,0 +1,38 @@
# Frontend/Backend Synchronization Checklist
This checklist tracks the necessary changes to ensure `src/App.tsx` works correctly with the refactored Rust backend.
## Backend Changes (`src-tauri/`)
- [ ] **Create `load_plugin_data` Command:**
- Implement a command `load_plugin_data(app_handle: AppHandle, server_path: String) -> Result<Vec<Plugin>, String>`.
- Use `get_plugin_data_path` from `scanner.rs` to find the correct `plugins.json`.
- Read and deserialize `plugins.json`.
- Return `Ok(plugins)` or an appropriate `Err(String)`.
- Register the command in `lib.rs`.
- [ ] **Create `save_plugin_data` Command:**
- Implement a command `save_plugin_data(app_handle: AppHandle, plugins: Vec<Plugin>, server_path: String) -> Result<(), String>`.
- Use `get_plugin_data_path` from `scanner.rs`.
- Serialize the `plugins` vector to JSON.
- Write the JSON to `plugins.json`, creating the directory if needed.
- Return `Ok(())` or an appropriate `Err(String)`.
- Register the command in `lib.rs`.
- [ ] **Align Bulk Update Events/Logic:**
- In `update_checker.rs` (`check_for_plugin_updates`), rename the emitted event from `"update_check_started"` to `"bulk_update_start"`.
- In `update_checker.rs` (`check_for_plugin_updates`), remove the `app_handle.emit("update_check_completed", ())` call. The result should be handled via the command's return value.
## Frontend Changes (`src/App.tsx`)
- [ ] **Align `Plugin` Interface Nullability:**
- Ensure `depend`, `soft_depend`, and `load_before` fields consistently use `string[] | null`.
- [ ] **Rename Command Invokes:**
- Change `invoke("check_single_plugin_update", ...)` to `invoke("check_single_plugin_update_command", ...)`.
- Change `invoke("set_plugin_repository_source", ...)` to `invoke("set_plugin_repository", ...)`.
- [ ] **Refactor `checkForUpdates` Result Handling:**
- Modify the `checkForUpdates` async function to `await` the `invoke("check_plugin_updates", ...)` call.
- Use a `try...catch` block or `.then().catch()` to handle the `Result<Vec<Plugin>, String>`.
- On success (`Ok(updatedPlugins)`), call `setPlugins(updatedPlugins)` and clear errors/loading states.
- On error (`Err(error)`), call `setUpdateError(error)` and clear loading states.
- [ ] **Adjust/Remove Event Listeners:**
- In `useEffect`, rename the listener for `"update_check_started"` to `"bulk_update_start"`.
- In `useEffect`, remove the listeners for `"bulk_update_complete"` and `"bulk_update_error"`.

1138
doc/spiget_api_endpoints.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,34 @@
## Backend Changes (`src-tauri/`)
- [x] **Create `load_plugin_data` Command:**
- Implement a command `load_plugin_data(app_handle: AppHandle, server_path: String) -> Result<Vec<Plugin>, String>`.
- Use `get_plugin_data_path` from `scanner.rs` to find the correct `plugins.json`.
- Read and deserialize `plugins.json`.
- Return `Ok(plugins)` or an appropriate `Err(String)`.
- Register the command in `lib.rs`.
- [x] **Create `save_plugin_data` Command:**
- Implement a command `save_plugin_data(app_handle: AppHandle, plugins: Vec<Plugin>, server_path: String) -> Result<(), String>`.
- Use `get_plugin_data_path` from `scanner.rs`.
- Serialize the `plugins` vector to JSON.
- Write the JSON to `plugins.json`, creating the directory if needed.
- Return `Ok(())` or an appropriate `Err(String)`.
- Register the command in `lib.rs`.
- [x] **Align Bulk Update Events/Logic:**
- In `update_checker.rs` (`check_for_plugin_updates`), rename the emitted event from `"update_check_started"` to `"bulk_update_start"`.
- In `update_checker.rs` (`check_for_plugin_updates`), remove the `app_handle.emit("update_check_completed", ())` call. The result should be handled via the command's return value.
## Frontend Changes (`src/App.tsx`)
- [x] **Align `Plugin` Interface Nullability:**
- Ensure `depend`, `soft_depend`, and `load_before` fields consistently use `string[] | null`.
- [x] **Rename Command Invokes:**
- Change `invoke("check_single_plugin_update", ...)` to `invoke("check_single_plugin_update_command", ...)`.
- Change `invoke("set_plugin_repository_source", ...)` to `invoke("set_plugin_repository", ...)`.
- [x] **Refactor `checkForUpdates` Result Handling:**
- Modify the `checkForUpdates` async function to `await` the `invoke("check_plugin_updates", ...)` call.
- Use a `try...catch` block or `.then().catch()` to handle the `Result<Vec<Plugin>, String>`.
- On success (`Ok(updatedPlugins)`), call `setPlugins(updatedPlugins)` and clear errors/loading states.
- On error (`Err(error)`), call `setUpdateError(error)` and clear loading states.
- [x] **Adjust/Remove Event Listeners:**
- In `useEffect`, rename the listener for `"update_check_started"` to `"bulk_update_start"`.
- In `useEffect`, remove the listeners for `"bulk_update_complete"` and `"bulk_update_error"`.

View File

@ -2,9 +2,10 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/tauri.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
<meta name="description" content="PlugSnatcher - A tool for managing and updating Minecraft server plugins" />
<title>PlugSnatcher - Minecraft Server Plugin Manager</title>
</head>
<body>

26
package-lock.json generated
View File

@ -8,14 +8,15 @@
"name": "plugsnatcher",
"version": "0.1.0",
"dependencies": {
"@tauri-apps/api": "^2.4.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
@ -1303,10 +1304,19 @@
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.2.6.tgz",
"integrity": "sha512-bSdkuP71ZQRepPOn8BOEdBKYJQvl6+jb160QtJX/i2H9BF6ZySY/kYljh76N2Ne5fJMQRge7rlKoStYQY5Jq1w==",
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.2.0.tgz",
"integrity": "sha512-+08mApuONKI8/sCNEZ6AR8vf5vI9DXD4YfrQ9NQmhRxYKMLVhRW164vdW5BSLmMpuevftpQ2FVoL9EFkfG9Z+g==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.2.0.tgz",
"integrity": "sha512-iC3Ic1hLmasoboG7BO+7p+AriSoqAwKrIk+Hpk+S/bjTQdXqbl2GbdclghI4gM32X0bls7xHzIFqhRdrlvJeaA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.0.0"

View File

@ -7,17 +7,19 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@tauri-apps/api": "^2.4.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@tauri-apps/cli": "^2.0.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",

View File

@ -1,7 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

1150
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,31 +1,48 @@
[package]
name = "plugsnatcher"
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "plugsnatcher_lib"
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
tauri-build = { version = "2.1.0", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
zip = "0.6"
yaml-rust = "0.4"
walkdir = "2.4"
regex = "1.10"
sha2 = "0.10"
# Tauri dependencies
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.4.0", features = [] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "~2.0"
tauri-plugin-dialog = "~2.0"
tauri-plugin-fs = "~2.0"
# Plugin scanner dependencies
walkdir = "2.3.3"
regex = "1.9.1"
yaml-rust = "0.4.5"
zip = "0.6.6"
sha2 = "0.10.7"
# Network and async dependencies
reqwest = { version = "0.11.18", features = ["json"] }
tokio = { version = "1.29.1", features = ["full"] }
futures = "0.3.28"
async-trait = "0.1.71"
cached = "0.44.0"
urlencoding = "2.1.2"
base64 = "0.21.2"
# Version management
semver = "1.0.18"

View File

@ -1,3 +1,3 @@
fn main() {
tauri_build::build()
tauri_build::build()
}

View File

@ -1,12 +1,25 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"description": "Default capability set for the main window",
"windows": [
"main"
],
"permissions": [
"core:default",
"opener:default",
"dialog:default",
"dialog:allow-open"
{ "identifier": "core:default" },
{ "identifier": "dialog:default" },
{ "identifier": "dialog:allow-open" },
{ "identifier": "shell:default" },
{ "identifier": "shell:allow-open" },
{ "identifier": "shell:allow-execute" },
{ "identifier": "fs:default" },
{
"identifier": "fs:allow-read-dir",
"allow": ["**", "//**"]
},
{
"identifier": "fs:allow-read-file",
"allow": ["**", "//**"]
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 49 KiB

0
src-tauri/schema.json Normal file
View File

View File

@ -0,0 +1,3 @@
pub mod plugin_commands;
pub mod scan_commands;
pub mod util_commands;

View File

@ -0,0 +1,371 @@
use tauri::{command, AppHandle};
use crate::models::repository::RepositorySource;
use crate::models::server::ServerType;
use crate::models::plugin::Plugin;
use crate::services::update_manager::{compare_plugin_versions, backup_plugin, replace_plugin};
/// Search for plugins in specified repositories
#[command]
pub async fn search_plugins(query: String, repositories: Vec<RepositorySource>) -> Result<Vec<crate::models::repository::RepositoryPlugin>, String> {
crate::lib_search_plugins_in_repositories(&query, repositories).await
}
/// Get plugin details from a repository
#[command]
pub async fn get_plugin_details(
plugin_id: String,
repository: RepositorySource,
server_type_str: Option<String>,
) -> Result<crate::models::repository::RepositoryPlugin, String> {
// Convert server_type_str to ServerType if provided
let server_type = if let Some(type_str) = server_type_str {
match type_str.as_str() {
"paper" => Some(crate::models::server::ServerType::Paper),
"spigot" => Some(crate::models::server::ServerType::Spigot),
"bukkit" => Some(crate::models::server::ServerType::Bukkit),
"velocity" => Some(crate::models::server::ServerType::Velocity),
"bungeecord" => Some(crate::models::server::ServerType::BungeeCord),
"waterfall" => Some(crate::models::server::ServerType::Waterfall),
"forge" => Some(crate::models::server::ServerType::Forge),
"fabric" => Some(crate::models::server::ServerType::Fabric),
_ => None,
}
} else {
None
};
crate::lib_get_plugin_details_from_repository(&plugin_id, repository, server_type.as_ref()).await
}
/// Download a plugin from a repository
#[command]
pub async fn download_plugin(
plugin_id: String,
version: String,
repository: String,
destination: String,
server_type_str: Option<String>
) -> Result<String, String> {
// Convert repository string to RepositorySource
let repo_source = match repository.to_lowercase().as_str() {
"hangarmc" => RepositorySource::HangarMC,
"spigotmc" => RepositorySource::SpigotMC,
"modrinth" => RepositorySource::Modrinth,
"github" => RepositorySource::GitHub,
"bukkitdev" => RepositorySource::BukkitDev,
_ => RepositorySource::Custom(repository.clone()),
};
// Convert server_type_str to ServerType if provided
let server_type = if let Some(type_str) = server_type_str {
match type_str.as_str() {
"paper" => Some(ServerType::Paper),
"spigot" => Some(ServerType::Spigot),
"bukkit" => Some(ServerType::Bukkit),
"velocity" => Some(ServerType::Velocity),
"bungeecord" => Some(ServerType::BungeeCord),
"waterfall" => Some(ServerType::Waterfall),
"forge" => Some(ServerType::Forge),
"fabric" => Some(ServerType::Fabric),
_ => None,
}
} else {
None
};
crate::lib_download_plugin_from_repository(&plugin_id, &version, repo_source, &destination, server_type.as_ref()).await
}
/// Update a plugin with a new version
#[command]
pub async fn update_plugin(
app_handle: AppHandle,
plugin_id: String,
version: String,
repository: String,
current_file_path: String,
server_type_str: Option<String>
) -> Result<String, String> {
// Convert repository string to RepositorySource
let repo_source = match repository.to_lowercase().as_str() {
"hangarmc" => RepositorySource::HangarMC,
"spigotmc" => RepositorySource::SpigotMC,
"modrinth" => RepositorySource::Modrinth,
"github" => RepositorySource::GitHub,
"bukkitdev" => RepositorySource::BukkitDev,
_ => RepositorySource::Custom(repository.clone()),
};
// Convert server_type_str to ServerInfo if provided
let server_info = if let Some(type_str) = server_type_str {
let server_type = match type_str.as_str() {
"paper" => ServerType::Paper,
"spigot" => ServerType::Spigot,
"bukkit" => ServerType::Bukkit,
"velocity" => ServerType::Velocity,
"bungeecord" => ServerType::BungeeCord,
"waterfall" => ServerType::Waterfall,
"forge" => ServerType::Forge,
"fabric" => ServerType::Fabric,
_ => ServerType::Unknown,
};
Some(crate::models::server::ServerInfo {
server_type,
minecraft_version: None,
plugins_directory: "".to_string(),
plugins_count: 0,
})
} else {
None
};
// Replace the plugin
replace_plugin(plugin_id, version, repo_source, current_file_path, server_info).await
}
/// Check for updates for multiple plugins
#[command]
pub async fn check_plugin_updates(
app_handle: AppHandle,
plugins: Vec<Plugin>,
repositories: Vec<String>,
server_path: String
) -> Result<Vec<Plugin>, String> {
// Convert repository strings to RepositorySource
let repos: Vec<RepositorySource> = repositories.into_iter()
.map(|repo| match repo.to_lowercase().as_str() {
"hangarmc" => RepositorySource::HangarMC,
"spigotmc" => RepositorySource::SpigotMC,
"modrinth" => RepositorySource::Modrinth,
"github" => RepositorySource::GitHub,
"bukkitdev" => RepositorySource::BukkitDev,
_ => RepositorySource::Custom(repo),
})
.collect();
// Get server info from the path for compatibility checking
let scan_result = match crate::services::plugin_scanner::perform_scan(&app_handle, &server_path).await {
Ok(result) => result,
Err(e) => {
println!("Warning: Could not get server info for compatibility check: {}", e);
// Create a minimal result with default server info
let server_info = crate::models::server::ServerInfo {
server_type: crate::models::server::ServerType::Unknown,
minecraft_version: None,
plugins_directory: format!("{}/plugins", server_path),
plugins_count: 0
};
crate::models::server::ScanResult {
server_info,
plugins: Vec::new()
}
}
};
crate::services::update_manager::check_for_plugin_updates(app_handle, plugins, repos, &scan_result.server_info).await
}
/// Check for updates for a single plugin
#[command]
pub async fn check_single_plugin_update_command(
app_handle: AppHandle,
plugin: Plugin,
repositories: Vec<String>,
server_path: Option<String>
) -> Result<(), String> {
// Convert repository strings to RepositorySource
let repos: Vec<RepositorySource> = repositories.into_iter()
.map(|repo| match repo.to_lowercase().as_str() {
"hangarmc" => RepositorySource::HangarMC,
"spigotmc" => RepositorySource::SpigotMC,
"modrinth" => RepositorySource::Modrinth,
"github" => RepositorySource::GitHub,
"bukkitdev" => RepositorySource::BukkitDev,
_ => RepositorySource::Custom(repo),
})
.collect();
// Get server info if a path was provided
let server_info = if let Some(path) = server_path {
match crate::services::plugin_scanner::perform_scan(&app_handle, &path).await {
Ok(result) => Some(result.server_info),
Err(e) => {
println!("Warning: Could not get server info for compatibility check: {}", e);
None
}
}
} else {
None
};
// Pass the optional server info to the update function
crate::services::update_manager::check_single_plugin_update(app_handle, plugin, repos, server_info.as_ref()).await
}
/// Create a backup of a plugin file
#[command]
pub async fn backup_plugin_command(plugin_file_path: String) -> Result<String, String> {
backup_plugin(plugin_file_path).await
}
/// Set repository source for a plugin
#[command]
pub async fn set_plugin_repository(
app_handle: AppHandle,
plugin_file_path: String,
repository: String,
repository_id: String,
page_url: String,
server_path: String
) -> Result<Plugin, String> {
// Convert repository string to RepositorySource
let repo_source = match repository.to_lowercase().as_str() {
"hangarmc" => RepositorySource::HangarMC,
"spigotmc" => RepositorySource::SpigotMC,
"modrinth" => RepositorySource::Modrinth,
"github" => RepositorySource::GitHub,
"bukkitdev" => RepositorySource::BukkitDev,
_ => RepositorySource::Custom(repository.clone()),
};
// Load the plugin data
let plugins = crate::services::plugin_scanner::perform_scan(&app_handle, &server_path).await?.plugins;
// Find the specific plugin
let mut plugin = plugins.into_iter()
.find(|p| p.file_path == plugin_file_path)
.ok_or_else(|| format!("Plugin not found: {}", plugin_file_path))?;
// Update repository information
plugin.repository_source = Some(repo_source);
plugin.repository_id = Some(repository_id);
plugin.repository_url = Some(page_url);
// Trigger an update check
if let Some(repo_id) = &plugin.repository_id {
if let Some(repo_source) = &plugin.repository_source {
match crate::lib_get_plugin_details_from_repository(repo_id, repo_source.clone(), None).await {
Ok(repo_plugin) => {
// Set latest version if newer
if repo_plugin.version != plugin.version {
let has_update = compare_plugin_versions(&plugin.version, &repo_plugin.version);
plugin.latest_version = Some(repo_plugin.version);
plugin.has_update = has_update;
plugin.changelog = repo_plugin.changelog;
} else {
plugin.has_update = false;
}
},
Err(e) => {
println!("Error checking for updates: {}", e);
}
}
}
}
Ok(plugin)
}
/// Load saved plugin data for a specific server
#[command]
pub async fn load_plugin_data(
app_handle: AppHandle,
server_path: String,
) -> Result<Vec<Plugin>, String> {
let data_dir = crate::services::plugin_scanner::get_plugin_data_path(&app_handle, &server_path)?;
let data_path = data_dir.join("plugins.json");
if !data_path.exists() {
// If the file doesn't exist, it's not an error, just return empty list
return Ok(Vec::new());
}
// Read the file content
let json_data = std::fs::read_to_string(&data_path)
.map_err(|e| format!("Failed to read plugin data file: {}", e))?;
// Deserialize the JSON data
let plugins: Vec<Plugin> = serde_json::from_str(&json_data)
.map_err(|e| format!("Failed to deserialize plugin data: {}", e))?;
Ok(plugins)
}
/// Save plugin data for a specific server
#[command]
pub async fn save_plugin_data(
app_handle: AppHandle,
plugins: Vec<Plugin>,
server_path: String,
) -> Result<(), String> {
let data_dir = crate::services::plugin_scanner::get_plugin_data_path(&app_handle, &server_path)?;
// Create directory if it doesn't exist
if !data_dir.exists() {
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create plugin data directory: {}", e))?;
}
// Save plugins data
let data_path = data_dir.join("plugins.json");
let json_data = serde_json::to_string_pretty(&plugins)
.map_err(|e| format!("Failed to serialize plugin data for saving: {}", e))?;
std::fs::write(&data_path, json_data)
.map_err(|e| format!("Failed to write plugin data file: {}", e))?;
Ok(())
}
/// Get versions for a plugin from a repository
#[command]
pub async fn get_plugin_versions(
plugin_id: String,
repository: String
) -> Result<Vec<String>, String> {
// This is a placeholder - would need to implement the actual repository API call
Ok(vec!["1.0.0".to_string(), "1.1.0".to_string(), "1.2.0".to_string()])
}
/// Get potential matches for a plugin from repositories
#[command]
pub async fn get_potential_plugin_matches(
app_handle: AppHandle,
plugin: Plugin,
repositories: Vec<String>
) -> Result<Vec<crate::models::repository::PotentialPluginMatch>, String> {
// Convert repository strings to RepositorySource
let repos: Vec<RepositorySource> = repositories.into_iter()
.map(|repo| match repo.to_lowercase().as_str() {
"hangarmc" => RepositorySource::HangarMC,
"spigotmc" => RepositorySource::SpigotMC,
"modrinth" => RepositorySource::Modrinth,
"github" => RepositorySource::GitHub,
"bukkitdev" => RepositorySource::BukkitDev,
_ => RepositorySource::Custom(repo),
})
.collect();
// This is a placeholder - would need to implement actual search
Ok(Vec::new())
}
/// Compare two version strings
#[command]
pub fn compare_versions(version1: String, version2: String) -> bool {
compare_plugin_versions(&version1, &version2)
}
/// Check if a plugin is compatible with a specific Minecraft version
#[command]
pub fn is_plugin_compatible(plugin_version: String, minecraft_version: String) -> bool {
// This is a placeholder - would need to implement actual compatibility check
true
}
/// Simple greeting function for testing
#[command]
pub fn greet(name: &str) -> String {
format!("Hello, {}! Welcome to PlugSnatcher.", name)
}

View File

@ -0,0 +1,14 @@
use tauri::{command, AppHandle};
use crate::services::plugin_scanner::{scan_server_directory, perform_scan};
/// Scan a server directory for plugins
#[command]
pub async fn scan_server_dir(app_handle: AppHandle, path: String) -> Result<(), String> {
scan_server_directory(app_handle, path).await
}
/// Scan a server directory and return the result immediately
#[command]
pub async fn scan_server_dir_sync(app_handle: AppHandle, path: String) -> Result<crate::models::server::ScanResult, String> {
perform_scan(&app_handle, &path).await
}

View File

@ -0,0 +1,17 @@
use serde::Serialize;
use tauri::command;
use crate::*;
// We will use this to return the app version from Cargo.toml
#[derive(Debug, Serialize)]
pub struct AppInfo {
pub version: String,
}
#[command]
pub fn get_app_version() -> AppInfo {
// Return the crate version from Cargo.toml
AppInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
}
}

View File

@ -0,0 +1,317 @@
use std::error::Error;
use std::path::Path;
use serde::{Serialize, Deserialize};
use crate::{HttpClient, RepositorySource};
use urlencoding;
use async_trait::async_trait;
use std::sync::Arc;
use crate::models::repository::RepositoryPlugin;
use crate::crawlers::Repository;
use regex;
// GitHub API response structures (Based on https://docs.github.com/en/rest/releases/releases)
#[derive(Debug, Serialize, Deserialize)]
struct GitHubRelease {
// Structure for release details
tag_name: String,
name: Option<String>,
body: Option<String>,
published_at: String,
assets: Vec<GitHubAsset>,
html_url: String, // URL to the release page
prerelease: bool,
draft: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct GitHubAsset {
// Structure for release asset details
name: String,
browser_download_url: String,
size: u64,
updated_at: String,
download_count: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct GitHubRepo {
// Structure for repository details (used for searching potentially)
id: u64,
name: String,
full_name: String, // "owner/repo"
owner: GitHubUser,
description: Option<String>,
html_url: String,
stargazers_count: u64, // Can use this as a proxy for rating/popularity
updated_at: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct GitHubUser {
login: String,
avatar_url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct GitHubSearchResponse {
// Structure for repository search results
items: Vec<GitHubRepo>,
// Ignoring total_count, incomplete_results for now
}
// GitHub crawler implementation
#[derive(Clone)]
pub struct GitHubCrawler {
client: Arc<HttpClient>,
api_base_url: String,
}
// Inherent methods for GitHubCrawler
impl GitHubCrawler {
pub fn new() -> Self {
GitHubCrawler {
client: Arc::new(HttpClient::new()),
api_base_url: "https://api.github.com".to_string(),
}
}
// Helper function to get the latest release for a repo, now async
async fn get_latest_release_details(&self, repo_full_name: &str) -> Result<GitHubRelease, Box<dyn Error + Send + Sync>> {
let url = format!("{}/repos/{}/releases/latest", self.api_base_url, repo_full_name);
let response_body = self.client.get(&url).await?;
let release: GitHubRelease = serde_json::from_str(&response_body)?;
Ok(release)
}
// Helper function to get all releases, now async
async fn get_all_release_details(&self, repo_full_name: &str) -> Result<Vec<GitHubRelease>, Box<dyn Error + Send + Sync>> {
let url = format!("{}/repos/{}/releases?per_page=100", self.api_base_url, repo_full_name);
let response_body = self.client.get(&url).await?;
let releases: Vec<GitHubRelease> = serde_json::from_str(&response_body)?;
Ok(releases)
}
// Helper function to get repo details, now async
async fn get_repo_details(&self, repo_full_name: &str) -> Result<GitHubRepo, Box<dyn Error + Send + Sync>> {
let repo_url = format!("{}/repos/{}", self.api_base_url, repo_full_name);
let repo_response_body = self.client.get(&repo_url).await?;
let repo: GitHubRepo = serde_json::from_str(&repo_response_body)?;
Ok(repo)
}
async fn get_plugin_versions(&self, repo_full_name: &str) -> Result<Vec<String>, String> {
let releases = match self.get_all_release_details(repo_full_name).await {
Ok(r) => r,
Err(e) => return Err(format!("Failed GitHub get releases for versions: {}", e)),
};
let versions = releases.into_iter()
.filter(|r| !r.draft && !r.prerelease && r.assets.iter().any(|a| a.name.ends_with(".jar")))
.map(|r| r.tag_name)
.collect();
Ok(versions)
}
}
// Helper function to extract Minecraft versions from text
fn extract_minecraft_versions(body: Option<&str>, description: &str) -> Vec<String> {
let mut versions = Vec::new();
// Common version patterns
let version_pattern = regex::Regex::new(r"(?i)(1\.\d{1,2}(?:\.\d{1,2})?)").unwrap();
// Check release body
if let Some(text) = body {
for cap in version_pattern.captures_iter(text) {
if let Some(version) = cap.get(1) {
versions.push(version.as_str().to_string());
}
}
}
// Check description if we didn't find any versions
if versions.is_empty() {
for cap in version_pattern.captures_iter(description) {
if let Some(version) = cap.get(1) {
versions.push(version.as_str().to_string());
}
}
}
// If still empty, add a default
if versions.is_empty() {
versions.push("Unknown".to_string());
}
versions
}
// Helper function to extract supported loaders from text
fn extract_loaders(body: Option<&str>, description: &str) -> Vec<String> {
let mut loaders = Vec::new();
// Check for common loader keywords
let mut check_for_loader = |text: &str, loader_name: &str| {
if text.to_lowercase().contains(&loader_name.to_lowercase()) {
loaders.push(loader_name.to_string());
}
};
// Process both body and description
let empty_string = String::new();
let body_str = body.unwrap_or("");
check_for_loader(body_str, "Paper");
check_for_loader(body_str, "Spigot");
check_for_loader(body_str, "Bukkit");
check_for_loader(body_str, "Forge");
check_for_loader(body_str, "Fabric");
check_for_loader(body_str, "Velocity");
check_for_loader(body_str, "BungeeCord");
check_for_loader(body_str, "Waterfall");
check_for_loader(description, "Paper");
check_for_loader(description, "Spigot");
check_for_loader(description, "Bukkit");
check_for_loader(description, "Forge");
check_for_loader(description, "Fabric");
check_for_loader(description, "Velocity");
check_for_loader(description, "BungeeCord");
check_for_loader(description, "Waterfall");
// If no loaders detected, assume Bukkit/Spigot/Paper as most common
if loaders.is_empty() {
loaders.push("Bukkit".to_string());
loaders.push("Spigot".to_string());
loaders.push("Paper".to_string());
}
loaders
}
#[async_trait]
impl Repository for GitHubCrawler {
fn get_repository_name(&self) -> String {
"GitHub".to_string()
}
async fn search(&self, query: &str) -> Result<Vec<RepositoryPlugin>, String> {
let search_terms = format!("{} topic:minecraft-plugin topic:spigot topic:paper topic:bukkit fork:false", query);
let encoded_query = urlencoding::encode(&search_terms);
let url = format!(
"{}/search/repositories?q={}&sort=stars&order=desc",
self.api_base_url,
encoded_query
);
let response_body = match self.client.get(&url).await {
Ok(body) => body,
Err(e) => return Err(format!("GitHub search request failed: {}", e)),
};
let search_response: GitHubSearchResponse = match serde_json::from_str(&response_body) {
Ok(res) => res,
Err(e) => return Err(format!("Failed to parse GitHub search results: {}", e)),
};
let fetch_tasks = search_response.items.into_iter().map(|repo| {
let self_clone = self.clone();
async move {
self_clone.get_plugin_details(&repo.full_name).await
}
});
let results: Vec<RepositoryPlugin> = futures::future::join_all(fetch_tasks).await
.into_iter()
.filter_map(|result| match result {
Ok(plugin) => Some(plugin),
Err(e) => {
println!("Error fetching details during search: {}", e);
None
}
})
.collect();
Ok(results)
}
async fn get_plugin_details(&self, plugin_id: &str) -> Result<RepositoryPlugin, String> {
let repo_full_name = plugin_id;
let repo = match self.get_repo_details(repo_full_name).await {
Ok(r) => r,
Err(e) => return Err(format!("Failed GitHub get repo details: {}", e)),
};
let mut releases = match self.get_all_release_details(repo_full_name).await {
Ok(r) => r,
Err(e) => return Err(format!("Failed GitHub get releases: {}", e)),
};
releases.sort_by(|a, b| b.published_at.cmp(&a.published_at));
let latest_valid_release = releases.into_iter()
.filter(|r| !r.draft && !r.prerelease)
.find(|r| r.assets.iter().any(|a| a.name.ends_with(".jar")));
if let Some(release) = latest_valid_release {
if let Some(asset) = release.assets.iter().find(|a| a.name.ends_with(".jar")) {
Ok(RepositoryPlugin {
id: repo_full_name.to_string(),
name: repo.name,
version: release.tag_name,
description: repo.description.clone(),
authors: vec![repo.owner.login],
download_url: asset.browser_download_url.clone(),
repository: RepositorySource::GitHub,
page_url: repo.html_url,
download_count: Some(asset.download_count),
last_updated: Some(release.published_at),
icon_url: repo.owner.avatar_url,
minecraft_versions: extract_minecraft_versions(
release.body.as_deref(),
&repo.description.clone().unwrap_or_default()
),
categories: Vec::new(),
rating: Some(repo.stargazers_count as f32),
file_size: Some(asset.size),
file_hash: None,
changelog: release.body.clone(),
loaders: extract_loaders(
release.body.as_deref(),
&repo.description.clone().unwrap_or_default()
),
supported_versions: extract_minecraft_versions(
release.body.as_deref(),
&repo.description.clone().unwrap_or_default()
),
})
} else {
Err(format!("No suitable JAR asset found in the latest valid release for {}", repo_full_name))
}
} else {
Err(format!("No valid release with a JAR asset found for {}", repo_full_name))
}
}
async fn download_plugin(&self, plugin_id: &str, version_number_str: &str, destination: &Path) -> Result<String, String> {
let repo_full_name = plugin_id;
let tag_name = version_number_str;
let release_url = format!("{}/repos/{}/releases/tags/{}", self.api_base_url, repo_full_name, tag_name);
let release_response_body = match self.client.get(&release_url).await {
Ok(body) => body,
Err(e) => return Err(format!("GitHub get release by tag failed: {}", e)),
};
let release: GitHubRelease = match serde_json::from_str(&release_response_body) {
Ok(r) => r,
Err(e) => return Err(format!("Failed to parse GitHub release by tag: {}", e)),
};
let asset = release.assets.iter()
.find(|a| a.name.ends_with(".jar"))
.ok_or_else(|| format!("No suitable JAR asset found in release tag '{}' for {}", tag_name, repo_full_name))?;
match self.client.download(&asset.browser_download_url, destination).await {
Ok(_) => Ok(destination.to_string_lossy().to_string()),
Err(e) => Err(format!("Failed to download from GitHub: {}", e))
}
}
}

View File

@ -0,0 +1,227 @@
use std::error::Error;
use std::path::Path;
use serde::{Serialize, Deserialize};
use crate::HttpClient;
use crate::models::repository::{RepositoryPlugin, RepositorySource};
use crate::crawlers::Repository;
use urlencoding;
use async_trait::async_trait;
use std::sync::Arc;
// HangarMC API response structures
#[derive(Debug, Serialize, Deserialize)]
struct HangarProjectsResponse {
pagination: HangarPagination,
result: Vec<HangarProject>,
}
#[derive(Debug, Serialize, Deserialize)]
struct HangarPagination {
limit: u32,
offset: u32,
count: u32,
}
#[derive(Debug, Serialize, Deserialize)]
struct HangarProject {
id: String,
name: String,
namespace: HangarNamespace,
category: String,
description: Option<String>,
stats: HangarStats,
last_updated: String,
icon_url: Option<String>,
created_at: String,
visibility: String,
game_versions: Vec<String>,
platform: String,
categories: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct HangarNamespace {
owner: String,
slug: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct HangarStats {
views: u64,
downloads: u64,
recent_views: u64,
recent_downloads: u64,
stars: u64,
watchers: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct HangarVersionsResponse {
pagination: HangarPagination,
result: Vec<HangarVersion>,
}
#[derive(Debug, Serialize, Deserialize)]
struct HangarVersion {
name: String,
created_at: String,
description: Option<String>,
downloads: u64,
file_size: u64,
platform_versions: Vec<String>,
}
// Hangar crawler implementation
pub struct HangarCrawler {
client: Arc<HttpClient>,
api_base_url: String,
web_base_url: String,
}
impl HangarCrawler {
pub fn new() -> Self {
HangarCrawler {
client: Arc::new(HttpClient::new()),
api_base_url: "https://hangar.papermc.io/api/v1".to_string(),
web_base_url: "https://hangar.papermc.io".to_string(),
}
}
async fn get_project_details_internal(&self, author: &str, slug: &str) -> Result<HangarProject, Box<dyn Error + Send + Sync>> {
let url = format!("{}/projects/{}/{}", self.api_base_url, author, slug);
let response_body = self.client.get(&url).await?;
let project: HangarProject = serde_json::from_str(&response_body)?;
Ok(project)
}
async fn get_project_versions_internal(&self, author: &str, slug: &str) -> Result<Vec<HangarVersion>, Box<dyn Error + Send + Sync>> {
let url = format!("{}/projects/{}/{}/versions?limit=25&offset=0", self.api_base_url, author, slug);
let response_body = self.client.get(&url).await?;
let versions_result: HangarVersionsResponse = serde_json::from_str(&response_body)?;
Ok(versions_result.result)
}
}
#[async_trait]
impl Repository for HangarCrawler {
fn get_repository_name(&self) -> String {
"HangarMC".to_string()
}
async fn search(&self, query: &str) -> Result<Vec<RepositoryPlugin>, String> {
let encoded_query = urlencoding::encode(query);
let url = format!(
"{}/projects?q={}",
self.api_base_url,
encoded_query
);
let response_body = match self.client.get(&url).await {
Ok(body) => body,
Err(e) => return Err(format!("Failed to search HangarMC: {}", e)),
};
let search_response: HangarProjectsResponse = match serde_json::from_str(&response_body) {
Ok(res) => res,
Err(e) => return Err(format!("Failed to parse HangarMC search results: {}", e)),
};
let results: Vec<RepositoryPlugin> = search_response.result.into_iter().map(|proj| {
let page_url = format!("{}/{}/{}", self.web_base_url, proj.namespace.owner, proj.namespace.slug);
let version = "Unknown".to_string();
RepositoryPlugin {
id: format!("{}/{}", proj.namespace.owner, proj.namespace.slug),
name: proj.name,
version,
description: proj.description.clone(),
authors: vec![proj.namespace.owner],
download_url: String::new(),
repository: RepositorySource::HangarMC,
page_url,
download_count: Some(proj.stats.downloads),
last_updated: Some(proj.last_updated),
icon_url: proj.icon_url.clone(),
minecraft_versions: proj.game_versions.clone(),
categories: proj.categories.clone(),
rating: Some(proj.stats.stars as f32),
file_size: None,
file_hash: None,
changelog: None,
loaders: vec![proj.platform.clone()],
supported_versions: proj.game_versions.clone(),
}
}).collect();
Ok(results)
}
async fn get_plugin_details(&self, plugin_id: &str) -> Result<RepositoryPlugin, String> {
let parts: Vec<&str> = plugin_id.split('/').collect();
if parts.len() != 2 {
return Err(format!("Invalid Hangar plugin ID format: {}. Expected 'author/slug'.", plugin_id));
}
let author = parts[0];
let slug = parts[1];
let project = match self.get_project_details_internal(author, slug).await {
Ok(p) => p,
Err(e) => return Err(format!("Failed to get Hangar project details: {}", e)),
};
let versions = match self.get_project_versions_internal(author, slug).await {
Ok(v) => v,
Err(e) => return Err(format!("Failed to get Hangar project versions: {}", e)),
};
let latest_version_name = versions.first().map_or("Unknown".to_string(), |v| v.name.clone());
let page_url = format!("{}/{}/{}", self.web_base_url, author, slug);
Ok(RepositoryPlugin {
id: plugin_id.to_string(),
name: project.name,
version: latest_version_name,
description: project.description.clone(),
authors: vec![project.namespace.owner],
download_url: String::new(),
repository: RepositorySource::HangarMC,
page_url,
download_count: Some(project.stats.downloads),
last_updated: Some(project.last_updated),
icon_url: project.icon_url.clone(),
minecraft_versions: project.game_versions.clone(),
categories: project.categories.clone(),
rating: Some(project.stats.stars as f32),
file_size: versions.first().map(|v| v.file_size),
file_hash: None,
changelog: None,
loaders: vec![project.platform.clone()],
supported_versions: project.game_versions.clone(),
})
}
async fn download_plugin(&self, plugin_id: &str, version: &str, destination: &Path) -> Result<String, String> {
let parts: Vec<&str> = plugin_id.split('/').collect();
if parts.len() != 2 {
return Err(format!("Invalid Hangar plugin ID format: {}. Expected 'author/slug'.", plugin_id));
}
let author = parts[0];
let slug = parts[1];
let platform_str = "PAPER";
let download_url = format!(
"{}/projects/{}/{}/versions/{}/{}/download",
self.api_base_url,
author,
slug,
version,
platform_str
);
match self.client.download(&download_url, destination).await {
Ok(_) => Ok(destination.to_string_lossy().to_string()),
Err(e) => Err(format!("Failed to download plugin: {}", e))
}
}
}

View File

@ -0,0 +1,43 @@
pub mod hangar;
pub mod spigotmc;
pub mod modrinth;
pub mod github;
use std::path::Path;
use crate::models::repository::RepositoryPlugin;
use crate::models::server::ServerType;
use async_trait::async_trait;
/// Common interface for all repository crawlers
#[async_trait]
pub trait Repository {
/// Get the name of the repository
fn get_repository_name(&self) -> String;
/// Search for plugins in the repository
async fn search(&self, query: &str) -> Result<Vec<RepositoryPlugin>, String>;
/// Get plugin details from the repository
async fn get_plugin_details(&self, plugin_id: &str) -> Result<RepositoryPlugin, String>;
/// Get plugin details with server type consideration
async fn get_plugin_details_with_server_type(&self, plugin_id: &str, server_type: Option<&ServerType>) -> Result<RepositoryPlugin, String> {
// Default implementation just calls the regular get_plugin_details
self.get_plugin_details(plugin_id).await
}
/// Download a plugin from the repository
async fn download_plugin(&self, plugin_id: &str, version: &str, destination: &Path) -> Result<String, String>;
/// Download a plugin with server type consideration
async fn download_plugin_with_server_type(&self, plugin_id: &str, version: &str, destination: &Path, server_type: Option<&ServerType>) -> Result<String, String> {
// Default implementation calls the regular download_plugin
self.download_plugin(plugin_id, version, destination).await
}
}
// Re-export the crawler implementations
pub use hangar::HangarCrawler;
pub use spigotmc::SpigotMCCrawler;
pub use modrinth::ModrinthCrawler;
pub use github::GitHubCrawler;

View File

@ -0,0 +1,357 @@
use std::error::Error;
use std::path::Path;
use serde::{Serialize, Deserialize};
use crate::{HttpClient, ServerType};
use crate::models::repository::{RepositoryPlugin, RepositorySource};
use crate::platform_matcher::is_version_compatible_with_server;
use urlencoding;
use async_trait::async_trait;
use std::sync::Arc;
use crate::crawlers::Repository;
// Modrinth API response structures (Based on https://docs.modrinth.com/api-spec/)
#[derive(Debug, Serialize, Deserialize)]
struct ModrinthSearchResponse {
hits: Vec<ModrinthSearchHit>,
// Omitting pagination fields (offset, limit, total_hits) for simplicity
}
#[derive(Debug, Serialize, Deserialize)]
struct ModrinthSearchHit {
project_id: String,
slug: String, // Use this or project_id for details requests
project_type: String, // e.g., "mod", "plugin"
author: String, // Sometimes different from the project owner
title: String,
description: String,
categories: Vec<String>,
versions: Vec<String>, // List of supported Minecraft versions
downloads: u64,
icon_url: Option<String>,
latest_version: Option<String>, // Version number of the latest version
date_modified: String,
// Omitting client_side, server_side, display_categories, gallery, featured_gallery, license, follows
}
#[derive(Debug, Serialize, Deserialize)]
struct ModrinthProject {
id: String,
slug: String,
project_type: String,
team: String, // Team ID, need another call potentially for author names?
title: String,
description: String,
body: String, // Full description
categories: Vec<String>,
game_versions: Vec<String>,
downloads: u64,
icon_url: Option<String>,
published: String,
updated: String,
// Omitting donation_urls, license, client_side, server_side, gallery, status, moderator_message, versions
// We might need the 'versions' array (list of version IDs) for getting specific version details
}
// Structure for the response from /project/{id}/version
#[derive(Debug, Serialize, Deserialize)]
struct ModrinthVersion {
id: String,
project_id: String,
author_id: String, // User ID, need another call for name?
name: String, // Version title
version_number: String,
changelog: Option<String>,
date_published: String,
downloads: u64,
version_type: String, // e.g., "release", "beta", "alpha"
files: Vec<ModrinthVersionFile>,
game_versions: Vec<String>,
loaders: Vec<String>,
// Omitting dependencies, featured, status, requested_status
}
#[derive(Debug, Serialize, Deserialize)]
struct ModrinthVersionFile {
hashes: ModrinthHashes,
url: String,
filename: String,
primary: bool,
size: u64,
// Omitting file_type (useful if filtering non-JARs)
}
#[derive(Debug, Serialize, Deserialize)]
struct ModrinthHashes {
sha1: String,
sha512: String,
}
// Modrinth crawler implementation
pub struct ModrinthCrawler {
client: Arc<HttpClient>,
api_base_url: String,
}
impl ModrinthCrawler {
pub fn new() -> Self {
ModrinthCrawler {
client: Arc::new(HttpClient::new()),
api_base_url: "https://api.modrinth.com/v2".to_string(),
}
}
// Helper to get versions, now async
async fn get_project_versions_internal(&self, plugin_id: &str) -> Result<Vec<ModrinthVersion>, Box<dyn Error + Send + Sync>> {
let url = format!("{}/project/{}/version", self.api_base_url, plugin_id);
let response_body = self.client.get(&url).await?;
let versions: Vec<ModrinthVersion> = serde_json::from_str(&response_body)?;
Ok(versions)
}
// Helper to get compatible versions for a server type
async fn get_compatible_versions(&self, plugin_id: &str, server_type: Option<&ServerType>) -> Result<Vec<ModrinthVersion>, Box<dyn Error + Send + Sync>> {
let all_versions = self.get_project_versions_internal(plugin_id).await?;
// If no server type provided, return all versions
if server_type.is_none() {
return Ok(all_versions);
}
let server_type = server_type.unwrap();
let compatible_versions: Vec<ModrinthVersion> = all_versions.into_iter()
.filter(|version| {
// Check if this version is compatible with our server
is_version_compatible_with_server(&version.loaders, server_type)
})
.collect();
// If no compatible versions found, log a warning
if compatible_versions.is_empty() {
println!("[ModrinthCrawler::get_compatible_versions] Warning: No versions compatible with server type {:?} for plugin {}", server_type, plugin_id);
}
Ok(compatible_versions)
}
// Helper to get user details from ID, now async
async fn get_user_details(&self, user_id: &str) -> Result<ModrinthUser, Box<dyn Error + Send + Sync>> {
let url = format!("{}/user/{}", self.api_base_url, user_id);
let response_body = self.client.get(&url).await?;
let user: ModrinthUser = serde_json::from_str(&response_body)?;
Ok(user)
}
}
#[derive(Debug, Serialize, Deserialize)]
struct ModrinthUser {
username: String,
// other fields like id, name, avatar_url etc. are available if needed
}
impl ModrinthCrawler {
pub async fn get_plugin_details_with_server_type(&self, plugin_id: &str, server_type: Option<&ServerType>) -> Result<RepositoryPlugin, String> {
let project_url = format!("{}/project/{}", self.api_base_url, plugin_id);
let project_response_body = match self.client.get(&project_url).await {
Ok(body) => body,
Err(e) => return Err(format!("Modrinth project request failed: {}", e)),
};
let project: ModrinthProject = match serde_json::from_str(&project_response_body) {
Ok(p) => p,
Err(e) => return Err(format!("Failed to parse Modrinth project: {}", e)),
};
let page_url = format!("https://modrinth.com/plugin/{}", project.slug);
// Fetch compatible versions
let versions = match self.get_compatible_versions(&project.id, server_type).await {
Ok(v) => v,
Err(e) => {
println!("Failed to fetch Modrinth versions for {}: {}", plugin_id, e);
Vec::new() // Continue with empty versions if fetch fails
}
};
let latest_version_opt = versions.first();
// Explicitly use version_number instead of id
let latest_version_number = latest_version_opt.map_or("Unknown".to_string(), |v| {
// Always use the version_number field which is the actual semantic version
// not the internal Modrinth ID
v.version_number.clone()
});
let changelog = latest_version_opt.and_then(|v| v.changelog.clone());
let author_id = latest_version_opt.map(|v| v.author_id.clone());
// Fetch author name if ID is available
let author_name = if let Some(id) = author_id {
match self.get_user_details(&id).await {
Ok(user) => user.username,
Err(e) => {
println!("Failed to fetch Modrinth author details for ID {}: {}", id, e);
"Unknown Author".to_string()
}
}
} else {
project.team.clone() // Fallback to team ID if no version author found
};
let primary_file = latest_version_opt.and_then(|v| v.files.iter().find(|f| f.primary));
Ok(RepositoryPlugin {
id: project.id.clone(),
name: project.title,
version: latest_version_number,
description: Some(project.description),
authors: vec![author_name],
download_url: primary_file.map_or(String::new(), |f| f.url.clone()),
repository: RepositorySource::Modrinth,
page_url,
download_count: Some(project.downloads),
last_updated: Some(project.updated),
icon_url: project.icon_url,
minecraft_versions: latest_version_opt.map_or(project.game_versions.clone(), |v| v.game_versions.clone()),
categories: project.categories,
rating: None, // Modrinth API doesn't provide rating directly
file_size: primary_file.map(|f| f.size),
file_hash: primary_file.map(|f| f.hashes.sha512.clone()), // Use SHA512
changelog,
loaders: latest_version_opt.map_or(Vec::new(), |v| v.loaders.clone()),
supported_versions: latest_version_opt.map_or(project.game_versions.clone(), |v| v.game_versions.clone()),
})
}
pub async fn download_plugin_with_server_type(&self, plugin_id: &str, version_number_str: &str, destination: &Path, server_type: Option<&ServerType>) -> Result<String, String> {
// First try to get compatible versions for the server type
let versions = match self.get_compatible_versions(plugin_id, server_type).await {
Ok(v) => v,
Err(e) => return Err(format!("Failed to get Modrinth versions for download: {}", e)),
};
// Try to find a version that matches the requested version number among compatible versions
let mut version_to_download = versions.into_iter().find(|v| v.version_number == version_number_str);
// If no compatible version found, but we have a server type filter (meaning we actually filtered)
if version_to_download.is_none() && server_type.is_some() {
println!("No compatible version '{}' found for server type {:?}, attempting to find version without server compatibility check",
version_number_str, server_type);
// Try to get all versions without server type filtering
match self.get_project_versions_internal(plugin_id).await {
Ok(all_versions) => {
// Look for the specific version number in all versions
version_to_download = all_versions.into_iter().find(|v| v.version_number == version_number_str);
if version_to_download.is_some() {
println!("Found version '{}' without server compatibility check, proceeding with download", version_number_str);
}
},
Err(e) => {
println!("Error getting all versions: {}", e);
// Continue with empty versions list, which will return an error below
}
}
}
// If we still don't have a target version, return an error
let version = match version_to_download {
Some(v) => v,
None => return Err(format!("Version '{}' not found or not compatible for plugin {}", version_number_str, plugin_id)),
};
let primary_file = match version.files.iter().find(|f| f.primary) {
Some(f) => f,
None => return Err(format!("No primary file found for version '{}' of plugin {}", version_number_str, plugin_id)),
};
match self.client.download(&primary_file.url, destination).await {
Ok(_) => Ok(destination.to_string_lossy().to_string()),
Err(e) => Err(format!("Failed to download from Modrinth: {}", e)),
}
}
}
#[async_trait]
impl Repository for ModrinthCrawler {
fn get_repository_name(&self) -> String {
"Modrinth".to_string()
}
async fn search(&self, query: &str) -> Result<Vec<RepositoryPlugin>, String> {
let encoded_query = urlencoding::encode(query);
// Corrected format string with proper quoting for facets
let url = format!(
"{}/search?query={}&facets=[[\"project_type:plugin\"]]&limit=20",
self.api_base_url,
encoded_query
);
let response_body = match self.client.get(&url).await {
Ok(body) => body,
Err(e) => return Err(format!("Modrinth search request failed: {}", e)),
};
let search_response: ModrinthSearchResponse = match serde_json::from_str(&response_body) {
Ok(sr) => sr,
Err(e) => return Err(format!("Failed to parse Modrinth search results: {}", e)),
};
let mut results = Vec::new();
for hit in search_response.hits {
// For search results, we don't have version_number directly,
// so we need to fetch the latest version for each project
let version_info = if let Some(latest_version) = &hit.latest_version {
// If latest_version looks like an ID (no dots and long), fetch the real version number
if latest_version.len() >= 8 && !latest_version.contains('.') {
match self.get_project_versions_internal(&hit.project_id).await {
Ok(versions) if !versions.is_empty() => {
versions[0].version_number.clone()
},
_ => latest_version.clone()
}
} else {
latest_version.clone()
}
} else {
"Unknown".to_string()
};
// Create a simplified repository plugin from the search hit
let repo_plugin = RepositoryPlugin {
id: hit.project_id.clone(),
name: hit.title,
version: version_info,
description: Some(hit.description),
authors: vec![hit.author],
download_url: String::new(), // Will be filled in when getting specific version
repository: RepositorySource::Modrinth,
page_url: format!("https://modrinth.com/plugin/{}", hit.slug),
download_count: Some(hit.downloads),
last_updated: Some(hit.date_modified),
icon_url: hit.icon_url,
minecraft_versions: hit.versions,
categories: hit.categories,
rating: None,
file_size: None,
file_hash: None,
changelog: None,
loaders: Vec::new(),
supported_versions: Vec::new(),
};
results.push(repo_plugin);
}
Ok(results)
}
async fn get_plugin_details(&self, plugin_id: &str) -> Result<RepositoryPlugin, String> {
// Just delegate to our server-type aware version with None
self.get_plugin_details_with_server_type(plugin_id, None).await
}
async fn download_plugin(&self, plugin_id: &str, version_number_str: &str, destination: &Path) -> Result<String, String> {
// Just delegate to our server-type aware version with None
self.download_plugin_with_server_type(plugin_id, version_number_str, destination, None).await
}
}

View File

@ -0,0 +1,422 @@
use serde::Deserialize; // Added Serialize for potential use, Deserialize is essential
use std::error::Error;
use std::path::Path;
use crate::{HttpClient};
use crate::models::repository::{RepositoryPlugin, RepositorySource};
use urlencoding;
use async_trait::async_trait;
use std::sync::Arc;
use std::time::SystemTime; // For converting timestamp
use base64::{Engine as _, engine::general_purpose::STANDARD}; // Correct import with Engine trait
use serde_json::Value; // Import Value
use crate::crawlers::Repository; // Use the correct trait import
// --- Structs for SpiGet API Deserialization ---
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
struct SpiGetIcon {
url: Option<String>,
// data: Option<String>, // Base64 data not typically needed if URL is present
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
struct SpiGetRating {
average: f32,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
struct SpiGetAuthor {
id: u32,
name: Option<String>, // SpiGet might not always return name in all contexts
// icon: Option<SpiGetIcon>, // Icon data might be available in author details endpoint
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
struct SpiGetFile {
// #[serde(rename = "type")]
// file_type: Option<String>, // e.g. ".jar"
size: Option<Value>, // Use Value to accept string or number from API
size_unit: Option<String>, // e.g. "MB"
url: Option<String>, // Link to the spigotmc resource page, *not* a direct download
// externalUrl: Option<String> // Field from docs, maybe add if needed later
}
// Represents a version summary, often nested or in arrays
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
struct SpiGetVersion {
id: u32,
name: Option<String>, // Make optional: The actual version string e.g., "1.19.4" or "v2.1"
uuid: Option<String>,
release_date: Option<u64>, // Timestamp
downloads: Option<u32>,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
struct SpiGetUpdate {
description: String, // Base64 encoded HTML description
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
struct IdReference { // Used for arrays containing only IDs
id: u32,
}
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
struct SpiGetResource {
id: u32,
name: Option<String>, // Make name optional to handle potential missing field in search results
tag: Option<String>, // Make tag optional as well for robustness
version: SpiGetVersion, // Represents the *latest* version details here
author: SpiGetAuthor, // Contains author ID, maybe name
downloads: u32,
tested_versions: Vec<String>, // List of MC versions plugin is tested against
rating: SpiGetRating,
icon: SpiGetIcon, // Contains URL to icon
update_date: u64, // Timestamp
file: SpiGetFile, // Details about the main file download
external: bool, // If true, download link points externally
}
// --- SpigotMC Crawler Implementation (using SpiGet API) ---
pub struct SpigotMCCrawler {
client: Arc<HttpClient>,
base_url: String,
}
impl SpigotMCCrawler {
pub fn new() -> Self {
SpigotMCCrawler {
client: Arc::new(HttpClient::new()),
base_url: "https://api.spiget.org/v2".to_string(), // Use SpiGet API base URL
}
}
// Helper to convert SpiGetResource to RepositoryPlugin
fn map_resource_to_plugin(&self, resource: &SpiGetResource) -> RepositoryPlugin {
// Construct SpigotMC page URL
let page_url = format!("https://www.spigotmc.org/resources/{}", resource.id);
// Construct potential download URL (may differ for external resources)
let download_url = if resource.external {
// For external resources, the 'url' in file info is the download link
resource.file.url.clone().unwrap_or_default()
} else {
// For internal resources, use the SpiGet download endpoint for the *latest* version
format!("{}/resources/{}/download", self.base_url, resource.id)
};
// Format update date
let last_updated = SystemTime::UNIX_EPOCH
.checked_add(std::time::Duration::from_secs(resource.update_date))
.map(|_st| { // Remove chrono formatting for now
// Simple ISO 8601 format or similar - requires chrono for better formatting
// For now, just return the timestamp as string for simplicity
format!("{}", resource.update_date)
});
// Safely get author name
let author_name = resource.author.name.clone().unwrap_or_else(|| format!("#{}", resource.author.id));
// Convert file size, handling potential string in 'size' field
let file_size_bytes = resource.file.size.as_ref().and_then(|s| s.as_f64()).map(|s_num| {
match resource.file.size_unit.as_deref() {
Some("KB") => (s_num * 1024.0) as u64,
Some("MB") => (s_num * 1024.0 * 1024.0) as u64,
Some("GB") => (s_num * 1024.0 * 1024.0 * 1024.0) as u64,
_ => s_num as u64, // Assume bytes if unit is missing or unknown
}
});
// Prepend base URL to icon URL if it's relative (SpiGet usually provides full URL)
let icon_url = resource.icon.url.clone();
// Use id as fallback if name is missing
let plugin_name = resource.name.clone().unwrap_or_else(|| format!("Unnamed Resource #{}", resource.id));
// Extract version information properly
let version_name = resource.version.name.clone().unwrap_or_else(|| {
println!("[SpigotMCCrawler::map_resource_to_plugin] Warning: Missing version name for resource ID: {}", resource.id);
// DO NOT use tested Minecraft versions as fallbacks for plugin versions
// since they are different types of versions
"Unknown".to_string()
});
println!("[SpigotMCCrawler::map_resource_to_plugin] Version for {}: {}", plugin_name, version_name);
RepositoryPlugin {
id: resource.id.to_string(),
name: plugin_name, // Use the potentially fallback name
version: version_name, // Use the potentially fallback version name
description: resource.tag.clone(), // Use tagline as description (already Option<String>)
authors: vec![author_name],
download_url,
repository: RepositorySource::SpigotMC,
page_url,
download_count: Some(resource.downloads as u64),
last_updated,
icon_url, // Use the potentially prefixed URL
minecraft_versions: resource.tested_versions.clone(),
categories: Vec::new(), // SpiGet only gives category ID, fetching name requires another call
rating: Some(resource.rating.average),
file_size: file_size_bytes,
file_hash: None, // SpiGet does not provide hashes
changelog: None, // Needs separate call to /updates/latest
loaders: vec!["Spigot".to_string(), "Paper".to_string()], // SpigotMC resources typically work on Spigot and Paper
supported_versions: resource.tested_versions.clone(), // Same as minecraft_versions
}
}
// Helper to fetch and decode the latest update's description as changelog
async fn get_latest_changelog(&self, resource_id: &str) -> Result<Option<String>, Box<dyn Error + Send + Sync>> {
let url = format!("{}/resources/{}/updates/latest", self.base_url, resource_id);
match self.client.get(&url).await {
Ok(body) => {
match serde_json::from_str::<SpiGetUpdate>(&body) {
Ok(update) => {
// Description is Base64 encoded HTML
match STANDARD.decode(&update.description) {
Ok(decoded_bytes) => {
// Convert bytes to string (assuming UTF-8)
// decoded_bytes is now a Vec<u8>, which implements Sized
Ok(Some(String::from_utf8_lossy(&decoded_bytes).to_string()))
}
Err(e) => {
println!("Failed to decode base64 changelog for {}: {}", resource_id, e);
Ok(None) // Return None if decoding fails
}
}
}
Err(e) => {
println!("Failed to parse latest update JSON for {}: {}", resource_id, e);
Ok(None) // Return None if parsing fails
}
}
}
Err(e) => {
// If the request itself fails (e.g., 404 if no updates), treat as no changelog
println!("Failed to fetch latest update for {}: {}", resource_id, e);
Ok(None)
}
}
}
// Moved get_plugin_versions back here as it's not part of the Repository trait
async fn get_plugin_versions(&self, plugin_id: &str) -> Result<Vec<String>, String> {
println!("Fetching versions for resource ID: {}", plugin_id);
let url = format!("{}/resources/{}/versions?sort=-releaseDate&size=10", self.base_url, plugin_id);
let body = match self.client.get(&url).await {
Ok(b) => b,
Err(e) => return Err(format!("Failed SpiGet versions request: {}", e))
};
match serde_json::from_str::<Vec<SpiGetVersion>>(&body) {
Ok(versions) => {
let version_names: Vec<String> = versions.into_iter()
.filter_map(|v| v.name)
.collect();
if version_names.is_empty() {
// If no version names available, try to extract from the version ID
println!("No named versions found for resource {}. Trying alternate methods.", plugin_id);
// Try to get full resource details for version info
let resource_url = format!("{}/resources/{}", self.base_url, plugin_id);
match self.client.get(&resource_url).await {
Ok(resource_body) => {
match serde_json::from_str::<SpiGetResource>(&resource_body) {
Ok(resource) => {
// Check if we can get a version from the resource directly
if let Some(name) = resource.version.name {
println!("Found version from resource details: {}", name);
return Ok(vec![name]);
}
},
Err(e) => println!("Failed to parse resource details: {}", e)
}
},
Err(e) => println!("Failed to fetch resource details: {}", e)
}
// If still no version, return a fallback
println!("Using fallback version for resource ID: {}", plugin_id);
Ok(vec!["Unknown".to_string()])
} else {
println!("Found {} versions for resource ID: {}", version_names.len(), plugin_id);
Ok(version_names)
}
},
Err(e) => {
println!("Failed to parse versions JSON: {}. Body: {}", e, body);
Err(format!("Failed to parse SpiGet versions: {}", e))
}
}
}
}
#[async_trait]
impl Repository for SpigotMCCrawler {
fn get_repository_name(&self) -> String {
"SpigotMC".to_string()
}
async fn search(&self, query: &str) -> Result<Vec<RepositoryPlugin>, String> {
let encoded_query = urlencoding::encode(query);
let url = format!(
"{}/search/resources/{}?field=name&fields=name,tag,author,version,downloads,rating,icon,updateDate,premium,file,external,testedVersions",
self.base_url,
encoded_query
);
let body = match self.client.get(&url).await {
Ok(b) => b,
Err(e) => return Err(format!("Failed SpiGet search request: {}", e))
};
match serde_json::from_str::<Vec<SpiGetResource>>(&body) {
Ok(resources) => {
let mut results = Vec::new();
for res in &resources {
let mut plugin = self.map_resource_to_plugin(res);
// Try fetching versions if missing
if plugin.version == "Unknown" {
println!("Searching for version information for resource ID: {}", plugin.id);
match self.get_plugin_versions(&plugin.id).await {
Ok(versions) => {
if let Some(latest) = versions.first() {
println!("Found version for {}: {}", plugin.name, latest);
plugin.version = latest.clone();
}
},
Err(e) => println!("Failed to fetch versions for {}: {}", plugin.id, e),
}
}
results.push(plugin);
}
Ok(results)
}
Err(e) => {
// Handle case where search returns a single object instead of array (e.g., direct ID match?)
// Or just return the parsing error
Err(format!("Failed to parse SpiGet search results: {}. Body: {}", e, body))
}
}
}
async fn get_plugin_details(&self, plugin_id: &str) -> Result<RepositoryPlugin, String> {
let url = format!(
"{}/resources/{}?fields=name,tag,author,version,downloads,rating,icon,updateDate,premium,file,external,testedVersions",
self.base_url,
plugin_id
);
let body = match self.client.get(&url).await {
Ok(b) => b,
Err(e) => return Err(format!("Failed SpiGet details request: {}", e))
};
match serde_json::from_str::<SpiGetResource>(&body) {
Ok(resource) => {
let mut plugin = self.map_resource_to_plugin(&resource);
// Fetch changelog
match self.get_latest_changelog(&plugin.id).await {
Ok(changelog_opt) => plugin.changelog = changelog_opt,
Err(e) => println!("Failed to fetch changelog for {}: {}", plugin.id, e),
}
// Try fetching versions if missing
if plugin.version == "Unknown" {
println!("Fetching versions for detail view of resource ID: {}", plugin.id);
match self.get_plugin_versions(&plugin.id).await {
Ok(versions) => {
if let Some(latest) = versions.first() {
println!("Found version for {} in detail view: {}", plugin.name, latest);
plugin.version = latest.clone();
}
},
Err(e) => println!("Failed to fetch versions for {} in detail view: {}", plugin.id, e),
}
}
Ok(plugin)
}
Err(e) => Err(format!("Failed to parse SpiGet resource details: {}. Body: {}", e, body))
}
}
async fn download_plugin(&self, plugin_id: &str, version: &str, destination: &Path) -> Result<String, String> {
// First, get the plugin details
let details = self.get_plugin_details(plugin_id).await?;
// Get the plugin page URL for potential manual download instructions
let plugin_page_url = details.page_url.clone();
// Use the SpigotMC direct download URL instead of SpiGet
// SpigotMC has a direct download URL pattern for resources
let direct_download_url = format!("https://www.spigotmc.org/resources/{}/download", plugin_id);
// Log the download attempt
println!("Attempting to download plugin from SpigotMC direct URL: {}", direct_download_url);
println!("Requested version: {} (Note: SpigotMC usually provides latest version only)", version);
// Try to download using direct SpigotMC URL
match self.client.download(&direct_download_url, destination).await {
Ok(_) => {
println!("Successfully downloaded plugin from SpigotMC direct URL");
Ok(destination.to_string_lossy().to_string())
},
Err(e) => {
// If direct download fails with 403, it might be a premium resource
let error_message = format!("{}", e);
if error_message.contains("403 Forbidden") {
println!("Plugin appears to be a premium/protected resource on SpigotMC");
// Try the SpiGet URL as a fallback just to be sure
let download_url = &details.download_url;
if download_url.is_empty() {
return Err(format!("PREMIUM_RESOURCE:{}", plugin_page_url));
}
println!("Falling back to SpiGet download URL: {}", download_url);
match self.client.download(download_url, destination).await {
Ok(_) => Ok(destination.to_string_lossy().to_string()),
Err(spiget_err) => {
// If both methods fail, this is very likely a premium plugin
if format!("{}", spiget_err).contains("404 Not Found") {
println!("Confirmed premium resource - both direct and SpiGet downloads failed");
Err(format!("PREMIUM_RESOURCE:{}", plugin_page_url))
} else {
Err(format!("Failed to download from SpiGet: {}", spiget_err))
}
}
}
} else {
// For other errors with direct download, try SpiGet
println!("Direct SpigotMC download failed: {}. Trying SpiGet URL as fallback...", e);
let download_url = &details.download_url;
if download_url.is_empty() {
return Err(format!("No download URL found for SpigotMC resource {}", plugin_id));
}
println!("Falling back to SpiGet download URL: {}", download_url);
match self.client.download(download_url, destination).await {
Ok(_) => Ok(destination.to_string_lossy().to_string()),
Err(e) => Err(format!("Failed to download from SpiGet: {}", e))
}
}
}
}
}
}

File diff suppressed because it is too large Load Diff

117
src-tauri/src/lib.rs.bak Normal file
View File

@ -0,0 +1,117 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
// Standard library imports
use std::error::Error;
use std::fs::{self, File};
use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::env;
use std::sync::Arc;
use std::time::Duration;
// Serde for serialization/deserialization
use serde::{Serialize, Deserialize};
// Tauri related imports
use tauri::{command, Emitter, AppHandle, Manager, State, Window};
// Internal modules
pub mod models;
pub mod services;
pub mod commands;
pub mod crawlers;
pub mod platform_matcher;
// Import our models
pub use models::server::{ServerType, ServerInfo, ScanResult, ScanProgress};
pub use models::plugin::{Plugin, PluginMeta};
pub use models::repository::{RepositorySource, RepositoryPlugin, PotentialPluginMatch};
// Import our services
pub use services::http::HttpClient;
pub use services::plugin_scanner::{scan_server_directory, perform_scan, extract_plugin_metadata, calculate_file_hash, is_file_locked};
pub use services::update_manager::{check_for_plugin_updates, check_single_plugin_update, backup_plugin, replace_plugin, normalize_version, compare_plugin_versions};
// Import our commands
pub use commands::plugin_commands::*;
pub use commands::scan_commands::*;
// Import our crawlers
pub use crawlers::HangarCrawler;
pub use crawlers::SpigotMCCrawler;
pub use crawlers::ModrinthCrawler;
pub use crawlers::GitHubCrawler;
// Import platform matchers
pub use platform_matcher::{get_compatible_modrinth_loaders, is_version_compatible_with_server};
/// Search for plugins in specified repositories
pub async fn lib_search_plugins_in_repositories(query: &str, repositories: Vec<RepositorySource>) -> Result<Vec<RepositoryPlugin>, String> {
// Implementation details to be moved from original lib.rs
Ok(Vec::new()) // Placeholder
}
/// Get plugin details from a repository
pub async fn lib_get_plugin_details_from_repository(
plugin_id: &str,
repository: RepositorySource,
server_type: Option<&ServerType>
) -> Result<RepositoryPlugin, String> {
// Implementation details to be moved from original lib.rs
Ok(RepositoryPlugin {
id: plugin_id.to_string(),
name: "Example Plugin".to_string(),
version: "1.0.0".to_string(),
description: Some("This is a placeholder".to_string()),
authors: vec!["Example Author".to_string()],
download_url: "https://example.com".to_string(),
repository: repository,
page_url: "https://example.com".to_string(),
download_count: Some(0),
last_updated: Some("2023-01-01".to_string()),
icon_url: None,
minecraft_versions: vec!["1.19.2".to_string()],
categories: vec![],
rating: None,
file_size: None,
file_hash: None,
changelog: None,
}) // Placeholder
}
/// Download a plugin from a repository
pub async fn lib_download_plugin_from_repository(
plugin_id: &str,
version: &str,
repository: RepositorySource,
destination: &str,
server_type: Option<&ServerType>
) -> Result<String, String> {
// Implementation details to be moved from original lib.rs
Ok(destination.to_string()) // Placeholder
}
/// Search for plugin variations
pub async fn search_with_variations(plugin_name: &str, repositories: &[RepositorySource]) -> Result<Vec<RepositoryPlugin>, String> {
// Implementation details to be moved from original lib.rs
Ok(Vec::new()) // Placeholder
}
/// Configure and run the Tauri application
pub fn run() {
// Build the Tauri application
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
// Plugin discovery commands
scan_server_dir,
scan_server_dir_sync,
// Plugin repository commands
search_plugins,
get_plugin_details,
// Other commands to be added
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
plugsnatcher_lib::run()
app_lib::run();
}

View File

@ -0,0 +1,7 @@
pub mod plugin;
pub mod server;
pub mod repository;
pub use plugin::{Plugin, PluginMeta};
pub use server::{ServerInfo, ServerType, ScanResult, ScanProgress};
pub use repository::{RepositorySource, RepositoryPlugin, PotentialPluginMatch};

View File

@ -0,0 +1,61 @@
use serde::{Serialize, Deserialize};
use super::repository::RepositorySource;
/// Enum representing plugin compatibility status with a server
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum PluginCompatibilityStatus {
Compatible,
IncompatibleLoader,
IncompatibleMinecraftVersion,
Unknown
}
/// Represents a Minecraft plugin with detailed information
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Plugin {
pub name: String,
pub version: String,
pub latest_version: Option<String>,
pub description: Option<String>,
pub authors: Vec<String>,
pub website: Option<String>,
pub has_update: bool,
pub api_version: Option<String>,
pub main_class: Option<String>,
pub depend: Option<Vec<String>>,
pub soft_depend: Option<Vec<String>>,
pub load_before: Option<Vec<String>>,
pub commands: Option<serde_json::Value>,
pub permissions: Option<serde_json::Value>,
pub file_path: String,
pub file_hash: String,
pub changelog: Option<String>, // Changelog for the latest version
// Compatibility fields
pub compatibility_status: Option<PluginCompatibilityStatus>,
pub platform_compatibility: Option<Vec<String>>, // List of compatible platforms/loaders
// Fields for persistence
pub repository_source: Option<RepositorySource>,
pub repository_id: Option<String>,
pub repository_url: Option<String>, // URL to the plugin page on the repository
}
/// Raw metadata extracted from a plugin.yml file
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PluginMeta {
pub name: String,
pub version: String,
pub description: Option<String>,
pub authors: Vec<String>,
pub website: Option<String>,
pub api_version: Option<String>,
pub main_class: Option<String>,
pub depend: Option<Vec<String>>,
pub soft_depend: Option<Vec<String>>,
pub load_before: Option<Vec<String>>,
pub commands: Option<serde_json::Value>,
pub permissions: Option<serde_json::Value>,
pub file_path: String,
pub file_size: u64,
pub file_hash: String,
}

View File

@ -0,0 +1,97 @@
use serde::{Serialize, Deserialize};
use std::error::Error;
use std::path::Path;
use std::any::Any;
/// Represents a source of plugins
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum RepositorySource {
HangarMC,
SpigotMC,
Modrinth,
GitHub,
BukkitDev,
Custom(String),
}
/// Represents a plugin from a repository
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RepositoryPlugin {
pub id: String, // Unique identifier in the repository
pub name: String, // Plugin name
pub version: String, // Latest version
pub description: Option<String>,
pub authors: Vec<String>,
pub download_url: String, // URL to download the plugin
pub repository: RepositorySource,
pub page_url: String, // URL to the plugin page
pub download_count: Option<u64>,
pub last_updated: Option<String>,
pub icon_url: Option<String>,
pub minecraft_versions: Vec<String>,
pub categories: Vec<String>,
pub rating: Option<f32>,
pub file_size: Option<u64>,
pub file_hash: Option<String>,
pub changelog: Option<String>, // Changelog information for latest version
pub loaders: Vec<String>, // Platforms/loaders this plugin supports (Paper, Spigot, etc.)
pub supported_versions: Vec<String>, // Minecraft versions this plugin supports
}
/// Trait for crawler implementors with object safety
pub trait RepositoryCrawlerBase {
fn as_any(&self) -> &dyn Any;
fn get_repository_name(&self) -> RepositorySource;
}
/// Repository crawler search functionality
pub trait RepositoryCrawlerSearch: RepositoryCrawlerBase {
fn search<'a>(&'a self, query: &'a str) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<RepositoryPlugin>, Box<dyn Error + Send + Sync>>> + Send + 'a>>;
}
/// Repository crawler details functionality
pub trait RepositoryCrawlerDetails: RepositoryCrawlerBase {
fn get_plugin_details<'a>(&'a self, plugin_id: &'a str) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<RepositoryPlugin, Box<dyn Error + Send + Sync>>> + Send + 'a>>;
}
/// Repository crawler versions functionality
pub trait RepositoryCrawlerVersions: RepositoryCrawlerBase {
fn get_plugin_versions<'a>(&'a self, plugin_id: &'a str) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<String>, Box<dyn Error + Send + Sync>>> + Send + 'a>>;
}
/// Repository crawler download functionality
pub trait RepositoryCrawlerDownload: RepositoryCrawlerBase {
fn download_plugin<'a>(&'a self, plugin_id: &'a str, version: &'a str, destination: &'a Path) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, Box<dyn Error + Send + Sync>>> + Send + 'a>>;
}
/// Represents a potential match from repositories for an installed plugin
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PotentialPluginMatch {
pub name: String,
pub version: String,
pub repository: RepositorySource,
pub repository_id: String,
pub page_url: String,
pub description: Option<String>,
pub minecraft_versions: Vec<String>,
pub download_count: Option<u64>,
}
/// Represents the result of a single plugin update operation
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SingleUpdateResult {
pub original_file_path: String,
pub plugin: Option<Plugin>, // None if error occurred
pub error: Option<String>,
}
/// Represents the progress of bulk plugin updates
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BulkUpdateProgress {
pub processed: usize,
pub total: usize,
pub current_plugin_name: String,
}
// Import required for SingleUpdateResult
use super::plugin::Plugin;

View File

@ -0,0 +1,42 @@
use serde::{Serialize, Deserialize};
use super::plugin::Plugin;
/// Represents the type of Minecraft server
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum ServerType {
Paper,
Spigot,
Bukkit,
Vanilla,
Forge,
Fabric,
Velocity,
BungeeCord,
Waterfall,
Unknown,
}
/// Contains information about a Minecraft server
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ServerInfo {
pub server_type: ServerType,
pub minecraft_version: Option<String>,
pub plugins_directory: String,
pub plugins_count: usize,
}
/// Result of a server scan operation
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ScanResult {
pub server_info: ServerInfo,
pub plugins: Vec<Plugin>,
}
/// Progress information during a server scan
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ScanProgress {
pub processed: usize,
pub total: usize,
pub current_file: String,
}

View File

@ -0,0 +1,117 @@
use crate::ServerType;
// Known platform/loader mappings
pub enum PlatformLoader {
Bukkit, // Bukkit API
Spigot, // Spigot API (Bukkit compatible)
Paper, // Paper API (Spigot compatible)
Forge, // Forge API
NeoForge, // NeoForge API (Forge fork)
Fabric, // Fabric API
Quilt, // Quilt API (Fabric compatible)
Velocity, // Velocity proxy
BungeeCord, // BungeeCord proxy
Waterfall, // Waterfall proxy (BungeeCord fork)
Sponge, // Sponge API
Unknown, // Unknown platform
}
// Maps the ServerType to a PlatformLoader
pub fn server_type_to_platform_loader(server_type: &ServerType) -> PlatformLoader {
match server_type {
ServerType::Paper => PlatformLoader::Paper,
ServerType::Spigot => PlatformLoader::Spigot,
ServerType::Bukkit => PlatformLoader::Bukkit,
ServerType::Forge => PlatformLoader::Forge,
ServerType::Fabric => PlatformLoader::Fabric,
ServerType::Velocity => PlatformLoader::Velocity,
ServerType::BungeeCord => PlatformLoader::BungeeCord,
ServerType::Waterfall => PlatformLoader::Waterfall,
ServerType::Vanilla => PlatformLoader::Unknown, // No specific loader for vanilla
ServerType::Unknown => PlatformLoader::Unknown,
}
}
// Standard Modrinth loader strings
pub fn get_modrinth_loader_strings(platform: &PlatformLoader) -> Vec<String> {
match platform {
PlatformLoader::Bukkit => vec!["bukkit".to_string(), "spigot".to_string(), "paper".to_string()],
PlatformLoader::Spigot => vec!["spigot".to_string(), "paper".to_string()],
PlatformLoader::Paper => vec!["paper".to_string()],
PlatformLoader::Forge => vec!["forge".to_string()],
PlatformLoader::NeoForge => vec!["neoforge".to_string()],
PlatformLoader::Fabric => vec!["fabric".to_string()],
PlatformLoader::Quilt => vec!["quilt".to_string(), "fabric".to_string()],
PlatformLoader::Velocity => vec!["velocity".to_string()],
PlatformLoader::BungeeCord => vec!["bungeecord".to_string(), "waterfall".to_string()],
PlatformLoader::Waterfall => vec!["waterfall".to_string()],
PlatformLoader::Sponge => vec!["sponge".to_string()],
PlatformLoader::Unknown => vec![], // No specific loader strings
}
}
// Compatible Modrinth loader strings (what the server can load)
pub fn get_compatible_modrinth_loaders(server_type: &ServerType) -> Vec<String> {
let platform = server_type_to_platform_loader(server_type);
match server_type {
ServerType::Paper => {
// Paper can load Paper, Spigot, and Bukkit plugins
vec!["paper".to_string(), "spigot".to_string(), "bukkit".to_string()]
},
ServerType::Spigot => {
// Spigot can load Spigot and Bukkit plugins
vec!["spigot".to_string(), "bukkit".to_string()]
},
ServerType::Bukkit => {
// Bukkit can only load Bukkit plugins
vec!["bukkit".to_string()]
},
ServerType::Forge => {
// Forge can load Forge plugins (and maybe Sponge)
vec!["forge".to_string()]
},
ServerType::Fabric => {
// Fabric can load Fabric plugins
vec!["fabric".to_string()]
},
ServerType::Velocity => {
// Velocity proxy
vec!["velocity".to_string()]
},
ServerType::BungeeCord => {
// BungeeCord can load BungeeCord plugins
vec!["bungeecord".to_string()]
},
ServerType::Waterfall => {
// Waterfall can load Waterfall and BungeeCord plugins
vec!["waterfall".to_string(), "bungeecord".to_string()]
},
_ => {
// For unknown server types, return an empty list
vec![]
}
}
}
// Check if a version's loaders are compatible with the server type
pub fn is_version_compatible_with_server(version_loaders: &Vec<String>, server_type: &ServerType) -> bool {
// If no loaders specified, it's possibly a universal plugin, consider it compatible
if version_loaders.is_empty() {
return true;
}
let compatible_loaders = get_compatible_modrinth_loaders(server_type);
// If we don't know compatible loaders for this server type, be conservative and return false
if compatible_loaders.is_empty() {
return false;
}
// Check if any loader in the version matches any compatible loader
version_loaders.iter().any(|loader| {
compatible_loaders.iter().any(|compatible|
loader.to_lowercase() == compatible.to_lowercase()
)
})
}

View File

@ -0,0 +1,212 @@
use std::env;
use std::error::Error;
use std::path::Path;
use std::time::Duration;
use tokio::time::sleep;
use cached::proc_macro::cached;
use reqwest;
use reqwest::header::{
HeaderMap, HeaderValue, USER_AGENT, AUTHORIZATION, ACCEPT, ACCEPT_LANGUAGE, CONNECTION, RETRY_AFTER,
};
use reqwest::StatusCode;
/// HTTP Client for making requests to external services
pub struct HttpClient {
client: reqwest::Client,
github_token: Option<String>,
}
/// Cache HTTP GET requests to avoid hitting rate limits
#[cached(
time = 3600, // Cache for 1 hour
size = 100, // Maximum number of cached responses
key = "String",
convert = r#"{ url.clone() }"#,
result = true
)]
async fn cached_http_get(url: String, client: reqwest::Client, token: Option<String>) -> Result<String, Box<dyn Error + Send + Sync>> {
const MAX_RETRIES: u32 = 3;
const BASE_RETRY_DELAY_MS: u64 = 1000;
let mut headers = HeaderMap::new();
// Set common headers for all requests
headers.insert(USER_AGENT, HeaderValue::from_static("PlugSnatcherApp/0.1.0"));
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.5"));
headers.insert(CONNECTION, HeaderValue::from_static("keep-alive"));
// Add authorization header if token is provided
if let Some(token_value) = token {
if url.contains("github.com") {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("token {}", token_value)).unwrap_or_else(|_| HeaderValue::from_static("")),
);
}
}
let mut retry_count = 0;
let mut retry_delay = BASE_RETRY_DELAY_MS;
loop {
let response = client
.get(&url)
.headers(headers.clone())
.send()
.await;
match response {
Ok(resp) => {
// Handle rate limiting
if resp.status() == StatusCode::TOO_MANY_REQUESTS {
if retry_count >= MAX_RETRIES {
return Err(format!("Rate limit exceeded for {}", url).into());
}
// Check for Retry-After header or use exponential backoff
let retry_after = resp.headers()
.get(RETRY_AFTER)
.and_then(|val| val.to_str().ok())
.and_then(|val| val.parse::<u64>().ok())
.map(|secs| secs * 1000) // Convert header seconds to ms
.unwrap_or_else(|| {
// If no Retry-After header, just use the current exponential delay
retry_delay
});
// Exponential backoff calculation for the *next* potential retry
retry_delay *= 2;
println!("Rate limited for {}. Retrying after {} ms...", url, retry_after);
sleep(Duration::from_millis(retry_after)).await;
retry_count += 1;
continue;
}
// Handle other responses
if resp.status().is_success() {
return Ok(resp.text().await?);
} else {
return Err(format!(
"Request to {} failed with status code: {}",
url,
resp.status()
).into());
}
},
Err(err) => {
if retry_count >= MAX_RETRIES {
return Err(Box::new(err));
}
sleep(Duration::from_millis(retry_delay)).await;
retry_delay *= 2;
retry_count += 1;
}
}
}
}
/// Helper function to parse Modrinth's rate limit format
fn parse_modrinth_ratelimit(error_body: &str) -> Option<u64> {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(error_body) {
if let Some(retry_after) = json.get("retry_after") {
if let Some(seconds) = retry_after.as_u64() {
return Some(seconds * 1000);
}
}
}
None
}
impl HttpClient {
/// Create a new HTTP client
pub fn new() -> Self {
let client = reqwest::ClientBuilder::new()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(5))
.pool_idle_timeout(Duration::from_secs(90))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
// Try to get GitHub token from environment variable
let github_token = match env::var("GITHUB_API_TOKEN") {
Ok(token) if !token.is_empty() => Some(token),
_ => None,
};
HttpClient {
client,
github_token,
}
}
/// Perform an HTTP GET request
pub async fn get(&self, url: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
cached_http_get(url.to_string(), self.client.clone(), self.github_token.clone()).await
}
/// Download a file from a URL to the specified destination
pub async fn download(&self, url: &str, destination: &Path) -> Result<(), Box<dyn Error + Send + Sync>> {
// Create a client with a larger timeout for downloads
let client = reqwest::ClientBuilder::new()
.timeout(Duration::from_secs(180)) // Longer timeout for downloads
.build()?;
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36")); // More browser-like
// Add Accept header for everything
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
// Add headers for better browser simulation when dealing with SpigotMC
if url.contains("spigotmc.org") {
headers.insert(ACCEPT, HeaderValue::from_static("text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"));
// Add custom headers as strings
let _ = headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("document"));
let _ = headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate"));
let _ = headers.insert("Sec-Fetch-Site", HeaderValue::from_static("none"));
let _ = headers.insert("Sec-Fetch-User", HeaderValue::from_static("?1"));
headers.insert(reqwest::header::UPGRADE_INSECURE_REQUESTS, HeaderValue::from_static("1"));
println!("Added browser simulation headers for SpigotMC download");
}
// Add GitHub token if URL is GitHub and we have a token
if url.contains("github.com") && self.github_token.is_some() {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("token {}", self.github_token.as_ref().unwrap()))
.unwrap_or_else(|_| HeaderValue::from_static("")),
);
}
// Get response
println!("Sending download request to: {}", url);
let response = client.get(url).headers(headers).send().await?;
// Log response status
println!("Download response status: {}", response.status());
// Check if request was successful
if !response.status().is_success() {
return Err(format!("Failed to download: Status {}", response.status()).into());
}
// Get response bytes
let bytes = response.bytes().await?;
println!("Downloaded {} bytes", bytes.len());
// Create parent directories if needed
if let Some(parent) = destination.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
}
}
// Write to file
std::fs::write(destination, bytes)?;
println!("Successfully wrote file to: {}", destination.display());
Ok(())
}
}

View File

@ -0,0 +1,3 @@
mod client;
pub use client::HttpClient;

View File

@ -0,0 +1,8 @@
pub mod http;
pub mod plugin_scanner;
pub mod update_manager;
// Re-export important services
pub use http::HttpClient;
pub use plugin_scanner::{scan_server_directory, perform_scan, extract_plugin_metadata, calculate_file_hash, is_file_locked};
pub use update_manager::{check_for_plugin_updates, check_single_plugin_update, backup_plugin, replace_plugin, normalize_version, compare_plugin_versions};

View File

@ -0,0 +1,58 @@
use std::fs::{self, File};
use std::io::Read;
use std::path::Path;
use sha2::{Sha256, Digest};
/// Calculate SHA-256 hash of a file for identification and verification
pub fn calculate_file_hash(file_path: &str) -> Result<String, String> {
// Open the file
let mut file = match File::open(file_path) {
Ok(file) => file,
Err(e) => return Err(format!("Failed to open file for hashing: {}", e)),
};
// Read the file content
let mut buffer = Vec::new();
if let Err(e) = file.read_to_end(&mut buffer) {
return Err(format!("Failed to read file for hashing: {}", e));
}
// Calculate the SHA-256 hash
let mut hasher = Sha256::new();
hasher.update(&buffer);
let result = hasher.finalize();
// Convert the hash to a hex string
let hash_string = format!("{:x}", result);
Ok(hash_string)
}
/// Check if a file is currently locked by another process
pub fn is_file_locked(file_path: &str) -> bool {
// Try to open the file with write permissions to check if locked
match fs::OpenOptions::new()
.write(true)
.open(file_path)
{
Ok(_) => false, // File can be opened for writing, so it's not locked
Err(_) => {
// If we can't open for writing, try to check if it exists
// This helps determine if the error is due to file being locked
// or just not existing
Path::new(file_path).exists()
}
}
}
/// Read YAML content from a ZIP archive
pub fn read_yaml_from_archive(archive: &mut zip::ZipArchive<fs::File>, file_name: &str) -> Result<String, String> {
match archive.by_name(file_name) {
Ok(mut file) => {
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|e| format!("Failed to read {}: {}", file_name, e))?;
Ok(contents)
},
Err(e) => Err(format!("Failed to find {}: {}", file_name, e))
}
}

View File

@ -0,0 +1,219 @@
use std::fs;
use std::path::Path;
use yaml_rust::{YamlLoader, Yaml};
use zip::ZipArchive;
use crate::models::plugin::PluginMeta;
use super::file_utils::{calculate_file_hash, read_yaml_from_archive};
/// Extract metadata from a plugin JAR file
pub fn extract_plugin_metadata(jar_path: &Path) -> Result<PluginMeta, String> {
// Get the file size
let metadata = match fs::metadata(jar_path) {
Ok(meta) => meta,
Err(e) => return Err(format!("Failed to get file metadata: {}", e)),
};
let file_size = metadata.len();
// Open the JAR file
let file = match fs::File::open(jar_path) {
Ok(file) => file,
Err(e) => return Err(format!("Failed to open JAR file: {}", e)),
};
// Create a ZIP archive reader
let mut archive = match ZipArchive::new(file) {
Ok(archive) => archive,
Err(e) => return Err(format!("Failed to read JAR as ZIP archive: {}", e)),
};
// Try to read plugin.yml
let yaml_content = match read_yaml_from_archive(&mut archive, "plugin.yml") {
Ok(content) => content,
Err(_) => {
// If plugin.yml is not found, try bungee.yml for BungeeCord plugins
match read_yaml_from_archive(&mut archive, "bungee.yml") {
Ok(content) => content,
Err(_) => {
// If neither is found, use fallback metadata
return fallback_plugin_meta(jar_path, file_size);
}
}
}
};
// Parse the YAML content
let docs = match YamlLoader::load_from_str(&yaml_content) {
Ok(docs) => docs,
Err(e) => return Err(format!("Failed to parse plugin.yml: {}", e)),
};
// If there's no document in the YAML, use fallback
if docs.is_empty() {
return fallback_plugin_meta(jar_path, file_size);
}
let doc = &docs[0];
// Extract plugin information
let name = yaml_str_with_fallback(doc, "name", jar_path);
let version = yaml_str_with_fallback(doc, "version", jar_path);
let description = yaml_str_opt(doc, "description");
let website = yaml_str_opt(doc, "website");
let api_version = yaml_str_opt(doc, "api-version");
let main_class = yaml_str_opt(doc, "main");
// Extract author/authors
let authors = match &doc["author"] {
Yaml::String(author) => vec![author.clone()],
_ => match &doc["authors"] {
Yaml::Array(authors) => authors
.iter()
.filter_map(|a| match a {
Yaml::String(s) => Some(s.clone()),
_ => None,
})
.collect(),
Yaml::String(author) => vec![author.clone()],
_ => Vec::new(),
},
};
// Extract dependencies
let depend = yaml_str_array(doc, "depend");
let soft_depend = yaml_str_array(doc, "softdepend");
let load_before = yaml_str_array(doc, "loadbefore");
// Extract commands and permissions
let commands = match &doc["commands"] {
Yaml::Hash(_) => {
Some(serde_json::Value::String("Commands data present".to_string()))
},
_ => None
};
let permissions = match &doc["permissions"] {
Yaml::Hash(_) => {
Some(serde_json::Value::String("Permissions data present".to_string()))
},
_ => None
};
// Calculate the file hash
let file_hash = calculate_file_hash(jar_path.to_str().unwrap_or("unknown.jar")).unwrap_or_else(|_| "unknown".to_string());
Ok(PluginMeta {
name,
version,
description,
authors,
api_version,
main_class,
depend,
soft_depend,
load_before,
commands,
permissions,
file_path: jar_path.to_string_lossy().to_string(),
file_size,
file_hash,
website,
})
}
/// Create plugin metadata with fallback values
fn fallback_plugin_meta(jar_path: &Path, file_size: u64) -> Result<PluginMeta, String> {
let filename = jar_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown.jar");
// Extract name and version from filename (e.g., "WorldEdit-7.2.8.jar" → name: "WorldEdit", version: "7.2.8")
let mut parts: Vec<&str> = filename.trim_end_matches(".jar").split('-').collect();
let version = if parts.len() > 1 {
parts.pop().unwrap_or("1.0.0").to_string()
} else {
"1.0.0".to_string()
};
let name = parts.join("-");
// Calculate hash
let file_hash = calculate_file_hash(jar_path.to_str().unwrap_or("unknown.jar")).unwrap_or_else(|_| "unknown".to_string());
Ok(PluginMeta {
name,
version,
description: None,
authors: Vec::new(),
website: None,
api_version: None,
main_class: None,
depend: None,
soft_depend: None,
load_before: None,
commands: None,
permissions: None,
file_path: jar_path.to_string_lossy().to_string(),
file_size,
file_hash,
})
}
/// Helper function to extract a string with fallback
fn yaml_str_with_fallback(yaml: &Yaml, key: &str, jar_path: &Path) -> String {
match yaml[key] {
Yaml::String(ref s) => s.clone(),
_ => {
// Fallback to the JAR filename without extension
let filename = jar_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.trim_end_matches(".jar");
if key == "name" {
let name_parts: Vec<&str> = filename.split('-').collect();
name_parts[0].to_string()
} else if key == "version" {
let version_parts: Vec<&str> = filename.split('-').collect();
if version_parts.len() > 1 {
version_parts[1].to_string()
} else {
"1.0.0".to_string()
}
} else {
"".to_string()
}
}
}
}
/// Helper function to extract an optional string
fn yaml_str_opt(yaml: &Yaml, key: &str) -> Option<String> {
match &yaml[key] {
Yaml::String(s) => Some(s.clone()),
_ => None,
}
}
/// Helper function to extract an array of strings
fn yaml_str_array(yaml: &Yaml, key: &str) -> Option<Vec<String>> {
match &yaml[key] {
Yaml::Array(arr) => {
let string_arr: Vec<String> = arr
.iter()
.filter_map(|item| match item {
Yaml::String(s) => Some(s.clone()),
_ => None,
})
.collect();
if string_arr.is_empty() {
None
} else {
Some(string_arr)
}
},
_ => None,
}
}

View File

@ -0,0 +1,7 @@
mod scanner;
mod metadata_extractor;
mod file_utils;
pub use scanner::{scan_server_directory, perform_scan, get_plugin_data_path};
pub use metadata_extractor::extract_plugin_metadata;
pub use file_utils::{calculate_file_hash, is_file_locked};

View File

@ -0,0 +1,411 @@
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Emitter};
use walkdir::WalkDir;
use regex::Regex;
use std::ffi::OsStr;
use std::io::Read;
use crate::models::server::{ServerType, ServerInfo, ScanResult, ScanProgress};
use crate::models::plugin::Plugin;
use super::metadata_extractor::extract_plugin_metadata;
/// Scan a server directory and emit progress events
pub async fn scan_server_directory(app_handle: AppHandle, path: String) -> Result<(), String> {
// Get the main window
let window = app_handle.get_webview_window("main").ok_or("Main window not found")?;
println!("Starting scan for server directory: {}", path);
// Start the scan
match window.emit("scan_started", {}) {
Ok(_) => println!("Emitted scan_started event successfully"),
Err(e) => {
let err_msg = format!("Failed to emit scan_started event: {}", e);
println!("{}", err_msg);
return Err(err_msg);
}
}
// Perform the actual scan in a separate function
match perform_scan(&app_handle, &path).await {
Ok(result) => {
println!("Scan completed successfully. Found {} plugins", result.plugins.len());
// Save the scan result to disk
match save_plugin_data(app_handle.clone(), result.plugins.clone(), path.clone()).await {
Ok(_) => println!("Saved plugin data to disk successfully"),
Err(e) => println!("Failed to save plugin data: {}", e)
}
// Emit scan completion event with the result
println!("Emitting scan_completed event with {} plugins", result.plugins.len());
match window.emit("scan_completed", result.clone()) {
Ok(_) => {
println!("Emitted scan_completed event successfully");
Ok(())
},
Err(e) => {
let err_msg = format!("Failed to emit scan_completed event: {}", e);
println!("{}", err_msg);
Err(err_msg)
}
}
},
Err(e) => {
println!("Scan failed with error: {}", e);
// Emit scan error event
match window.emit("scan_error", e.clone()) {
Ok(_) => println!("Emitted scan_error event successfully"),
Err(err) => println!("Failed to emit scan_error event: {}", err)
}
Err(e)
}
}
}
/// Perform a scan of the server directory
pub async fn perform_scan(app_handle: &AppHandle, path: &str) -> Result<ScanResult, String> {
// Normalize the path and check if it exists
let server_path = Path::new(path);
if !server_path.exists() {
return Err(format!("Server directory not found: {}", path));
}
// Detect server type
let server_type = detect_server_type(server_path);
// Find Minecraft version
let minecraft_version = detect_minecraft_version(server_path, &server_type);
// Find plugins directory
let plugins_dir = get_plugins_directory(server_path, &server_type);
let plugins_path = Path::new(&plugins_dir);
// Check if plugins directory exists
if !plugins_path.exists() {
return Err(format!("Plugins directory not found: {}", plugins_dir));
}
// Get all JAR files in plugins directory
let mut plugin_files = Vec::new();
for entry in WalkDir::new(plugins_path)
.max_depth(1) // Only scan the top level
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_file() && path.extension() == Some(OsStr::new("jar")) {
plugin_files.push(path.to_path_buf());
}
}
// Create server info object
let server_info = ServerInfo {
server_type,
minecraft_version,
plugins_directory: plugins_dir,
plugins_count: plugin_files.len(),
};
// Emit total plugin count
app_handle.emit("scan_progress", ScanProgress {
processed: 0,
total: plugin_files.len(),
current_file: "Starting scan...".to_string(),
}).map_err(|e| e.to_string())?;
// Process each plugin
let mut plugins = Vec::new();
for (index, jar_path) in plugin_files.iter().enumerate() {
// Emit progress update
let file_name = jar_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown.jar");
app_handle.emit("scan_progress", ScanProgress {
processed: index,
total: plugin_files.len(),
current_file: file_name.to_string(),
}).map_err(|e| e.to_string())?;
// Extract plugin metadata
match extract_plugin_metadata(jar_path) {
Ok(meta) => {
// Convert PluginMeta to Plugin
let plugin = Plugin {
name: meta.name,
version: meta.version,
latest_version: None, // Will be populated during update check
description: meta.description,
authors: meta.authors,
website: meta.website,
has_update: false, // Will be populated during update check
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,
changelog: None, // Will be populated during update check
compatibility_status: None, // Will be populated during update check
platform_compatibility: None, // Will be populated during update check
repository_source: None, // Will be populated during update check
repository_id: None, // Will be populated during update check
repository_url: None, // Will be populated during update check
};
plugins.push(plugin);
},
Err(e) => {
// Log the error but continue processing other plugins
eprintln!("Error extracting metadata from {}: {}", file_name, e);
}
}
}
// Emit final progress
app_handle.emit("scan_progress", ScanProgress {
processed: plugin_files.len(),
total: plugin_files.len(),
current_file: "Scan complete".to_string(),
}).map_err(|e| e.to_string())?;
// Return the result
Ok(ScanResult {
server_info,
plugins,
})
}
/// Detect the type of Minecraft server
fn detect_server_type(server_path: &Path) -> ServerType {
// Check for server JAR file
if let Some(jar_path) = find_server_jar(server_path) {
let jar_filename = jar_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
// Convert filename to lowercase for easier matching
let filename_lower = jar_filename.to_lowercase();
// Check for known server types
if filename_lower.contains("paper") {
return ServerType::Paper;
} else if filename_lower.contains("spigot") {
return ServerType::Spigot;
} else if filename_lower.contains("bukkit") || filename_lower.contains("craftbukkit") {
return ServerType::Bukkit;
} else if filename_lower.contains("forge") {
return ServerType::Forge;
} else if filename_lower.contains("fabric") {
return ServerType::Fabric;
} else if filename_lower.contains("velocity") {
return ServerType::Velocity;
} else if filename_lower.contains("bungeecord") {
return ServerType::BungeeCord;
} else if filename_lower.contains("waterfall") {
return ServerType::Waterfall;
}
// If server.properties exists, it's likely a vanilla or modified vanilla server
if server_path.join("server.properties").exists() {
// Check if there's a plugins directory
if server_path.join("plugins").exists() {
// Assume Spigot if we can't tell more specifically but plugins exist
return ServerType::Spigot;
}
return ServerType::Vanilla;
}
}
// If we can't determine, check for directory structure hints
if server_path.join("plugins").exists() {
if server_path.join("cache").exists() && server_path.join("modules").exists() {
return ServerType::Velocity;
} else if server_path.join("libraries").exists() && server_path.join("mods").exists() {
return ServerType::Forge;
} else {
return ServerType::Spigot; // Default assumption for server with plugins
}
}
ServerType::Unknown
}
/// Find the server JAR file in the server directory
fn find_server_jar(server_path: &Path) -> Option<PathBuf> {
// Define pattern for server JAR files
let server_jar_pattern = Regex::new(r"^(paper|spigot|craftbukkit|minecraft|fabric|forge|velocity|bungeecord|waterfall).*\.jar$").unwrap();
// Look for JAR files in the directory
let entries = match fs::read_dir(server_path) {
Ok(entries) => entries,
Err(_) => return None,
};
// Check each entry
for entry in entries {
if let Ok(entry) = entry {
if let Some(filename) = entry.file_name().to_str() {
// Check if it matches server JAR pattern
if filename.to_lowercase().ends_with(".jar") {
if server_jar_pattern.is_match(&filename.to_lowercase()) {
return Some(entry.path());
}
// Also check for common naming patterns
if filename.contains("server") || filename == "server.jar" {
return Some(entry.path());
}
}
}
}
}
None
}
/// Detect the Minecraft version from server files
fn detect_minecraft_version(server_path: &Path, server_type: &ServerType) -> Option<String> {
// Try to find server JAR
if let Some(jar_path) = find_server_jar(server_path) {
// Try to extract version from JAR filename
if let Some(filename) = jar_path.file_name().and_then(|n| n.to_str()) {
// Look for version pattern like 1.19.2 in filename
let version_pattern = Regex::new(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)").unwrap();
if let Some(captures) = version_pattern.captures(filename) {
if let Some(version_match) = captures.get(0) {
return Some(version_match.as_str().to_string());
}
}
}
// If version not found in filename, try to read it from the JAR
return read_version_from_jar(&jar_path);
}
// Try server.properties for vanilla/bukkit/spigot servers
if matches!(server_type, ServerType::Vanilla | ServerType::Bukkit | ServerType::Spigot | ServerType::Paper) {
let properties_path = server_path.join("server.properties");
if properties_path.exists() {
// Read properties file
if let Ok(content) = fs::read_to_string(properties_path) {
// Look for the level-type property
for line in content.lines() {
if line.starts_with("level-name=") {
// Try to find version.json in the level directory
let level_name = line.trim_start_matches("level-name=").trim();
let version_json_path = server_path.join(level_name).join("version.json");
if version_json_path.exists() {
if let Ok(version_content) = fs::read_to_string(version_json_path) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&version_content) {
if let Some(name) = json.get("name").and_then(|n| n.as_str()) {
return Some(name.to_string());
}
}
}
}
}
}
}
}
}
None
}
/// Read version information from a JAR file
fn read_version_from_jar(jar_path: &Path) -> Option<String> {
// Open the JAR file
let file = match fs::File::open(jar_path) {
Ok(file) => file,
Err(_) => return None,
};
// Create a ZIP archive reader
let mut archive = match zip::ZipArchive::new(file) {
Ok(archive) => archive,
Err(_) => return None,
};
// Try to find version.json or similar file
for i in 0..archive.len() {
let mut file = match archive.by_index(i) {
Ok(file) => file,
Err(_) => continue,
};
let name = file.name();
// Check various version files
if name.ends_with("version.json") || name.contains("version") && name.ends_with(".json") {
let mut contents = String::new();
if let Err(_) = file.read_to_string(&mut contents) {
continue;
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) {
// Try different possible keys for version
for key in ["name", "version", "minecraft_version", "id"] {
if let Some(version) = json.get(key).and_then(|v| v.as_str()) {
return Some(version.to_string());
}
}
}
}
}
None
}
/// Get the plugins directory for the server
fn get_plugins_directory(server_path: &Path, server_type: &ServerType) -> String {
match server_type {
ServerType::Velocity | ServerType::BungeeCord | ServerType::Waterfall => {
server_path.join("plugins").to_string_lossy().to_string()
},
_ => server_path.join("plugins").to_string_lossy().to_string(),
}
}
/// Save plugin data to disk for persistence
async fn save_plugin_data(app_handle: AppHandle, plugins: Vec<Plugin>, server_path: String) -> Result<(), String> {
// Get plugin data path
let data_dir = get_plugin_data_path(&app_handle, &server_path)?;
// Create directory if it doesn't exist
if !data_dir.exists() {
fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
}
// Save plugins data
let data_path = data_dir.join("plugins.json");
let json_data = serde_json::to_string_pretty(&plugins)
.map_err(|e| format!("Failed to serialize plugin data: {}", e))?;
fs::write(&data_path, json_data)
.map_err(|e| format!("Failed to write plugin data: {}", e))?;
Ok(())
}
/// Get plugin data directory path
pub fn get_plugin_data_path(app_handle: &AppHandle, server_path: &str) -> Result<PathBuf, String> {
let app_data_dir = app_handle.path().app_data_dir()
.map_err(|e| format!("Failed to get app data directory: {}", e))?;
// Hash the server path to create a unique identifier
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(server_path.as_bytes());
let server_hash = format!("{:x}", hasher.finalize());
// Create a directory for this server
Ok(app_data_dir.join("servers").join(server_hash))
}

View File

@ -0,0 +1,7 @@
mod update_checker;
mod version_utils;
mod plugin_updater;
pub use update_checker::{check_for_plugin_updates, check_single_plugin_update};
pub use version_utils::{normalize_version, compare_plugin_versions};
pub use plugin_updater::{backup_plugin, replace_plugin};

View File

@ -0,0 +1,162 @@
use std::path::{Path, PathBuf};
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use regex;
use crate::models::repository::RepositorySource;
use crate::models::server::ServerInfo;
use crate::services::plugin_scanner::is_file_locked;
/// Backup a plugin before replacing it
pub async fn backup_plugin(plugin_file_path: String) -> Result<String, String> {
// Get the current timestamp for the backup filename
let now = SystemTime::now().duration_since(UNIX_EPOCH)
.map_err(|e| format!("Failed to get timestamp: {}", e))?
.as_secs();
// Create backup file path
let path = Path::new(&plugin_file_path);
let file_name = path.file_name()
.ok_or_else(|| "Invalid plugin file path".to_string())?
.to_str()
.ok_or_else(|| "Invalid file name encoding".to_string())?;
// Create backup directory if it doesn't exist
let backup_dir = path.parent()
.unwrap_or_else(|| Path::new("."))
.join("backups");
if !backup_dir.exists() {
fs::create_dir_all(&backup_dir)
.map_err(|e| format!("Failed to create backup directory: {}", e))?;
}
// Create backup file path with timestamp
let backup_path = backup_dir.join(format!("{}.{}.bak", file_name, now));
let backup_path_str = backup_path.to_string_lossy().to_string();
// Check if file is locked
if is_file_locked(&plugin_file_path) {
return Err(format!("File is locked: {}", plugin_file_path));
}
// Copy the file
fs::copy(&plugin_file_path, &backup_path)
.map_err(|e| format!("Failed to create backup: {}", e))?;
Ok(backup_path_str)
}
/// Replace a plugin with a new version
pub async fn replace_plugin(
plugin_id: String,
version: String,
repository: RepositorySource,
current_file_path: String,
server_info: Option<ServerInfo>
) -> Result<String, String> {
// Check if file is locked
if is_file_locked(&current_file_path) {
return Err(format!("Plugin file is currently locked: {}", current_file_path));
}
// Create a temporary file path
let download_path = create_temp_download_path(&current_file_path)?;
// Download the new plugin version
let server_type = server_info.as_ref().map(|info| &info.server_type);
let download_result = crate::lib_download_plugin_from_repository(
&plugin_id,
&version,
repository,
&download_path.to_string_lossy(),
server_type
).await;
// Check for premium resource indicator
if let Err(error) = &download_result {
if error.starts_with("PREMIUM_RESOURCE:") {
// Extract the resource URL from the error
let resource_url = error.strip_prefix("PREMIUM_RESOURCE:").unwrap_or_default();
return Err(format!("PREMIUM_RESOURCE:{}:{}:{}", plugin_id, version, resource_url));
}
}
// If other error, propagate it
let _ = download_result?;
// Backup the original file
backup_plugin(current_file_path.clone()).await?;
// Determine new file name with version
let new_file_path = create_versioned_file_path(&current_file_path, &version)?;
// Replace the original file with the downloaded one
// Use the new file path with version instead of the original path
fs::rename(download_path, &new_file_path)
.map_err(|e| format!("Failed to replace plugin: {}", e))?;
// Remove the old file if the path changed
if new_file_path != current_file_path {
match fs::remove_file(&current_file_path) {
Ok(_) => println!("Removed old plugin file: {}", current_file_path),
Err(e) => println!("Warning: Could not remove old plugin file {}: {}", current_file_path, e)
}
}
Ok(new_file_path)
}
/// Create a temporary file path for plugin download
fn create_temp_download_path(current_file_path: &str) -> Result<PathBuf, String> {
let path = Path::new(current_file_path);
let file_stem = path.file_stem()
.ok_or_else(|| "Invalid plugin file path".to_string())?
.to_str()
.ok_or_else(|| "Invalid file name encoding".to_string())?;
let file_ext = path.extension()
.unwrap_or_else(|| std::ffi::OsStr::new("jar"))
.to_str()
.unwrap_or("jar");
let parent = path.parent()
.unwrap_or_else(|| Path::new("."));
// Generate temp file name with timestamp
let now = SystemTime::now().duration_since(UNIX_EPOCH)
.map_err(|e| format!("Failed to get timestamp: {}", e))?
.as_secs();
let temp_name = format!("{}.new.{}.{}", file_stem, now, file_ext);
Ok(parent.join(temp_name))
}
/// Create a versioned file path for the updated plugin
fn create_versioned_file_path(current_file_path: &str, version: &str) -> Result<String, String> {
let path = Path::new(current_file_path);
let file_name = path.file_name()
.ok_or_else(|| "Invalid plugin file path".to_string())?
.to_str()
.ok_or_else(|| "Invalid file name encoding".to_string())?;
let parent = path.parent()
.unwrap_or_else(|| Path::new("."));
// Check if the file name already has a version pattern
// Common formats: PluginName-1.2.3.jar, PluginName-v1.2.3.jar, PluginName_1.2.3.jar
if let Some(captures) = regex::Regex::new(r"^([A-Za-z0-9_]+)[-_]v?[\d\.]+(\.[a-zA-Z0-9]+)$")
.unwrap()
.captures(file_name) {
// Get the plugin name without version
let base_name = captures.get(1).unwrap().as_str();
let extension = captures.get(2).unwrap().as_str();
// Create new filename with updated version
let new_file_name = format!("{}-{}{}", base_name, version, extension);
return Ok(parent.join(new_file_name).to_string_lossy().to_string());
}
// If no version pattern, just return the original path
Ok(current_file_path.to_string())
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,326 @@
use semver::{Version, VersionReq};
use regex::Regex;
/// Normalize a version string to be semver compatible
pub fn normalize_version(version_str: &str) -> String {
// Handle empty strings or "Latest"
if version_str.is_empty() || version_str.eq_ignore_ascii_case("latest") {
return "0.0.0".to_string(); // Default version if none provided
}
// Remove 'v' prefix if present
let cleaned = version_str.trim_start_matches(|c| c == 'v' || c == 'V');
// Remove double 'v' prefixes (like "vv1.0.0")
let cleaned = if cleaned.starts_with("v") || cleaned.starts_with("V") {
cleaned.trim_start_matches(|c| c == 'v' || c == 'V')
} else {
cleaned
};
cleaned.to_string()
}
/// Sanitize version string for comparison by removing platform suffixes
fn sanitize_version_for_comparison(version_str: &str) -> String {
// First normalize the version
let normalized = normalize_version(version_str);
// Remove common platform-specific suffixes
let platform_suffixes = [
"-paper", "-spigot", "-bukkit", "-forge", "-fabric", "-neoforge",
"-sponge", "-velocity", "-waterfall", "-bungeecord", "-quilt"
];
let mut result = normalized.to_string();
// Case-insensitive suffix removal
for suffix in platform_suffixes.iter() {
let suffix_lower = suffix.to_lowercase();
let version_lower = result.to_lowercase();
if version_lower.contains(&suffix_lower) {
// Find the position of the suffix (case-insensitive)
if let Some(pos) = version_lower.find(&suffix_lower) {
// Remove the suffix (with original casing)
result = result[0..pos].to_string();
}
}
}
// Remove any build metadata (anything after +)
if let Some(plus_pos) = result.find('+') {
result = result[0..plus_pos].to_string();
}
// Handle snapshot versions with build numbers
if result.contains("-SNAPSHOT") || result.contains("-snapshot") {
// Extract just the version part before any build info
if let Some(snapshot_pos) = result.to_lowercase().find("-snapshot") {
result = result[0..snapshot_pos].to_string();
}
}
// Normalize dev build formats that use dash-separated numbers
let build_regex = Regex::new(r"-build\d+").unwrap();
result = build_regex.replace_all(&result, "").to_string();
// Handle other common non-numeric suffixes
let common_suffixes = ["-RELEASE", "-dev", "-final", "-stable"];
for suffix in common_suffixes.iter() {
let suffix_lower = suffix.to_lowercase();
let version_lower = result.to_lowercase();
if version_lower.ends_with(&suffix_lower) {
let suffix_len = suffix_lower.len();
result = result[0..result.len() - suffix_len].to_string();
}
}
// If we've removed everything, return the original normalized version
if result.is_empty() {
return normalized;
}
// Make sure it ends with at least one digit for semver parsing
if !result.chars().last().map_or(false, |c| c.is_ascii_digit()) {
if let Some(last_digit_pos) = result.rfind(|c: char| c.is_ascii_digit()) {
result = result[0..=last_digit_pos].to_string();
}
}
// If we've removed version numbers entirely, return the original
if !result.chars().any(|c| c.is_ascii_digit()) {
return normalized;
}
result
}
/// Extracts platform suffix from a version string
pub fn extract_platform_suffix(version_str: &str) -> Option<String> {
// Platform suffixes to check for
let platform_suffixes = [
"-paper", "-spigot", "-bukkit", "-forge", "-fabric", "-neoforge",
"-sponge", "-velocity", "-waterfall", "-bungeecord", "-quilt"
];
let version_lower = version_str.to_lowercase();
for suffix in platform_suffixes.iter() {
let suffix_lower = suffix.to_lowercase();
if version_lower.contains(&suffix_lower) {
// Return the actual platform with original case
return Some(suffix_lower[1..].to_string()); // Remove the leading '-'
}
}
None
}
/// Determine if a version is a pre-release (snapshot, beta, etc.)
fn is_prerelease_version(version_str: &str) -> bool {
let version_lower = version_str.to_lowercase();
// Check for common pre-release indicators
version_lower.contains("-snapshot") ||
version_lower.contains("-alpha") ||
version_lower.contains("-beta") ||
version_lower.contains("-pre") ||
version_lower.contains("-rc") ||
version_lower.contains("dev") ||
version_lower.contains("test") ||
version_lower.contains("nightly")
}
/// Compare two plugin versions to determine if an update is available
/// Returns true if repo_str represents a newer version than installed_str
pub fn compare_plugin_versions(installed_str: &str, repo_str: &str) -> bool {
// Special case: identical strings are never upgrades
if installed_str == repo_str {
return false;
}
// Extract platform suffixes
let installed_platform = extract_platform_suffix(installed_str);
let repo_platform = extract_platform_suffix(repo_str);
// If platforms differ and both are specified, it's not considered an upgrade
// (we don't want to suggest forge versions for paper plugins)
if let (Some(installed_p), Some(repo_p)) = (&installed_platform, &repo_platform) {
if installed_p != repo_p {
println!("Platforms differ: {} vs {}, not an upgrade", installed_p, repo_p);
return false;
}
}
// Check for downgrades from release to prerelease
let installed_is_prerelease = is_prerelease_version(installed_str);
let repo_is_prerelease = is_prerelease_version(repo_str);
// Don't consider a pre-release version an upgrade from a stable version
if !installed_is_prerelease && repo_is_prerelease {
println!("Not upgrading from release {} to pre-release {}", installed_str, repo_str);
return false;
}
// Sanitize versions for comparison by removing platform-specific suffixes
let sanitized_installed = sanitize_version_for_comparison(installed_str);
let sanitized_repo = sanitize_version_for_comparison(repo_str);
// Log for debugging
println!("Comparing versions: '{}'({}') vs '{}'('{}')",
installed_str, sanitized_installed, repo_str, sanitized_repo);
// Try to parse as semver
match (Version::parse(&sanitized_installed), Version::parse(&sanitized_repo)) {
(Ok(installed), Ok(repo)) => {
// Properly formatted semver comparison
println!(" Using semver comparison: {} vs {}", installed, repo);
repo > installed
},
_ => {
// Fallback to more sophisticated string comparison for non-semver versions
if sanitized_installed == sanitized_repo {
// Same base version, check if repo has a higher build number or qualifier
compare_version_qualifiers(installed_str, repo_str)
} else {
// Try numeric component-by-component comparison
compare_version_components(&sanitized_installed, &sanitized_repo)
}
}
}
}
/// Compare version qualifiers and build numbers when base versions are the same
fn compare_version_qualifiers(installed_str: &str, repo_str: &str) -> bool {
// Try to extract build numbers
let installed_build = extract_build_number(installed_str);
let repo_build = extract_build_number(repo_str);
if let (Some(i_build), Some(r_build)) = (installed_build, repo_build) {
return r_build > i_build;
}
// If qualifiers differ, use a rank-based system
let installed_rank = get_qualifier_rank(installed_str);
let repo_rank = get_qualifier_rank(repo_str);
if installed_rank != repo_rank {
return repo_rank > installed_rank;
}
// Default case - not considered an upgrade
false
}
/// Assign rank values to different version qualifiers
fn get_qualifier_rank(version_str: &str) -> i32 {
let lower_str = version_str.to_lowercase();
if lower_str.contains("alpha") { return 10; }
if lower_str.contains("beta") { return 20; }
if lower_str.contains("rc") { return 30; }
if lower_str.contains("pre") { return 40; }
if lower_str.contains("snapshot") { return 50; }
if lower_str.contains("nightly") { return 60; }
if lower_str.contains("dev") { return 70; }
if lower_str.contains("release") { return 100; }
// Default for stable releases
90
}
/// Extract build number from a version string
fn extract_build_number(version_str: &str) -> Option<i32> {
// Try to find patterns like "build123" or "-b123" or ".123"
let build_patterns = [
Regex::new(r"build(\d+)").ok()?,
Regex::new(r"-b(\d+)").ok()?,
Regex::new(r"\.(\d+)$").ok()?,
Regex::new(r"-(\d+)$").ok()?,
];
for pattern in &build_patterns {
if let Some(captures) = pattern.captures(version_str) {
if let Some(build_match) = captures.get(1) {
if let Ok(build) = build_match.as_str().parse::<i32>() {
return Some(build);
}
}
}
}
None
}
/// Compare version strings component by component
fn compare_version_components(version1: &str, version2: &str) -> bool {
// Split versions into numeric and non-numeric parts
let v1_parts: Vec<&str> = version1.split(|c: char| !c.is_ascii_digit()).filter(|s| !s.is_empty()).collect();
let v2_parts: Vec<&str> = version2.split(|c: char| !c.is_ascii_digit()).filter(|s| !s.is_empty()).collect();
// Compare corresponding numeric parts
let max_parts = v1_parts.len().max(v2_parts.len());
for i in 0..max_parts {
let n1 = v1_parts.get(i).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
let n2 = v2_parts.get(i).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
if n2 > n1 {
return true; // Version 2 is higher
}
if n1 > n2 {
return false; // Version 1 is higher
}
}
// If all numeric parts are equal, compare by string length
// (Consider more segments to be a higher version, like 1.2.3 > 1.2)
v2_parts.len() > v1_parts.len()
}
/// Extract version pattern from a string
pub fn extract_version_pattern(input: &str) -> Option<String> {
// Look for version pattern like 1.19.2 in string
let version_pattern = Regex::new(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)").unwrap();
if let Some(captures) = version_pattern.captures(input) {
if let Some(version_match) = captures.get(0) {
return Some(version_match.as_str().to_string());
}
}
None
}
/// Check if a plugin version is compatible with a specific Minecraft version
pub fn is_version_compatible(plugin_version: &str, minecraft_versions: &Vec<String>) -> bool {
// If no versions specified, assume compatible
if minecraft_versions.is_empty() {
return true;
}
// Check if the Minecraft version is in the list of supported versions
for supported_version in minecraft_versions {
if plugin_version == supported_version {
return true;
}
// Try to parse the Minecraft version
if let Ok(mc_version) = semver::Version::parse(plugin_version) {
// Try to parse as a version requirement
if let Ok(req) = semver::VersionReq::parse(supported_version) {
if req.matches(&mc_version) {
return true;
}
}
}
// If version formats are incompatible, make best guess
if plugin_version.contains(supported_version) || supported_version.contains(plugin_version) {
return true;
}
}
// No compatibility match found
false
}

View File

@ -1,28 +1,31 @@
{
"$schema": "https://schema.tauri.app/config/2",
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"identifier": "com.plugsnatcher.app",
"productName": "PlugSnatcher",
"version": "0.1.0",
"identifier": "com.plugsnatcher.app",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
"frontendDist": "../dist",
"devUrl": "http://localhost:1420"
},
"app": {
"security": {
"csp": null
},
"windows": [
{
"label": "main",
"title": "PlugSnatcher",
"width": 1024,
"height": 768,
"minWidth": 800,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"center": true
}
],
"security": {
"csp": null
}
]
},
"bundle": {
"active": true,
@ -36,7 +39,10 @@
]
},
"plugins": {
"shell": {
"open": "^((mailto:\\w+)|(tel:\\w+)|(https?://\\w+)|(file://.+)).+"
},
"dialog": null,
"opener": null
"fs": null
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,298 +1,237 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import "./App.css";
import React, { useEffect, useState } from 'react';
import './App.css';
type ServerType =
| 'Paper'
| 'Spigot'
| 'Bukkit'
| 'Vanilla'
| 'Forge'
| 'Fabric'
| 'Velocity'
| 'BungeeCord'
| 'Waterfall'
| 'Unknown';
// Import context providers
import { ServerProvider } from './context/ServerContext/ServerContext';
import { PluginProvider } from './context/PluginContext/PluginContext';
import UIProvider from './context/UIContext/UIContext';
import { useUIContext } from './context/UIContext/useUIContext';
interface ServerInfo {
server_type: ServerType;
minecraft_version?: string;
plugins_directory: string;
plugins_count: number;
// Import layout components
import Footer from './components/layout/Footer/Footer';
import MainContent from './components/layout/MainContent/MainContent';
// Import server components
import ServerSelector from './components/server/ServerSelector/ServerSelector';
import ServerInfo from './components/server/ServerInfo/ServerInfo';
import ScanProgress from './components/server/ScanProgress/ScanProgress';
// Import plugin components
import PluginList from './components/plugins/PluginList/PluginList';
import PluginDetails from './components/plugins/PluginDetails/PluginDetails';
import NoPluginsMessage from './components/plugins/NoPluginsMessage/NoPluginsMessage';
// Import update components
import UpdateControls from './components/updates/UpdateControls/UpdateControls';
import BulkUpdateProgress from './components/updates/BulkUpdateProgress/BulkUpdateProgress';
import CompatibilityCheckDialog from './components/updates/CompatibilityCheckDialog/CompatibilityCheckDialog';
import PremiumPluginModal from './components/updates/PremiumPluginModal/PremiumPluginModal';
import DownloadProgressIndicator from './components/updates/DownloadProgressIndicator/DownloadProgressIndicator';
import PluginMatchSelector from './components/updates/PluginMatchSelector/PluginMatchSelector';
import WarningModal from './components/updates/WarningModal/WarningModal';
// Import common components
import NotificationDisplay from './components/common/NotificationDisplay/NotificationDisplay';
// Import hooks
import { useServerContext } from './context/ServerContext/useServerContext';
import { usePluginContext } from './context/PluginContext/usePluginContext';
import { usePluginActions } from './hooks/usePluginActions';
// Define interfaces for component props
interface PluginContextWrapperProps {
appVersion: string;
}
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;
}
interface ScanResult {
server_info: ServerInfo;
plugins: Plugin[];
}
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}>&times;</button>
<h2>{plugin.name}</h2>
<div className="plugin-version-display">Version: {plugin.version}</div>
{plugin.description && (
<div className="plugin-description">{plugin.description}</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>
)}
<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>
</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>
);
interface AppContentProps {
appVersion: string;
}
/**
* The main application component that serves as the entry point.
* This component is responsible for setting up the context providers
* and rendering the main application structure.
*/
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);
console.log("App Component Initialized");
const [appVersion, setAppVersion] = useState('1.0.0');
async function selectDirectory() {
try {
const selected = await open({
directory: true,
multiple: false,
title: 'Select Minecraft Server Directory'
});
if (selected !== null) {
if (typeof selected === 'string') {
setServerPath(selected);
setError(null);
} else if (Array.isArray(selected)) {
const selectedArr = selected as string[];
if (selectedArr.length > 0) {
setServerPath(selectedArr[0]);
setError(null);
}
}
useEffect(() => {
// Get the app version from the backend
const getAppVersion = async () => {
try {
// Use Tauri's invoke to call our Rust command
const { invoke } = await import('@tauri-apps/api/core');
const appInfo = await invoke('get_app_version');
setAppVersion((appInfo as { version: string }).version);
} catch (error) {
console.error('Failed to get app version:', error);
// display unknown version
setAppVersion('unknown');
}
} catch (err) {
console.error('Failed to open directory:', err);
setError("Failed to open directory selector. Please enter path manually.");
}
}
};
async function scanForPlugins() {
try {
setIsScanning(true);
setError(null);
getAppVersion();
}, []);
// Call the Rust backend
const result = await invoke<ScanResult>("scan_server_directory", { path: serverPath });
return (
<ServerProvider>
<PluginContextWrapper appVersion={appVersion} />
</ServerProvider>
);
}
setServerInfo(result.server_info);
setPlugins(result.plugins);
setIsScanning(false);
setScanComplete(true);
} catch (err) {
console.error("Error scanning for plugins:", err);
setError(err as string);
setIsScanning(false);
}
}
/**
* Wrapper to ensure PluginProvider has access to ServerContext
*/
function PluginContextWrapper({ appVersion }: PluginContextWrapperProps) {
const { serverPath, serverInfo } = useServerContext();
const showPluginDetails = (plugin: Plugin) => {
setSelectedPlugin(plugin);
};
useEffect(() => {
console.log("PluginContextWrapper: serverPath =", serverPath);
}, [serverPath]);
const closePluginDetails = () => {
setSelectedPlugin(null);
};
return (
<PluginProvider>
<UIProvider>
<AppContent appVersion={appVersion} />
</UIProvider>
</PluginProvider>
);
}
/**
* The main application content that uses context hooks.
* This is separate from App to ensure the contexts are available.
*/
function AppContent({ appVersion }: AppContentProps) {
const {
serverInfo,
serverPath,
isScanning,
scanProgress,
scanComplete
} = useServerContext();
const {
plugins,
selectedPlugin,
showPluginDetails,
closePluginDetails,
isCheckingUpdates,
bulkUpdateProgress
} = usePluginContext();
const {
warningMessage,
clearWarningMessage,
downloadProgress,
premiumPluginInfo,
clearPremiumPluginInfo
} = useUIContext();
const { isMatchSelectorOpen, potentialMatches, currentPluginForMatch, handleMatchSelection, closeMatchSelector } = usePluginActions();
return (
<div className="app-container">
<NotificationDisplay />
<header className="app-header">
<h1>🔧 PlugSnatcher</h1>
<p>Minecraft Plugin Manager</p>
<h1>PlugSnatcher</h1>
<p className="app-subtitle">Minecraft Server 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>
<MainContent>
{/* Server section with selector and info */}
<div className="server-section">
<ServerSelector />
{!isScanning && serverInfo && <ServerInfo serverInfo={serverInfo} />}
</div>
{error && (
<div className="error-message">
{error}
{/* Scan progress - only shown when scanning is in progress */}
{/* Removed duplicate ScanProgress component since it's already in ServerSelector */}
{/* Plugins section - only shown when scan is complete */}
{serverPath && scanComplete && plugins.length > 0 && (
<div className="plugins-section">
<div className="plugins-header-container">
<h2>Plugins ({plugins.length})</h2>
<UpdateControls />
{bulkUpdateProgress && <BulkUpdateProgress />}
</div>
)}
</section>
{scanComplete && serverInfo && (
<ServerInfoDisplay serverInfo={serverInfo} />
<div className="plugin-grid-header">
<div>Name</div>
<div>Version</div>
<div>Compatibility</div>
<div>Actions</div>
</div>
<PluginList />
</div>
)}
{scanComplete && (
<section className="plugins-list">
<h2>Installed Plugins ({plugins.length})</h2>
{plugins.length > 0 ? (
<>
<div className="plugins-header">
<span>Name</span>
<span>Current Version</span>
<span>Latest Version</span>
<span>Actions</span>
</div>
{plugins.map((plugin, index) => (
<div key={index} 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.latest_version || 'Unknown'}</div>
<div className="plugin-actions">
{plugin.has_update && (
<button className="update-button">Update</button>
)}
<button className="info-button" onClick={() => showPluginDetails(plugin)}>Info</button>
</div>
</div>
))}
</>
) : (
<p>No plugins found in this directory.</p>
)}
</section>
{/* No plugins message - only shown when scan complete but no plugins found */}
{serverPath && scanComplete && plugins.length === 0 && (
<NoPluginsMessage />
)}
{/* Modals and Overlays */}
{selectedPlugin && (
<PluginDetails plugin={selectedPlugin} onClose={closePluginDetails} />
<PluginDetails
plugin={selectedPlugin}
onClose={closePluginDetails}
/>
)}
</main>
<footer className="app-footer">
<p>PlugSnatcher v0.1.0 - Developed with 💻 and </p>
</footer>
{warningMessage && (
<WarningModal
isOpen={!!warningMessage}
onClose={clearWarningMessage}
title="Warning"
message={warningMessage.text}
variant={warningMessage.type === 'error' ? 'error' :
warningMessage.type === 'success' ? 'info' : 'warning'}
/>
)}
{downloadProgress && downloadProgress > 0 && (
<DownloadProgressIndicator
downloadProgress={{
pluginName: "Plugin",
version: "Latest",
percentage: downloadProgress,
status: "downloading"
}}
/>
)}
{premiumPluginInfo && (
<PremiumPluginModal
isOpen={!!premiumPluginInfo}
onClose={clearPremiumPluginInfo}
pluginInfo={{
name: premiumPluginInfo.name,
version: "Unknown",
url: "#"
}}
/>
)}
{isMatchSelectorOpen && currentPluginForMatch && (
<PluginMatchSelector
isOpen={isMatchSelectorOpen}
onClose={closeMatchSelector}
pluginName={currentPluginForMatch.name}
potentialMatches={potentialMatches}
onSelectMatch={handleMatchSelection}
/>
)}
</MainContent>
<Footer appVersion={appVersion} />
</div>
);
}
export default App;
export default App;

View File

@ -0,0 +1,143 @@
.app-badge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 16px;
font-weight: 500;
transition: all 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* Size variants */
.app-badge-small {
padding: 0 8px;
font-size: 0.65rem;
height: 18px;
min-width: 18px;
}
.app-badge-medium {
padding: 0 10px;
font-size: 0.75rem;
height: 22px;
min-width: 22px;
}
.app-badge-large {
padding: 0 12px;
font-size: 0.85rem;
height: 26px;
min-width: 26px;
}
/* Color variants - solid backgrounds */
.app-badge-default {
background-color: var(--badge-default-bg, #e0e0e0);
color: var(--badge-default-text, #333333);
}
.app-badge-primary {
background-color: var(--primary-color, #007bff);
color: white;
}
.app-badge-success {
background-color: var(--success-color, #28a745);
color: white;
}
.app-badge-warning {
background-color: var(--warning-color, #ffc107);
color: var(--warning-text, #212529);
}
.app-badge-error {
background-color: var(--error-color, #dc3545);
color: white;
}
.app-badge-info {
background-color: var(--info-color, #17a2b8);
color: white;
}
/* Outlined variants */
.app-badge-outlined {
background-color: transparent;
border: 1px solid;
}
.app-badge-outlined.app-badge-default {
border-color: var(--badge-default-bg, #e0e0e0);
color: var(--badge-default-text, #555555);
}
.app-badge-outlined.app-badge-primary {
border-color: var(--primary-color, #007bff);
color: var(--primary-color, #007bff);
}
.app-badge-outlined.app-badge-success {
border-color: var(--success-color, #28a745);
color: var(--success-color, #28a745);
}
.app-badge-outlined.app-badge-warning {
border-color: var(--warning-color, #ffc107);
color: var(--warning-color, #ffc107);
}
.app-badge-outlined.app-badge-error {
border-color: var(--error-color, #dc3545);
color: var(--error-color, #dc3545);
}
.app-badge-outlined.app-badge-info {
border-color: var(--info-color, #17a2b8);
color: var(--info-color, #17a2b8);
}
/* Clickable badges */
.app-badge-clickable {
cursor: pointer;
}
.app-badge-clickable:hover {
opacity: 0.8;
}
/* Badge with icon */
.app-badge-icon {
display: flex;
align-items: center;
margin-right: 4px;
}
/* Support for server type badges */
.app-badge[data-server-type="Paper"] {
background-color: var(--paper-color, #4caf50);
color: white;
}
.app-badge[data-server-type="Spigot"] {
background-color: var(--spigot-color, #ff9800);
color: white;
}
.app-badge[data-server-type="Bukkit"] {
background-color: var(--bukkit-color, #9c27b0);
color: white;
}
.app-badge[data-server-type="Forge"] {
background-color: var(--forge-color, #f44336);
color: white;
}
.app-badge[data-server-type="Fabric"] {
background-color: var(--fabric-color, #2196f3);
color: white;
}

View File

@ -0,0 +1,109 @@
import React, { memo, useCallback, KeyboardEvent } from 'react';
import './Badge.css';
export interface BadgeProps {
/**
* Badge text content
*/
label: string;
/**
* Badge visual variant/color scheme
* @default 'default'
*/
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info';
/**
* Badge size
* @default 'medium'
*/
size?: 'small' | 'medium' | 'large';
/**
* Optional icon to display before the label
*/
icon?: React.ReactNode;
/**
* Whether to display badge with an outline style
* @default false
*/
outlined?: boolean;
/**
* Additional CSS class
*/
className?: string;
/**
* Optional tooltip text displayed on hover
*/
tooltip?: string;
/**
* Optional badge data attribute used for custom styling
*/
dataAttribute?: {
name: string;
value: string;
};
/**
* Click handler for the badge
*/
onClick?: () => void;
}
/**
* A reusable badge component for displaying labels, statuses, and categories
*/
const BadgeComponent: React.FC<BadgeProps> = ({
label,
variant = 'default',
size = 'medium',
icon,
outlined = false,
className = '',
tooltip,
dataAttribute,
onClick
}) => {
const badgeClasses = [
'app-badge',
`app-badge-${variant}`,
`app-badge-${size}`,
outlined && 'app-badge-outlined',
onClick && 'app-badge-clickable',
className
].filter(Boolean).join(' ');
// Construct the data attributes object
const dataAttrs: { [key: string]: string } = {};
if (dataAttribute) {
dataAttrs[`data-${dataAttribute.name}`] = dataAttribute.value;
}
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLSpanElement>) => {
if (onClick && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault(); // Prevent default space bar scroll
onClick();
}
}, [onClick]);
return (
<span
className={badgeClasses}
title={tooltip}
onClick={onClick}
onKeyDown={onClick ? handleKeyDown : undefined}
tabIndex={onClick ? 0 : undefined}
role={onClick ? 'button' : undefined}
{...dataAttrs}
>
{icon && <span className="app-badge-icon">{icon}</span>}
<span className="app-badge-label">{label}</span>
</span>
);
};
export default memo(BadgeComponent);

View File

@ -0,0 +1,151 @@
.app-button {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
font-family: inherit;
font-weight: 500;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
outline: none;
border: none;
gap: 8px;
text-align: center;
white-space: nowrap;
}
/* Size variants */
.app-button-small {
padding: 6px 12px;
font-size: 0.75rem;
min-height: 28px;
}
.app-button-medium {
padding: 8px 16px;
font-size: 0.875rem;
min-height: 36px;
}
.app-button-large {
padding: 10px 20px;
font-size: 1rem;
min-height: 44px;
}
/* Style variants */
.app-button-primary {
background-color: var(--primary-color, #007bff);
color: white;
}
.app-button-primary:hover:not(:disabled) {
background-color: var(--primary-hover, #0069d9);
}
.app-button-secondary {
background-color: var(--secondary-color, #6c757d);
color: white;
}
.app-button-secondary:hover:not(:disabled) {
background-color: var(--secondary-hover, #5a6268);
}
.app-button-outline {
background-color: transparent;
border: 1px solid var(--border-color, #d1d1d1);
color: var(--text-color, #333);
}
.app-button-outline:hover:not(:disabled) {
background-color: var(--hover-bg);
}
.app-button-danger {
background-color: var(--danger-color, #dc3545);
color: white;
}
.app-button-danger:hover:not(:disabled) {
background-color: var(--danger-hover, #c82333);
}
.app-button-success {
background-color: var(--success-color, #28a745);
color: #202124;
}
.app-button-success:hover:not(:disabled) {
background-color: var(--success-hover, #218838);
color: #202124;
}
.app-button-warning {
background-color: var(--warning-color, #ff9800);
color: #202124;
}
.app-button-warning:hover:not(:disabled) {
background-color: var(--warning-hover);
color: #202124;
}
.app-button-text {
background-color: transparent;
color: var(--text-color, #007bff);
padding-left: 4px;
padding-right: 4px;
}
.app-button-text:hover:not(:disabled) {
background-color: var(--text-hover-bg);
}
/* Disabled state */
.app-button:disabled {
opacity: 0.65;
cursor: not-allowed;
}
/* Full width */
.app-button-full-width {
width: 100%;
}
/* Loading spinner */
.app-button-loading {
cursor: wait;
}
.app-button-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 0.8s linear infinite;
margin-right: 4px;
}
.app-button-outline .app-button-spinner,
.app-button-text .app-button-spinner {
border: 2px solid rgba(0, 0, 0, 0.1);
border-top-color: var(--primary-color, #007bff);
}
/* Icon positioning */
.app-button-start-icon,
.app-button-end-icon {
display: flex;
align-items: center;
justify-content: center;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,91 @@
import React, { memo } from 'react';
import './Button.css';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/**
* Button visual variant
* @default 'primary'
*/
variant?: 'primary' | 'secondary' | 'outline' | 'danger' | 'success' | 'text' | 'warning';
/**
* Button size
* @default 'medium'
*/
size?: 'small' | 'medium' | 'large';
/**
* Whether the button is in a loading state
* @default false
*/
isLoading?: boolean;
/**
* Icon to display before the button text
*/
startIcon?: React.ReactNode;
/**
* Icon to display after the button text
*/
endIcon?: React.ReactNode;
/**
* Full width button
* @default false
*/
fullWidth?: boolean;
/**
* Button content
*/
children: React.ReactNode;
/**
* Optional tooltip text displayed on hover
*/
tooltip?: string;
}
/**
* A reusable button component with various styling options
*/
const ButtonComponent: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'medium',
isLoading = false,
disabled = false,
startIcon,
endIcon,
fullWidth = false,
className = '',
tooltip,
children,
...restProps
}) => {
const buttonClasses = [
'app-button',
`app-button-${variant}`,
`app-button-${size}`,
isLoading && 'app-button-loading',
fullWidth && 'app-button-full-width',
className
].filter(Boolean).join(' ');
return (
<button
className={buttonClasses}
disabled={disabled || isLoading}
aria-busy={isLoading}
title={tooltip}
{...restProps}
>
{isLoading && <span role="status" className="app-button-spinner"></span>}
{startIcon && !isLoading && <span className="app-button-start-icon">{startIcon}</span>}
<span className="app-button-text">{children}</span>
{endIcon && !isLoading && <span className="app-button-end-icon">{endIcon}</span>}
</button>
);
};
export default memo(ButtonComponent);

View File

@ -0,0 +1,102 @@
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.modal-container {
background-color: var(--surface-color, #292a2d);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
animation: modal-fade-in 0.2s ease-out;
border: 1px solid var(--border-color, #3c4043);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--border-color, #3c4043);
background-color: var(--surface-color, #292a2d);
}
.modal-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color, #e8eaed);
}
.modal-close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary-color, #9aa0a6);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
padding: 0;
line-height: 1;
}
.modal-close-button:hover {
color: var(--text-color, #e8eaed);
background-color: rgba(255, 255, 255, 0.1);
}
.modal-content {
padding: 16px;
overflow-y: auto;
background-color: var(--surface-color, #292a2d);
color: var(--text-color, #e8eaed);
}
@keyframes modal-fade-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 768px) {
.modal-container {
width: 95%;
max-height: 95vh;
}
.modal-header {
padding: 12px;
}
.modal-content {
padding: 12px;
}
.modal-title {
font-size: 1.1rem;
}
}

View File

@ -0,0 +1,114 @@
import React, { ReactNode, useEffect, useRef } from 'react';
import './Modal.css';
export interface ModalProps {
/**
* Title displayed at the top of the modal
*/
title?: string;
/**
* Content of the modal
*/
children: ReactNode;
/**
* Whether the modal is visible or not
*/
isOpen: boolean;
/**
* Function to call when the modal close button is clicked
*/
onClose: () => void;
/**
* Optional CSS class name to add to the modal for custom styling
*/
className?: string;
/**
* Whether to show the close button in the header
* @default true
*/
showCloseButton?: boolean;
/**
* Whether to close the modal when clicking outside of it
* @default true
*/
closeOnOutsideClick?: boolean;
}
/**
* A reusable modal component that can be used for various purposes
*/
const Modal: React.FC<ModalProps> = ({
title,
children,
isOpen,
onClose,
className = '',
showCloseButton = true,
closeOnOutsideClick = true
}) => {
const modalRef = useRef<HTMLDivElement>(null);
// Close modal when escape key is pressed
useEffect(() => {
const handleEscKey = (event: KeyboardEvent) => {
if (isOpen && event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscKey);
return () => {
document.removeEventListener('keydown', handleEscKey);
};
}, [isOpen, onClose]);
// Handle outside click
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (closeOnOutsideClick && modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
if (!isOpen) {
return null;
}
return (
<div className="modal-backdrop" onClick={handleBackdropClick}>
<div
className={`modal-container ${className}`}
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
aria-describedby="modal-content"
>
{(title || showCloseButton) && (
<div className="modal-header">
{title && <h3 className="modal-title" id="modal-title">{title}</h3>}
{showCloseButton && (
<button
className="modal-close-button"
onClick={onClose}
aria-label="Close"
>
&times;
</button>
)}
</div>
)}
<div className="modal-content" id="modal-content">
{children}
</div>
</div>
</div>
);
};
export default Modal;

View File

@ -0,0 +1,51 @@
.notification-display {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem 1.5rem;
border-radius: 6px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
z-index: 1500; /* Above most content, below critical modals? */
display: flex;
align-items: center;
justify-content: space-between;
min-width: 250px;
max-width: 400px;
}
.notification-text {
flex-grow: 1;
margin-right: 1rem;
}
.notification-close-button {
background: none;
border: none;
font-size: 1.2rem;
font-weight: bold;
color: inherit; /* Inherit color from parent */
cursor: pointer;
padding: 0;
line-height: 1;
}
/* Type-specific styles */
.notification-success {
background-color: var(--success-color);
color: #202124; /* Dark text for contrast */
}
.notification-error {
background-color: var(--error-color);
color: white;
}
.notification-warning {
background-color: var(--warning-color);
color: #202124; /* Dark text for contrast */
}
.notification-info {
background-color: var(--primary-color);
color: white;
}

View File

@ -0,0 +1,34 @@
import React from 'react';
import { useUIContext } from '../../../context/UIContext/useUIContext';
import './NotificationDisplay.css';
/**
* Displays the current notification message from UIContext.
*/
export const NotificationDisplay: React.FC = () => {
const { warningMessage, clearWarningMessage } = useUIContext();
if (!warningMessage) {
return null;
}
const { text, type, id } = warningMessage;
return (
<div className={`notification-display notification-${type}`}>
<span className="notification-text">{text}</span>
{/* Add a close button only for persistent errors? */}
{type === 'error' && (
<button
className="notification-close-button"
onClick={clearWarningMessage}
aria-label="Close notification"
>
&times;
</button>
)}
</div>
);
};
export default NotificationDisplay;

View File

@ -0,0 +1,69 @@
.progress-container {
display: flex;
flex-direction: column;
width: 100%;
margin: 12px 0;
}
.progress-label {
font-size: 0.875rem;
margin-bottom: 4px;
color: var(--text-secondary, #666);
}
.progress-bar-wrapper {
width: 100%;
height: 10px;
background-color: var(--progress-bg, #f0f0f0);
border-radius: 6px;
overflow: hidden;
position: relative;
}
.progress-bar {
height: 100%;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: width 0.3s ease;
position: relative;
min-width: 20px;
}
.progress-percentage {
position: absolute;
font-size: 0.7rem;
color: var(--progress-text, #fff);
font-weight: 500;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
right: 6px;
}
.progress-values {
font-size: 0.75rem;
margin-top: 4px;
text-align: right;
color: var(--text-secondary, #666);
}
/* Color variants */
.progress-bar-primary {
background-color: var(--primary-color, #007bff);
}
.progress-bar-success {
background-color: var(--success-color, #28a745);
}
.progress-bar-warning {
background-color: var(--warning-color, #ffc107);
}
.progress-bar-error {
background-color: var(--error-color, #dc3545);
}
.progress-bar-info {
background-color: var(--info-color, #17a2b8);
}

View File

@ -0,0 +1,114 @@
import React from 'react';
import './ProgressBar.css';
export interface ProgressBarProps {
/**
* Current value of the progress (should be between min and max)
*/
value: number;
/**
* Maximum value for the progress bar
* @default 100
*/
max?: number;
/**
* Minimum value for the progress bar
* @default 0
*/
min?: number;
/**
* Label to display with the progress
*/
label?: string;
/**
* Whether to show percentage text inside the progress bar
* @default true
*/
showPercentage?: boolean;
/**
* Additional CSS class for custom styling
*/
className?: string;
/**
* Progress bar color
*/
color?: 'primary' | 'success' | 'warning' | 'error' | 'info';
/**
* Whether to display the current value and max next to the bar
* @default false
*/
showValues?: boolean;
/**
* Format for displaying values (when showValues is true)
* @default "{value}/{max}"
*/
valueFormat?: string;
}
/**
* A reusable progress bar component that shows the progress of an operation
*/
const ProgressBar: React.FC<ProgressBarProps> = ({
value,
max = 100,
min = 0,
label,
showPercentage = true,
className = '',
color = 'primary',
showValues = false,
valueFormat = "{value}/{max}"
}) => {
// Calculate percentage
const range = max - min;
const valueInRange = Math.max(min, Math.min(max, value)) - min;
const percentage = range > 0 ? Math.round((valueInRange / range) * 100) : 0;
// Format value display
const formattedValue = valueFormat
.replace('{value}', value.toString())
.replace('{max}', max.toString())
.replace('{min}', min.toString())
.replace('{percentage}', `${percentage}%`);
return (
<div
className={`progress-container ${className}`}
aria-live="polite"
>
{label && <div className="progress-label" id={`progress-label-${label}`}>{label}</div>}
<div
className="progress-bar-wrapper"
role="progressbar"
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-label={label ? undefined : 'Progress'}
aria-labelledby={label ? `progress-label-${label}` : undefined}
aria-valuetext={showValues ? formattedValue : `${percentage}%`}
>
<div
className={`progress-bar progress-bar-${color}`}
style={{ width: `${percentage}%` }}
>
{showPercentage && (
<span className="progress-percentage">{percentage}%</span>
)}
</div>
</div>
{showValues && (
<div className="progress-values" aria-hidden="true">{formattedValue}</div>
)}
</div>
);
};
export default ProgressBar;

View File

@ -0,0 +1,62 @@
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1.5rem;
background-color: var(--color-background-dark);
color: var(--color-text-muted);
border-top: 1px solid var(--color-border);
height: 40px;
font-size: 0.8rem;
}
.footer-left, .footer-center, .footer-right {
display: flex;
align-items: center;
}
.footer-left {
flex: 1;
}
.footer-center {
flex: 2;
justify-content: center;
}
.footer-right {
flex: 1;
justify-content: flex-end;
}
.footer-version {
opacity: 0.7;
}
.server-info {
display: flex;
align-items: center;
gap: 1rem;
}
.server-type {
font-weight: 500;
color: var(--color-text-light);
}
.plugin-count {
background-color: rgba(0, 0, 0, 0.2);
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.github-link {
color: var(--color-primary);
text-decoration: none;
transition: color 0.2s;
}
.github-link:hover {
color: var(--color-primary-light);
text-decoration: underline;
}

View File

@ -0,0 +1,45 @@
import React from 'react';
import { usePluginContext } from '../../../context/PluginContext/usePluginContext';
import { useServerContext } from '../../../context/ServerContext/useServerContext';
import './Footer.css';
interface FooterProps {
appVersion?: string;
}
export const Footer: React.FC<FooterProps> = ({ appVersion = '1.0.0' }) => {
const { plugins } = usePluginContext();
const { serverInfo } = useServerContext();
const getServerTypeText = () => {
if (!serverInfo) return 'No server selected';
let text = `${serverInfo.server_type}`;
if (serverInfo.minecraft_version) {
text += ` (${serverInfo.minecraft_version})`;
}
return text;
};
return (
<footer className="footer">
<div className="footer-left">
<span className="footer-version">PlugSnatcher v{appVersion}</span>
</div>
<div className="footer-right">
<a
href="https://git.spacetrainclubhouse.com/Space-Train-Clubhouse/PlugSnatcher"
target="_blank"
rel="noopener noreferrer"
className="github-link"
>
Gitea
</a>
</div>
</footer>
);
};
export default Footer;

View File

@ -0,0 +1,112 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background-color: var(--color-background-dark);
color: var(--color-text-light);
border-bottom: 1px solid var(--color-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
height: 60px;
}
.header-left, .header-center, .header-right {
display: flex;
align-items: center;
}
.header-left {
flex: 1;
}
.header-center {
flex: 2;
justify-content: center;
}
.header-right {
flex: 1;
justify-content: flex-end;
}
.app-title {
font-size: 1.5rem;
font-weight: bold;
margin: 0;
margin-right: 0.5rem;
color: var(--color-primary);
}
.app-version {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-right: 0.75rem;
}
.update-badge {
background-color: var(--color-accent);
color: white;
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-weight: bold;
animation: pulse 2s infinite;
}
.server-path-display {
display: flex;
align-items: center;
background-color: rgba(0, 0, 0, 0.1);
padding: 0.4rem 0.75rem;
border-radius: 4px;
max-width: 100%;
overflow: hidden;
}
.server-label {
font-weight: bold;
margin-right: 0.5rem;
white-space: nowrap;
color: var(--color-text-muted);
}
.server-path {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
color: var(--color-text-light);
}
.update-check-button {
background-color: var(--color-primary);
color: white;
border: none;
border-radius: 4px;
padding: 0.5rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
}
.update-check-button:hover:not(:disabled) {
background-color: var(--color-primary-dark);
}
.update-check-button:disabled {
background-color: var(--color-disabled);
cursor: not-allowed;
opacity: 0.7;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}

View File

@ -0,0 +1,56 @@
import React from 'react';
import { useAppUpdates } from '../../../hooks/useAppUpdates';
import { useServerContext } from '../../../context/ServerContext/useServerContext';
import { usePluginContext } from '../../../context/PluginContext/usePluginContext';
import './Header.css';
interface HeaderProps {
appVersion?: string;
}
export const Header: React.FC<HeaderProps> = ({ appVersion = '1.0.0' }) => {
const { isCheckingAppUpdate, appUpdateAvailable, checkForAppUpdate } = useAppUpdates();
const { serverPath } = useServerContext();
const { isCheckingUpdates } = usePluginContext();
const handleCheckForUpdates = async () => {
if (!isCheckingAppUpdate) {
await checkForAppUpdate();
}
};
return (
<header className="header">
<div className="header-left">
<h1 className="app-title">PlugSnatcher</h1>
<span className="app-version">v{appVersion}</span>
{appUpdateAvailable && (
<span className="update-badge">Update Available</span>
)}
</div>
<div className="header-center">
{serverPath && (
<div className="server-path-display">
<span className="server-label">Selected Server:</span>
<span className="server-path" title={serverPath}>
{serverPath.length > 40 ? `...${serverPath.slice(-40)}` : serverPath}
</span>
</div>
)}
</div>
<div className="header-right">
<button
className="update-check-button"
onClick={handleCheckForUpdates}
disabled={isCheckingAppUpdate || isCheckingUpdates}
>
{isCheckingAppUpdate ? 'Checking...' : 'Check for Updates'}
</button>
</div>
</header>
);
};
export default Header;

View File

@ -0,0 +1,78 @@
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 0;
position: relative;
background-color: var(--background-color);
}
.content-container {
flex: 1;
padding: 1.5rem;
display: flex;
flex-direction: column;
overflow-y: auto;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.warning-message {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
width: 100%;
animation: slide-down 0.3s ease-out;
}
.warning-text {
flex: 1;
}
.warning-info {
background-color: var(--primary-color);
color: white;
}
.warning-success {
background-color: var(--success-color);
color: white;
}
.warning-warning {
background-color: var(--warning-color);
color: white;
}
.warning-error {
background-color: var(--error-color);
color: white;
}
.close-warning {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 1.2rem;
margin-left: 1rem;
opacity: 0.7;
transition: opacity 0.2s;
}
.close-warning:hover {
opacity: 1;
}
@keyframes slide-down {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}

View File

@ -0,0 +1,34 @@
import React, { ReactNode } from 'react';
import { useUIContext } from '../../../context/UIContext/useUIContext';
import './MainContent.css';
interface MainContentProps {
children: ReactNode;
}
export const MainContent: React.FC<MainContentProps> = ({ children }) => {
const { warningMessage, clearWarningMessage } = useUIContext();
return (
<main className="main-content">
{warningMessage && (
<div className={`warning-message warning-${warningMessage.type}`}>
<span className="warning-text">{warningMessage.text}</span>
<button
className="close-warning"
onClick={clearWarningMessage}
aria-label="Close message"
>
×
</button>
</div>
)}
<div className="content-container">
{children}
</div>
</main>
);
};
export default MainContent;

View File

@ -0,0 +1,74 @@
.no-plugins-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 2rem;
text-align: center;
background-color: var(--color-background);
border-radius: 8px;
border: 1px dashed var(--color-border);
margin: 2rem auto;
max-width: 500px;
}
.no-plugins-message h3 {
margin: 1rem 0 0.5rem;
font-size: 1.5rem;
color: var(--color-text);
}
.no-plugins-message p {
margin: 0.25rem 0;
color: var(--color-text-muted);
font-size: 1rem;
max-width: 400px;
}
.no-plugins-message .suggestion {
font-size: 0.9rem;
margin-top: 1rem;
padding: 0.75rem;
background-color: rgba(var(--color-info-rgb), 0.1);
border-radius: 4px;
color: var(--color-info);
}
.no-plugins-message .icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.no-plugins-message.scanning {
background-color: rgba(var(--color-primary-rgb), 0.05);
border-color: var(--color-primary);
}
.no-plugins-message.no-server {
background-color: rgba(var(--color-info-rgb), 0.05);
border-color: var(--color-info);
}
.no-plugins-message.empty {
background-color: rgba(var(--color-warning-rgb), 0.05);
border-color: var(--color-warning);
}
.spinner {
border: 4px solid rgba(var(--color-primary-rgb), 0.2);
border-left: 4px solid var(--color-primary);
border-radius: 50%;
width: 40px;
height: 40px;
margin-bottom: 1rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,49 @@
import React from 'react';
import { useServerActions } from '../../../hooks/useServerActions';
import './NoPluginsMessage.css';
export const NoPluginsMessage: React.FC = () => {
const { serverPath, scanComplete, isScanning } = useServerActions();
if (isScanning) {
return (
<div className="no-plugins-message scanning">
<div className="spinner"></div>
<h3>Scanning for plugins...</h3>
<p>This might take a moment, please wait.</p>
</div>
);
}
if (!serverPath) {
return (
<div className="no-plugins-message no-server">
<div className="icon">📁</div>
<h3>No Server Selected</h3>
<p>Please select a Minecraft server directory to get started.</p>
</div>
);
}
if (scanComplete && !isScanning) {
return (
<div className="no-plugins-message empty">
<div className="icon">🔍</div>
<h3>No Plugins Found</h3>
<p>We couldn't find any plugins in the selected server directory.</p>
<p className="suggestion">Make sure you've selected a valid Minecraft server with plugins installed.</p>
</div>
);
}
// Default message if we're in an intermediate state
return (
<div className="no-plugins-message">
<div className="icon">🧩</div>
<h3>Ready to Scan</h3>
<p>Click "Scan for Plugins" to discover plugins in your server.</p>
</div>
);
};
export default NoPluginsMessage;

View File

@ -0,0 +1,228 @@
.plugin-details-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
animation: fadeIn 0.2s ease-out;
}
.plugin-details-container {
background-color: var(--background-color);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 1200px;
max-height: 90vh;
overflow-y: auto;
overflow-x: hidden;
position: relative;
animation: slideIn 0.2s ease-out;
display: flex;
flex-direction: column;
}
.plugin-details-header {
position: sticky;
top: 0;
background-color: var(--background-color);
padding: 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: flex-start;
z-index: 1;
width: auto;
box-sizing: border-box;
}
.plugin-details-content {
padding: 20px;
align-self: center;
box-sizing: border-box;
max-width: 100%;
width: 100%;
}
.close-button {
background: none;
border: none;
font-size: 24px;
color: var(--text-color);
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: background-color 0.2s;
line-height: 1;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
background-color: var(--hover-color);
}
.plugin-details-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
width: 100%;
}
.plugin-details-column {
width: 100%;
}
@media (max-width: 768px) {
.plugin-details-columns {
grid-template-columns: 1fr;
}
}
.plugin-details-section {
margin-bottom: 24px;
}
.plugin-details-section h3 {
margin-bottom: 12px;
color: var(--heading-color);
font-size: 1.1em;
}
.plugin-name {
margin: 0;
font-size: 1.5em;
color: var(--heading-color);
}
.plugin-version-info {
margin-top: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.update-info {
display: flex;
align-items: center;
gap: 8px;
}
.version-link {
color: var(--link-color);
text-decoration: none;
}
.version-link:hover {
text-decoration: underline;
}
.plugin-description {
line-height: 1.5;
color: var(--text-color);
}
.plugin-authors {
margin: 0;
color: var(--text-color);
}
.plugin-website {
display: inline-block;
margin-top: 8px;
color: var(--link-color);
text-decoration: none;
}
.plugin-website:hover {
text-decoration: underline;
}
.dependency-list {
list-style: none;
padding: 0;
margin: 0;
}
.dependency-list li {
margin-bottom: 4px;
}
.file-info-item {
margin-bottom: 8px;
display: flex;
gap: 8px;
width: 100%;
}
.file-info-label {
color: var(--text-muted);
min-width: 100px;
flex-shrink: 0;
}
.file-path {
word-break: break-all;
}
.file-info-value {
word-break: break-all;
flex: 1;
}
.platform-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.platform-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em;
background-color: var(--badge-background);
color: var(--badge-text);
}
.plugin-actions-section {
padding: 20px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 12px;
position: sticky;
bottom: 0;
background-color: var(--background-color);
width: auto;
box-sizing: border-box;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@ -0,0 +1,291 @@
import React, { useEffect, useCallback } from 'react';
import { Plugin } from '../../../types/plugin.types';
import { usePluginActions } from '../../../hooks/usePluginActions';
import Badge from '../../common/Badge/Badge';
import Button from '../../common/Button/Button';
import './PluginDetails.css';
interface PluginDetailsProps {
plugin: Plugin;
onClose: () => void;
}
// Add a function to create repository URL
const getRepositoryUrl = (plugin: Plugin): string | null => {
// Return existing repository URL if available
if (plugin.repository_url) {
return plugin.repository_url;
}
// Try to construct URL based on repository source and ID
if (plugin.repository_source && plugin.repository_id) {
switch (plugin.repository_source) {
case 'SpigotMC':
return `https://www.spigotmc.org/resources/${plugin.repository_id}`;
case 'Modrinth':
return `https://modrinth.com/plugin/${plugin.repository_id}`;
case 'GitHub':
return `https://github.com/${plugin.repository_id}/releases`;
case 'HangarMC':
return `https://hangar.papermc.io/plugins/${plugin.repository_id}`;
default:
return null;
}
}
return null;
};
export const PluginDetails: React.FC<PluginDetailsProps> = ({ plugin, onClose }) => {
const { updatePlugin, checkSinglePlugin, pluginLoadingStates } = usePluginActions();
// Check if this specific plugin is being checked or updated
const isLoading = !!pluginLoadingStates[plugin.file_path];
// Handle escape key to close modal
const handleEscapeKey = useCallback((event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
}, [onClose]);
// Handle clicking outside the modal to close
const handleOverlayClick = useCallback((event: React.MouseEvent) => {
if (event.target === event.currentTarget) {
onClose();
}
}, [onClose]);
// Add event listener for escape key
useEffect(() => {
document.addEventListener('keydown', handleEscapeKey);
return () => {
document.removeEventListener('keydown', handleEscapeKey);
};
}, [handleEscapeKey]);
const handleCheckUpdate = async () => {
if (isLoading) return; // Prevent multiple clicks
try {
console.log(`Checking for updates for plugin: ${plugin.name}`);
await checkSinglePlugin(plugin);
} catch (error) {
console.error(`Error checking updates for plugin ${plugin.name}:`, error);
}
};
const handleUpdate = async () => {
if (isLoading) return; // Prevent multiple clicks
try {
console.log(`Updating plugin: ${plugin.name}`);
await updatePlugin(plugin);
// Note: Any success/error notifications will be handled by the usePluginActions hook
} catch (error) {
console.error(`Error updating plugin ${plugin.name}:`, error);
}
};
const formatDate = (timestamp: number | null): string => {
if (!timestamp) return 'Unknown';
return new Date(timestamp).toLocaleDateString();
};
const renderDependencies = () => {
const dependencies = [
...(plugin.depend || []),
...(plugin.soft_depend || [])
].filter(Boolean);
if (dependencies.length === 0) {
return <p className="no-dependencies">No dependencies</p>;
}
return (
<ul className="dependency-list">
{dependencies.map((dep, index) => (
<li key={index}>
<span className="dependency-name">{dep}</span>
</li>
))}
</ul>
);
};
const getSizeInMB = (bytes: number | undefined): string => {
if (!bytes) return 'Unknown';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
};
return (
<div className="plugin-details-overlay" onClick={handleOverlayClick}>
<div className="plugin-details-container" role="dialog" aria-labelledby="plugin-details-title">
<div className="plugin-details-header">
<div>
<h2 id="plugin-details-title" className="plugin-name">{plugin.name}</h2>
<div className="plugin-version-info">
<span className="version">
Version: {
plugin.repository_url || (plugin.repository_source && plugin.repository_id) ? (
<a
href={getRepositoryUrl(plugin) || '#'}
target="_blank"
rel="noopener noreferrer"
className="version-link"
>
{plugin.version}
</a>
) : plugin.version
}
</span>
{plugin.has_update && plugin.latest_version && (
<div className="update-info">
<Badge
label="Update Available"
variant="warning"
size="small"
/>
<span className="latest-version">
Latest: {
plugin.repository_url || (plugin.repository_source && plugin.repository_id) ? (
<a
href={getRepositoryUrl(plugin) || '#'}
target="_blank"
rel="noopener noreferrer"
className="version-link"
>
{plugin.latest_version}
</a>
) : plugin.latest_version
}
</span>
</div>
)}
</div>
</div>
<button
className="close-button"
onClick={onClose}
aria-label="Close details"
>
×
</button>
</div>
<div className="plugin-details-content">
<div className="plugin-details-section">
<h3>Description</h3>
<p className="plugin-description">
{plugin.description || 'No description available.'}
</p>
</div>
<div className="plugin-details-columns">
<div className="plugin-details-column">
<div className="plugin-details-section">
<h3>Author Information</h3>
<p className="plugin-authors">
{plugin.authors && plugin.authors.length > 0
? plugin.authors.join(', ')
: 'Unknown author'}
</p>
{plugin.website && (
<a
href={plugin.website}
target="_blank"
rel="noopener noreferrer"
className="plugin-website"
>
Visit Website
</a>
)}
</div>
<div className="plugin-details-section">
<h3>Dependencies</h3>
{renderDependencies()}
</div>
</div>
<div className="plugin-details-column">
<div className="plugin-details-section">
<h3>File Information</h3>
<div className="file-info-item">
<span className="file-info-label">Path:</span>
<span className="file-info-value file-path">{plugin.file_path}</span>
</div>
<div className="file-info-item">
<span className="file-info-label">Hash:</span>
<span className="file-info-value">{plugin.file_hash.substring(0, 10)}...</span>
</div>
{plugin.repository_source && (
<div className="file-info-item">
<span className="file-info-label">Source:</span>
<span className="file-info-value">
{plugin.repository_url || getRepositoryUrl(plugin) ? (
<a
href={getRepositoryUrl(plugin) || '#'}
target="_blank"
rel="noopener noreferrer"
>
{plugin.repository_source}
</a>
) : plugin.repository_source}
</span>
</div>
)}
{plugin.platform_compatibility && Object.keys(plugin.platform_compatibility).length > 0 && (
<div className="file-info-item">
<span className="file-info-label">Compatible with:</span>
<div className="platform-badges">
{Object.entries(plugin.platform_compatibility).map(([platform, isCompatible]) => (
<span
key={platform}
className="platform-badge"
data-server-type={platform}
title={`${isCompatible ? 'Compatible' : 'May not be compatible'} with ${platform}`}
>
{platform}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
<div className="plugin-actions-section">
{plugin.has_update ? (
<Button
variant="warning"
onClick={handleUpdate}
disabled={isLoading}
isLoading={isLoading}
tooltip={`Update ${plugin.name} to version ${plugin.latest_version}`}
aria-label={`Update ${plugin.name} to version ${plugin.latest_version}`}
>
Update to {plugin.latest_version}
</Button>
) : (
<Button
variant="outline"
onClick={handleCheckUpdate}
disabled={isLoading}
isLoading={isLoading}
tooltip={`Check for updates for ${plugin.name}`}
aria-label={`Check for updates for ${plugin.name}`}
>
Check for Updates
</Button>
)}
</div>
</div>
</div>
);
};
export default PluginDetails;

View File

@ -0,0 +1,172 @@
.plugin-item {
display: grid;
grid-template-columns: 3fr 1fr 1.5fr 1.5fr;
align-items: center;
background-color: var(--surface-color);
border-bottom: 1px solid var(--border-color);
padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3);
transition: background-color 0.2s;
}
.plugin-item:last-child {
border-bottom: none;
}
.plugin-item:hover {
background-color: rgba(255, 255, 255, 0.03);
}
.plugin-item.has-update {
background-color: rgba(255, 152, 0, 0.08);
}
.plugin-item.has-update:hover {
background-color: rgba(255, 152, 0, 0.12);
}
.plugin-name-column {
padding-right: calc(var(--spacing-unit) * 2);
min-width: 0;
}
.plugin-version-column {
padding: 0 calc(var(--spacing-unit));
}
.plugin-compatibility-column {
padding: 0 calc(var(--spacing-unit));
}
.plugin-actions-column {
justify-self: end;
text-align: right;
}
.plugin-name {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.plugin-version {
display: flex;
align-items: center;
gap: var(--spacing-unit);
font-size: 0.95rem;
}
.current-version {
color: var(--text-secondary-color);
}
.version-arrow {
color: var(--text-secondary-color);
font-size: 0.8rem;
}
.latest-version {
color: var(--success-color);
font-weight: 500;
}
.plugin-description {
margin: calc(var(--spacing-unit) * 0.75) 0;
font-size: 0.9rem;
color: var(--text-secondary-color);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.plugin-authors {
font-size: 0.85rem;
color: var(--text-secondary-color);
margin-top: calc(var(--spacing-unit) * 0.5);
}
.plugin-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: calc(var(--spacing-unit));
}
.compatibility-unknown {
color: var(--text-secondary-color);
font-size: 0.9rem;
font-style: italic;
}
.platform-badges {
display: flex;
flex-wrap: wrap;
gap: calc(var(--spacing-unit) * 0.5);
}
.platform-badge {
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
padding: calc(var(--spacing-unit) * 0.25) calc(var(--spacing-unit) * 0.75);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
}
.platform-badge[data-server-type="Paper"] {
background-color: #3498db;
}
.platform-badge[data-server-type="Spigot"] {
background-color: #e67e22;
}
.platform-badge[data-server-type="Bukkit"] {
background-color: #2ecc71;
}
/* Custom styles for plugin action buttons */
.info-button {
background-color: var(--primary-color);
color: white;
}
.update-button {
background-color: var(--warning-color);
color: white;
}
/* Responsive styles */
@media (max-width: 900px) {
.plugin-item {
grid-template-columns: 1fr;
gap: calc(var(--spacing-unit) * 1.5);
padding: calc(var(--spacing-unit) * 2);
}
.plugin-name-column,
.plugin-version-column,
.plugin-compatibility-column,
.plugin-actions-column {
padding: 0;
}
.plugin-actions-column {
justify-self: start;
text-align: left;
}
.plugin-actions {
padding-top: var(--spacing-unit);
}
}

View File

@ -0,0 +1,159 @@
import React, { useCallback, memo } from 'react';
import { Plugin } from '../../../types/plugin.types';
import { usePluginActions } from '../../../hooks/usePluginActions';
import Badge from '../../common/Badge/Badge';
import Button from '../../common/Button/Button';
import './PluginItem.css';
interface PluginItemProps {
plugin: Plugin;
onSelect?: (plugin: Plugin) => void;
}
const PluginItemComponent: React.FC<PluginItemProps> = ({ plugin, onSelect }) => {
const { updatePlugin, checkSinglePlugin, pluginLoadingStates } = usePluginActions();
// Check if this specific plugin is being checked or updated
const isLoading = !!pluginLoadingStates[plugin.file_path];
const handlePluginClick = useCallback(() => {
if (onSelect) {
onSelect(plugin);
}
}, [onSelect, plugin]);
const handleUpdateClick = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent triggering the parent click
if (isLoading) return; // Prevent multiple clicks while loading
try {
console.log(`Updating plugin: ${plugin.name}`);
await updatePlugin(plugin);
} catch (error) {
console.error(`Error updating plugin ${plugin.name}:`, error);
}
}, [updatePlugin, plugin, isLoading]);
const handleCheckUpdateClick = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent triggering the parent click
if (isLoading) return; // Prevent multiple clicks while loading
try {
console.log(`Checking for updates for plugin: ${plugin.name}`);
await checkSinglePlugin(plugin);
} catch (error) {
console.error(`Error checking updates for plugin ${plugin.name}:`, error);
}
}, [checkSinglePlugin, plugin, isLoading]);
const handleInfoClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation(); // Prevent triggering the parent click
if (onSelect) {
console.log(`Viewing details for plugin: ${plugin.name}`);
onSelect(plugin);
}
}, [onSelect, plugin]);
// Get the plugin compatibility information for display, if available
const compatibility = plugin.platform_compatibility ? Object.keys(plugin.platform_compatibility).length > 0 : false;
return (
<div className={`plugin-item ${plugin.has_update ? 'has-update' : ''}`}>
{/* Plugin Name Column */}
<div className="plugin-name-column">
<h3 className="plugin-name" title={plugin.name}>{plugin.name}</h3>
{plugin.authors && plugin.authors.length > 0 && (
<div className="plugin-authors">
By {plugin.authors.join(', ')}
</div>
)}
{plugin.description && (
<p className="plugin-description" title={plugin.description}>
{plugin.description.length > 100
? `${plugin.description.substring(0, 100)}...`
: plugin.description}
</p>
)}
</div>
{/* Version Column */}
<div className="plugin-version-column">
<div className="plugin-version">
<span className="current-version" title={`Current version: ${plugin.version}`}>v{plugin.version}</span>
{plugin.has_update && plugin.latest_version && (
<>
<span className="version-arrow"></span>
<span className="latest-version" title={`Latest version: ${plugin.latest_version}`}>v{plugin.latest_version}</span>
</>
)}
</div>
</div>
{/* Compatibility Column */}
<div className="plugin-compatibility-column">
{compatibility ? (
<div className="platform-badges">
{Object.entries(plugin.platform_compatibility || {}).map(([platform, isCompatible]) => (
<span
key={platform}
className="platform-badge"
data-server-type={platform}
title={`${isCompatible ? 'Compatible' : 'May not be compatible'} with ${platform}`}
>
{platform}
</span>
))}
</div>
) : (
<span className="compatibility-unknown">Unknown</span>
)}
</div>
{/* Actions Column */}
<div className="plugin-actions-column">
<div className="plugin-actions">
<Button
variant="primary"
size="small"
onClick={handleInfoClick}
className="info-button"
tooltip={`View details for ${plugin.name}`}
aria-label={`View details for ${plugin.name}`}
>
Info
</Button>
{plugin.has_update ? (
<Button
variant="warning"
size="small"
onClick={handleUpdateClick}
disabled={isLoading}
isLoading={isLoading}
className="update-button"
tooltip={`Update ${plugin.name} to version ${plugin.latest_version}`}
aria-label={`Update ${plugin.name} to version ${plugin.latest_version}`}
>
Update
</Button>
) : (
<Button
variant="outline"
size="small"
onClick={handleCheckUpdateClick}
disabled={isLoading}
isLoading={isLoading}
className="check-button"
tooltip={`Check for updates for ${plugin.name}`}
aria-label={`Check for updates for ${plugin.name}`}
>
Check
</Button>
)}
</div>
</div>
</div>
);
};
export const PluginItem = memo(PluginItemComponent);

View File

@ -0,0 +1,164 @@
.plugin-list-container {
width: 100%;
}
.plugin-list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background-color: var(--surface-color);
}
.plugin-list-title {
margin: 0;
font-size: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-color);
}
.plugin-count {
font-size: 1rem;
color: var(--text-secondary-color);
font-weight: normal;
}
.outdated-badge {
background-color: var(--warning-color);
color: white;
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-weight: bold;
margin-left: 0.5rem;
}
.plugin-list-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.search-container {
position: relative;
width: 100%;
padding: 1rem 1.5rem;
background-color: transparent;
}
.search-input {
width: 100%;
padding: 0.6rem 2rem 0.6rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--background-color);
color: var(--text-color);
font-size: 0.9rem;
transition: all 0.2s;
}
.search-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
}
.clear-search {
position: absolute;
right: 2rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-secondary-color);
font-size: 1.2rem;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.clear-search:hover {
color: var(--text-color);
background-color: rgba(255, 255, 255, 0.1);
}
.plugin-list-controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.5rem;
background-color: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid var(--border-color);
}
.sort-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.sort-label {
font-size: 0.85rem;
color: var(--text-secondary-color);
font-weight: 500;
}
.sort-button {
background: none;
border: none;
padding: 0.3rem 0.6rem;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
color: var(--text-color);
transition: all 0.2s;
}
.sort-button:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.sort-button.active {
background-color: rgba(26, 115, 232, 0.2);
color: var(--primary-color);
font-weight: 500;
}
.filter-info {
font-size: 0.85rem;
color: var(--text-secondary-color);
}
.plugin-list {
overflow-y: auto;
}
.no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
text-align: center;
color: var(--text-secondary-color);
background-color: var(--surface-color);
min-height: 200px;
}
.no-results p {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.no-results button {
/* Button component will style this */
}

View File

@ -0,0 +1,260 @@
import React, { useState, useMemo } from 'react';
import { usePluginActions } from '../../../hooks/usePluginActions';
import { usePluginContext } from '../../../context/PluginContext/usePluginContext';
import { Plugin } from '../../../types/plugin.types';
import { PluginItem } from '../PluginItem/PluginItem';
import Button from '../../common/Button/Button';
import './PluginList.css';
export const PluginList: React.FC = () => {
const { plugins, isCheckingUpdates } = usePluginActions();
const { showPluginDetails } = usePluginContext();
const [searchTerm, setSearchTerm] = useState<string>('');
const [sortBy, setSortBy] = useState<'name' | 'version' | 'update'>('name');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
const [filterUpdates, setFilterUpdates] = useState<boolean>(false);
// Advanced search with fuzzy matching and relevance scoring
const filteredPlugins = useMemo(() => {
if (!searchTerm.trim() && !filterUpdates) {
return plugins;
}
// Filter by updates if that filter is active
let results = filterUpdates ? plugins.filter(plugin => plugin.has_update) : plugins;
if (!searchTerm.trim()) {
return results;
}
const searchLower = searchTerm.toLowerCase().trim();
const searchTerms = searchLower.split(/\s+/);
// If using multiple search terms, match plugins that contain all terms
if (searchTerms.length > 1) {
return results.filter(plugin => {
// Create a searchable text combining all relevant plugin fields
const searchableText = [
plugin.name,
plugin.version,
plugin.description || '',
plugin.authors?.join(' ') || '',
plugin.website || '',
plugin.main_class || '',
].join(' ').toLowerCase();
// Plugin must match all search terms to be included
return searchTerms.every(term => searchableText.includes(term));
});
}
// For single term search, score each plugin for relevance
const scoredPlugins = results.map(plugin => {
let score = 0;
// Exact name match is highest priority
if (plugin.name.toLowerCase() === searchLower) {
score += 100;
}
// Name starts with search term
else if (plugin.name.toLowerCase().startsWith(searchLower)) {
score += 80;
}
// Name contains search term
else if (plugin.name.toLowerCase().includes(searchLower)) {
score += 60;
}
// Check if search term is an acronym of the plugin name
// E.g., "WE" for "WorldEdit"
if (searchLower.length >= 2 && isAcronymMatch(searchLower, plugin.name)) {
score += 70;
}
// Secondary matches in other fields
if (plugin.description?.toLowerCase().includes(searchLower)) {
score += 40;
}
if (plugin.authors?.some(author => author.toLowerCase().includes(searchLower))) {
score += 50;
}
if (plugin.main_class?.toLowerCase().includes(searchLower)) {
score += 30;
}
if (plugin.version.toLowerCase().includes(searchLower)) {
score += 20;
}
if (plugin.website?.toLowerCase().includes(searchLower)) {
score += 20;
}
// Tags or categories (if available in your data model)
if (plugin.repository_source?.toString().toLowerCase().includes(searchLower)) {
score += 30;
}
return { plugin, score };
});
// Filter plugins that have at least some relevance and sort by score
const relevantPlugins = scoredPlugins
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score)
.map(item => item.plugin);
return relevantPlugins;
}, [plugins, searchTerm, filterUpdates]);
// Memoize sorted plugins for performance
const sortedPlugins = useMemo(() => {
return [...filteredPlugins].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'version':
comparison = a.version.localeCompare(b.version);
break;
case 'update':
// Sort by update status (plugins with updates first)
comparison = (b.has_update ? 1 : 0) - (a.has_update ? 1 : 0);
break;
}
return sortOrder === 'asc' ? comparison : -comparison;
});
}, [filteredPlugins, sortBy, sortOrder]);
// Function to check if search is an acronym of plugin name
const isAcronymMatch = (acronym: string, fullName: string): boolean => {
// Extract capital letters or the first letter of each word
const words = fullName.split(/[\s-_]+/);
// Method 1: First letter of each word
const firstLetters = words.map(word => word.charAt(0).toLowerCase()).join('');
if (firstLetters === acronym) {
return true;
}
// Method 2: Capital letters in a camel case name
const capitals = fullName.replace(/[^A-Z]/g, '').toLowerCase();
if (capitals === acronym) {
return true;
}
// For shorter search terms, check if it's a substring of the first letters
if (acronym.length >= 2 && acronym.length <= 3 && firstLetters.includes(acronym)) {
return true;
}
return false;
};
const handleSort = (criteria: 'name' | 'version' | 'update') => {
if (criteria === sortBy) {
// Toggle sort order if clicking the same criteria
setSortOrder(prevOrder => prevOrder === 'asc' ? 'desc' : 'asc');
} else {
// Set new criteria and default to ascending
setSortBy(criteria);
setSortOrder('asc');
}
};
const toggleUpdateFilter = () => {
setFilterUpdates(!filterUpdates);
};
const clearFilters = () => {
setSearchTerm('');
setFilterUpdates(false);
};
// Get stats for UI feedback
const updateCount = plugins.filter(p => p.has_update).length;
return (
<div className="plugin-list-container">
<div className="plugin-list-header">
<div className="search-and-filter">
<input
type="text"
className="search-input"
placeholder="Search plugins by name, author, description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="filter-options">
<Button
variant={filterUpdates ? "primary" : "secondary"}
size="small"
onClick={toggleUpdateFilter}
>
{filterUpdates ? "Showing Updates" : "Show Updates Only"}
{updateCount > 0 && `(${updateCount})`}
</Button>
{(searchTerm || filterUpdates) && (
<Button
variant="secondary"
size="small"
onClick={clearFilters}
>
Clear Filters
</Button>
)}
</div>
</div>
<div className="sort-controls">
<span className="sort-label">Sort by:</span>
<Button
variant={sortBy === 'name' ? 'primary' : 'secondary'}
size="small"
onClick={() => handleSort('name')}
>
Name {sortBy === 'name' && (sortOrder === 'asc' ? '↑' : '↓')}
</Button>
<Button
variant={sortBy === 'version' ? 'primary' : 'secondary'}
size="small"
onClick={() => handleSort('version')}
>
Version {sortBy === 'version' && (sortOrder === 'asc' ? '↑' : '↓')}
</Button>
<Button
variant={sortBy === 'update' ? 'primary' : 'secondary'}
size="small"
onClick={() => handleSort('update')}
>
Update Status {sortBy === 'update' && (sortOrder === 'asc' ? '↓' : '↑')}
</Button>
</div>
</div>
<div className="plugin-list">
{sortedPlugins.length > 0 ? (
sortedPlugins.map(plugin => (
<PluginItem
key={plugin.file_path}
plugin={plugin}
onSelect={showPluginDetails}
/>
))
) : (
<div className="no-results">
<p>No plugins match your search criteria.</p>
<Button variant="secondary" onClick={clearFilters}>Clear All Filters</Button>
</div>
)}
</div>
</div>
);
};
export default PluginList;

View File

@ -0,0 +1,78 @@
.scan-progress {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.25rem;
background-color: var(--color-background);
border-radius: 6px;
border: 1px solid var(--color-border);
animation: fade-in 0.3s ease-out;
}
.scan-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.scan-progress-title {
font-weight: 500;
color: var(--color-text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.scan-progress-title::before {
content: '';
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--color-primary);
animation: pulse 1.5s infinite;
}
.scan-progress-percentage {
font-weight: bold;
color: var(--color-primary);
}
.scan-progress-details {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
}
.scan-progress-count {
white-space: nowrap;
}
@keyframes pulse {
0% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -0,0 +1,50 @@
import React from 'react';
import ProgressBar from '../../common/ProgressBar/ProgressBar';
import './ScanProgress.css';
interface ScanProgressProps {
progress: { current: number; total: number } | null;
}
export const ScanProgress: React.FC<ScanProgressProps> = ({ progress }) => {
// Add console logging for debugging
React.useEffect(() => {
console.log("ScanProgress component received progress:", progress);
}, [progress]);
const calculatePercentage = (): number => {
if (!progress) return 0;
if (progress.total <= 0) return 0; // Handle case where total is 0 or negative
// Make sure current is at least 0 to prevent NaN
const current = Math.max(0, progress.current);
return Math.min(Math.round((current / progress.total) * 100), 100);
};
const percentage = calculatePercentage();
return (
<div className="scan-progress">
<div className="scan-progress-header">
<span className="scan-progress-title">Scanning for plugins...</span>
<span className="scan-progress-percentage">{percentage}%</span>
</div>
<ProgressBar
value={percentage}
color="primary"
showPercentage={false}
/>
{progress && (
<div className="scan-progress-details">
<span className="scan-progress-count">
{progress.current} of {progress.total} files processed
</span>
</div>
)}
</div>
);
};
export default ScanProgress;

View File

@ -0,0 +1,109 @@
.server-info-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background-color: var(--surface-color);
border-radius: 6px;
border: 1px solid var(--border-color);
margin-top: 1.5rem;
}
.server-info-header {
display: flex;
align-items: center;
gap: 1rem;
}
.server-icon {
font-size: 2rem;
background-color: var(--background-color);
width: 4rem;
height: 4rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid var(--border-color);
flex-shrink: 0;
}
.server-details {
flex: 1;
}
.server-type-name {
font-size: 1.2rem;
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-color);
}
.server-path {
margin: 0;
font-size: 0.9rem;
color: var(--text-secondary-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.server-path.clickable {
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition: color 0.2s;
width: fit-content;
}
.server-path.clickable:hover {
color: var(--primary-color);
text-decoration: underline;
}
.folder-icon {
font-size: 1rem;
}
.server-stats {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.stat-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat-label {
font-size: 0.8rem;
color: var(--text-secondary-color);
text-transform: uppercase;
}
.stat-value {
font-size: 0.95rem;
font-weight: 500;
color: var(--text-color);
}
.server-warning {
margin-top: 0.5rem;
padding: 0.75rem;
background-color: rgba(255, 152, 0, 0.1);
border-radius: 4px;
border-left: 4px solid var(--warning-color);
}
.server-warning p {
margin: 0;
font-size: 0.9rem;
color: var(--warning-color);
}

View File

@ -0,0 +1,126 @@
import React from 'react';
import { ServerInfo as ServerInfoType } from '../../../types/server.types';
import Badge from '../../common/Badge/Badge';
import './ServerInfo.css';
// Use the shell.open for opening directories
import { open } from '@tauri-apps/plugin-shell';
import { useServerContext } from '../../../context/ServerContext/useServerContext';
interface ServerInfoProps {
serverInfo: ServerInfoType;
}
export const ServerInfo: React.FC<ServerInfoProps> = ({ serverInfo }) => {
const { serverPath } = useServerContext();
const getServerTypeIcon = () => {
switch (serverInfo.server_type.toLowerCase()) {
case 'paper':
return '📄';
case 'spigot':
return '🔌';
case 'bukkit':
return '🪣';
case 'purpur':
return '🟣';
case 'fabric':
return '🧵';
case 'forge':
return '⚒️';
default:
return '🖥️';
}
};
const formatServerType = (type: string): string => {
return type.charAt(0).toUpperCase() + type.slice(1);
};
const getStatusColor = (): 'success' | 'warning' | 'error' => {
const version = serverInfo.minecraft_version;
if (!version) return 'warning';
// Parse version numbers like 1.19.2
const parts = version.split('.');
const major = parseInt(parts[0] || '0');
const minor = parseInt(parts[1] || '0');
// Check if using recent version (arbitrary definition)
if (major >= 1 && minor >= 18) {
return 'success';
} else if (major >= 1 && minor >= 16) {
return 'warning';
} else {
return 'error';
}
};
// Function to open the server directory in file explorer
const handleOpenDirectory = async () => {
try {
console.log('Attempting to open directory:', serverPath);
// Format the path as a proper file:// URL
let dirPath = serverPath;
// Convert to the proper URL format based on path type
if (dirPath.startsWith('\\\\')) {
// UNC path (network location)
// Format: file:////server/share/path (4 slashes)
dirPath = `file:////` + dirPath.replace(/\\/g, '/').substring(2);
} else {
// Local path
// Format: file:///C:/path/to/dir (3 slashes)
dirPath = `file:///${dirPath.replace(/\\/g, '/')}`;
}
console.log('Opening directory with path:', dirPath);
// Open the directory in system's file explorer
await open(dirPath);
console.log('Successfully opened directory');
} catch (error) {
console.error('Failed to open directory:', error);
}
};
return (
<div className="server-info-container">
<div className="server-info-header">
<div className="server-icon">{getServerTypeIcon()}</div>
<div className="server-details">
<h3 className="server-type-name">
{formatServerType(serverInfo.server_type)}
<Badge
label={serverInfo.minecraft_version || 'Unknown Version'}
variant={getStatusColor()}
size="small"
/>
</h3>
<p
className="server-path clickable"
onClick={handleOpenDirectory}
title="Click to open folder"
>
Server directory <span className="folder-icon">📂</span>
</p>
</div>
</div>
<div className="server-stats">
<div className="stat-item">
<span className="stat-label">Plugins Directory</span>
<span className="stat-value">{serverInfo.plugins_directory}</span>
</div>
<div className="stat-item">
<span className="stat-label">Plugins Found</span>
<span className="stat-value">{serverInfo.plugins_count}</span>
</div>
</div>
</div>
);
};
export default ServerInfo;

View File

@ -0,0 +1,32 @@
.server-selector {
display: flex;
flex-direction: column;
gap: 1.5rem;
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 1.5rem;
background-color: var(--surface-color);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
}
.server-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.server-selector-error {
padding: 0.75rem 1rem;
background-color: rgba(244, 67, 54, 0.1);
border-radius: 4px;
border-left: 4px solid var(--error-color);
}
.error-message {
color: var(--error-color);
margin: 0;
font-size: 0.9rem;
}

View File

@ -0,0 +1,98 @@
import React, { useEffect } from 'react';
import { useServerActions } from '../../../hooks/useServerActions';
import { usePluginContext } from '../../../context/PluginContext/usePluginContext';
import Button from '../../common/Button/Button';
import ScanProgress from '../ScanProgress/ScanProgress';
import './ServerSelector.css';
export const ServerSelector: React.FC = () => {
const {
serverPath,
isScanning,
scanComplete,
scanProgress,
error,
selectDirectory,
scanForPlugins,
lastScanResult
} = useServerActions();
const { setPlugins } = usePluginContext();
// Effect to handle scan results
useEffect(() => {
if (lastScanResult && lastScanResult.plugins) {
console.log("Setting plugins from lastScanResult in ServerSelector");
setPlugins(lastScanResult.plugins);
}
}, [lastScanResult, setPlugins]);
const handleSelectDirectory = async () => {
await selectDirectory();
};
const handleScanForPlugins = async () => {
await scanForPlugins();
};
const handleResetSelection = () => {
// Clear server path and plugins
setPlugins([]);
};
return (
<div className="server-selector">
<div className="server-actions">
<Button
onClick={handleSelectDirectory}
disabled={isScanning}
variant="primary"
tooltip="Choose the root folder of your Minecraft server"
>
Select Server Directory
</Button>
{serverPath && !isScanning && (
<Button
onClick={handleScanForPlugins}
disabled={!serverPath || isScanning}
isLoading={isScanning}
variant="success"
tooltip="Scan the selected server directory for plugins"
>
Scan for Plugins
</Button>
)}
{serverPath && scanComplete && (
<Button
onClick={handleResetSelection}
disabled={isScanning}
variant="secondary"
tooltip="Clear the current server selection and plugin list"
>
Reset
</Button>
)}
</div>
{isScanning && (
<>
{/* Add debug info */}
<div style={{ display: 'none' }}>
<pre>Debug - scanProgress: {JSON.stringify(scanProgress, null, 2)}</pre>
</div>
<ScanProgress progress={scanProgress} />
</>
)}
{error && (
<div className="server-selector-error">
<p className="error-message" role="alert">Error: {error}</p>
</div>
)}
</div>
);
};
export default ServerSelector;

View File

@ -0,0 +1,87 @@
.bulk-update-progress {
background-color: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.75rem 1rem;
margin-top: 0.5rem;
width: 100%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
animation: slide-in 0.3s ease-out;
}
.bulk-update-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.bulk-update-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
display: flex;
align-items: center;
}
.bulk-update-title::before {
content: '';
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--color-primary);
margin-right: 0.5rem;
animation: pulse 1.5s infinite;
}
.bulk-update-count {
font-size: 0.9rem;
color: var(--color-text-muted);
font-weight: 500;
}
.bulk-update-details {
margin-top: 0.75rem;
font-size: 0.9rem;
color: var(--color-text-muted);
}
.current-plugin {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.current-plugin strong {
color: var(--color-text);
}
@keyframes pulse {
0% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -0,0 +1,47 @@
import React from 'react';
import { usePluginContext } from '../../../context/PluginContext/usePluginContext';
import ProgressBar from '../../common/ProgressBar/ProgressBar';
import './BulkUpdateProgress.css';
/**
* Component that displays the progress of bulk plugin updates
*/
export const BulkUpdateProgress: React.FC = () => {
const { bulkUpdateProgress } = usePluginContext();
// If no bulk update is in progress, don't render anything
if (!bulkUpdateProgress) {
return null;
}
const progressPercentage = bulkUpdateProgress.total > 0
? Math.round((bulkUpdateProgress.processed / bulkUpdateProgress.total) * 100)
: 0;
return (
<div className="bulk-update-progress">
<div className="bulk-update-header">
<h3 className="bulk-update-title">
Checking for Updates
</h3>
<span className="bulk-update-count">
{bulkUpdateProgress.processed} of {bulkUpdateProgress.total}
</span>
</div>
<ProgressBar
value={progressPercentage}
color={progressPercentage === 100 ? 'success' : 'primary'}
showPercentage={true}
/>
<div className="bulk-update-details">
<span className="current-plugin">
Processing: <strong>{bulkUpdateProgress.current_plugin_name}</strong>
</span>
</div>
</div>
);
};
export default BulkUpdateProgress;

View File

@ -0,0 +1,81 @@
.compatibility-check-content {
padding: 1rem 0;
}
.compatibility-status {
margin-bottom: 1.5rem;
}
.compatibility-supported,
.compatibility-warning {
display: flex;
align-items: flex-start;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.compatibility-supported {
background-color: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
}
.compatibility-warning {
background-color: rgba(255, 152, 0, 0.1);
border: 1px solid rgba(255, 152, 0, 0.3);
}
.status-icon {
font-size: 1.25rem;
margin-right: 0.75rem;
line-height: 1;
}
.compatibility-status p {
margin: 0;
line-height: 1.5;
}
.plugin-details {
margin-bottom: 1.5rem;
}
.plugin-details h4 {
margin-top: 0;
margin-bottom: 0.75rem;
font-size: 1rem;
}
.plugin-details ul {
margin: 0;
padding-left: 1.5rem;
}
.plugin-details li {
margin-bottom: 0.5rem;
}
.risk-acknowledgment {
margin-bottom: 1.5rem;
}
.checkbox-container {
display: flex;
align-items: center;
cursor: pointer;
}
.checkbox-container input[type="checkbox"] {
margin-right: 0.5rem;
}
.checkbox-label {
font-weight: 500;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}

Some files were not shown because too many files have changed in this diff Show More