Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
0240ab9c50 | |||
61becf8d22 | |||
340ce3d834 | |||
7b772bb1bb | |||
057bba0c56 | |||
a5e7b766ac | |||
1ebb16c15f | |||
ae885f3780 | |||
975258d70e | |||
29f06b197e | |||
2b8821bc77 | |||
b5af6e7e2c | |||
43dafb57da | |||
83be780243 | |||
78f22f65f4 | |||
4adb291592 | |||
fc96a10397 | |||
eb51afaea8 |
@ -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
|
||||
|
||||
|
6
.cursor/rules/roadmap.mdc
Normal file
@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
You must update your current roadmap file before/after EVERY Change.
|
211
PlugSnatcher Frontend Refactoring Plan.md
Normal 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.
|
29
README.md
@ -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.
|
||||
|
||||
|
107
ROADMAP.md
@ -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
|
||||
@ -73,3 +138,5 @@
|
||||
- [ ] Rollback system
|
||||
- [ ] Plugin recommendation system
|
||||
- [ ] Discord webhook integration
|
||||
- [ ] Plugin search and browse functionality
|
||||
- [ ] Plugin install from repository
|
24
UI_UX_Improvement_Task_List.md
Normal 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
@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
57
doc/PLATFORM_COMPATIBILITY.md
Normal 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
@ -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
34
frontend_backend_sync_checklist.md
Normal 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"`.
|
@ -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
@ -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"
|
||||
|
12
package.json
@ -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",
|
||||
|
3
src-tauri/.gitignore
vendored
@ -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
@ -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"
|
||||
|
@ -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": ["**", "//**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 9.0 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.4 KiB |
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 49 KiB |
0
src-tauri/schema.json
Normal file
3
src-tauri/src/commands/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod plugin_commands;
|
||||
pub mod scan_commands;
|
||||
pub mod util_commands;
|
371
src-tauri/src/commands/plugin_commands.rs
Normal 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)
|
||||
}
|
14
src-tauri/src/commands/scan_commands.rs
Normal 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
|
||||
}
|
17
src-tauri/src/commands/util_commands.rs
Normal 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(),
|
||||
}
|
||||
}
|
317
src-tauri/src/crawlers/github.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
227
src-tauri/src/crawlers/hangar.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
43
src-tauri/src/crawlers/mod.rs
Normal 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;
|
357
src-tauri/src/crawlers/modrinth.rs
Normal 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
|
||||
}
|
||||
}
|
422
src-tauri/src/crawlers/spigotmc.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
1060
src-tauri/src/lib.rs
117
src-tauri/src/lib.rs.bak
Normal 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");
|
||||
}
|
@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
plugsnatcher_lib::run()
|
||||
app_lib::run();
|
||||
}
|
||||
|
7
src-tauri/src/models/mod.rs
Normal 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};
|
61
src-tauri/src/models/plugin.rs
Normal 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,
|
||||
}
|
97
src-tauri/src/models/repository.rs
Normal 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;
|
42
src-tauri/src/models/server.rs
Normal 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,
|
||||
}
|
117
src-tauri/src/platform_matcher.rs
Normal 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()
|
||||
)
|
||||
})
|
||||
}
|
212
src-tauri/src/services/http/client.rs
Normal 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(())
|
||||
}
|
||||
}
|
3
src-tauri/src/services/http/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
mod client;
|
||||
|
||||
pub use client::HttpClient;
|
8
src-tauri/src/services/mod.rs
Normal 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};
|
58
src-tauri/src/services/plugin_scanner/file_utils.rs
Normal 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))
|
||||
}
|
||||
}
|
219
src-tauri/src/services/plugin_scanner/metadata_extractor.rs
Normal 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,
|
||||
}
|
||||
}
|
7
src-tauri/src/services/plugin_scanner/mod.rs
Normal 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};
|
411
src-tauri/src/services/plugin_scanner/scanner.rs
Normal 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))
|
||||
}
|
7
src-tauri/src/services/update_manager/mod.rs
Normal 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};
|
162
src-tauri/src/services/update_manager/plugin_updater.rs
Normal 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(¤t_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(¤t_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(¤t_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(¤t_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())
|
||||
}
|
1019
src-tauri/src/services/update_manager/update_checker.rs
Normal file
326
src-tauri/src/services/update_manager/version_utils.rs
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
958
src/App.css
467
src/App.tsx
@ -1,296 +1,235 @@
|
||||
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}>×</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() {
|
||||
useEffect(() => {
|
||||
// Get the app version from the backend
|
||||
const getAppVersion = async () => {
|
||||
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);
|
||||
// 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);
|
||||
|
||||
// Call the Rust backend
|
||||
const result = await invoke<ScanResult>("scan_server_directory", { path: serverPath });
|
||||
|
||||
setServerInfo(result.server_info);
|
||||
setPlugins(result.plugins);
|
||||
setIsScanning(false);
|
||||
setScanComplete(true);
|
||||
} catch (err) {
|
||||
console.error("Error scanning for plugins:", err);
|
||||
setError(err as string);
|
||||
setIsScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const showPluginDetails = (plugin: Plugin) => {
|
||||
setSelectedPlugin(plugin);
|
||||
};
|
||||
|
||||
const closePluginDetails = () => {
|
||||
setSelectedPlugin(null);
|
||||
};
|
||||
getAppVersion();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ServerProvider>
|
||||
<PluginContextWrapper appVersion={appVersion} />
|
||||
</ServerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper to ensure PluginProvider has access to ServerContext
|
||||
*/
|
||||
function PluginContextWrapper({ appVersion }: PluginContextWrapperProps) {
|
||||
const { serverPath, serverInfo } = useServerContext();
|
||||
|
||||
useEffect(() => {
|
||||
console.log("PluginContextWrapper: serverPath =", serverPath);
|
||||
}, [serverPath]);
|
||||
|
||||
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>
|
||||
<MainContent>
|
||||
{/* Server section with selector and info */}
|
||||
<div className="server-section">
|
||||
<ServerSelector />
|
||||
{!isScanning && serverInfo && <ServerInfo serverInfo={serverInfo} />}
|
||||
</div>
|
||||
<button
|
||||
className="scan-button"
|
||||
onClick={scanForPlugins}
|
||||
disabled={isScanning || !serverPath}
|
||||
>
|
||||
{isScanning ? "Scanning..." : "Scan for Plugins"}
|
||||
</button>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
143
src/components/common/Badge/Badge.css
Normal 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;
|
||||
}
|
109
src/components/common/Badge/Badge.tsx
Normal 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);
|
151
src/components/common/Button/Button.css
Normal 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);
|
||||
}
|
||||
}
|
91
src/components/common/Button/Button.tsx
Normal 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);
|
102
src/components/common/Modal/Modal.css
Normal 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;
|
||||
}
|
||||
}
|
114
src/components/common/Modal/Modal.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-content" id="modal-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
@ -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;
|
||||
}
|
@ -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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationDisplay;
|
69
src/components/common/ProgressBar/ProgressBar.css
Normal 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);
|
||||
}
|
114
src/components/common/ProgressBar/ProgressBar.tsx
Normal 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;
|
62
src/components/layout/Footer/Footer.css
Normal 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;
|
||||
}
|
45
src/components/layout/Footer/Footer.tsx
Normal 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;
|
112
src/components/layout/Header/Header.css
Normal 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;
|
||||
}
|
||||
}
|
56
src/components/layout/Header/Header.tsx
Normal 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;
|
78
src/components/layout/MainContent/MainContent.css
Normal 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);
|
||||
}
|
||||
}
|
34
src/components/layout/MainContent/MainContent.tsx
Normal 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;
|
74
src/components/plugins/NoPluginsMessage/NoPluginsMessage.css
Normal 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);
|
||||
}
|
||||
}
|
49
src/components/plugins/NoPluginsMessage/NoPluginsMessage.tsx
Normal 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;
|
228
src/components/plugins/PluginDetails/PluginDetails.css
Normal 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;
|
||||
}
|
||||
}
|
291
src/components/plugins/PluginDetails/PluginDetails.tsx
Normal 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;
|
172
src/components/plugins/PluginItem/PluginItem.css
Normal 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);
|
||||
}
|
||||
}
|
159
src/components/plugins/PluginItem/PluginItem.tsx
Normal 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);
|
164
src/components/plugins/PluginList/PluginList.css
Normal 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 */
|
||||
}
|
260
src/components/plugins/PluginList/PluginList.tsx
Normal 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;
|
78
src/components/server/ScanProgress/ScanProgress.css
Normal 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);
|
||||
}
|
||||
}
|
50
src/components/server/ScanProgress/ScanProgress.tsx
Normal 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;
|
109
src/components/server/ServerInfo/ServerInfo.css
Normal 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);
|
||||
}
|
126
src/components/server/ServerInfo/ServerInfo.tsx
Normal 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;
|
32
src/components/server/ServerSelector/ServerSelector.css
Normal 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;
|
||||
}
|
98
src/components/server/ServerSelector/ServerSelector.tsx
Normal 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;
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
||||
}
|
@ -0,0 +1,112 @@
|
||||
import React, { useState } from 'react';
|
||||
import Modal from '../../common/Modal/Modal';
|
||||
import Button from '../../common/Button/Button';
|
||||
import './CompatibilityCheckDialog.css';
|
||||
import { useServerContext } from '../../../context/ServerContext/useServerContext';
|
||||
import { Plugin } from '../../../types/plugin.types';
|
||||
|
||||
interface CompatibilityCheckDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
plugin: Plugin;
|
||||
onConfirmUpdate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dialog that shows compatibility warnings before updating a plugin
|
||||
*/
|
||||
export const CompatibilityCheckDialog: React.FC<CompatibilityCheckDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
plugin,
|
||||
onConfirmUpdate
|
||||
}) => {
|
||||
const { serverInfo } = useServerContext();
|
||||
const [acknowledgedRisk, setAcknowledgedRisk] = useState(false);
|
||||
|
||||
// Determine if the plugin explicitly supports this server version
|
||||
const serverVersion = serverInfo?.minecraft_version || '';
|
||||
const supportsVersion = plugin.platform_compatibility?.includes(serverVersion) || false;
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirmUpdate();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Compatibility Check"
|
||||
>
|
||||
<div className="compatibility-check-content">
|
||||
<div className="compatibility-status">
|
||||
{supportsVersion ? (
|
||||
<div className="compatibility-supported">
|
||||
<span className="status-icon">✓</span>
|
||||
<p>
|
||||
This plugin update is marked as compatible with your server version ({serverVersion}).
|
||||
It should work correctly after updating.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="compatibility-warning">
|
||||
<span className="status-icon">⚠️</span>
|
||||
<p>
|
||||
This plugin update is not explicitly marked as compatible with your server version ({serverVersion}).
|
||||
Updating may cause compatibility issues or server errors.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="plugin-details">
|
||||
<h4>Plugin Details</h4>
|
||||
<ul>
|
||||
<li><strong>Name:</strong> {plugin.name}</li>
|
||||
<li><strong>Current Version:</strong> {plugin.version}</li>
|
||||
<li><strong>Latest Version:</strong> {plugin.latest_version}</li>
|
||||
{plugin.platform_compatibility && (
|
||||
<li>
|
||||
<strong>Supported Versions:</strong> {plugin.platform_compatibility.join(', ')}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{!supportsVersion && (
|
||||
<div className="risk-acknowledgment">
|
||||
<label className="checkbox-container">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledgedRisk}
|
||||
onChange={() => setAcknowledgedRisk(!acknowledgedRisk)}
|
||||
/>
|
||||
<span className="checkbox-label">
|
||||
I understand the risks and still want to update this plugin
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant="primary"
|
||||
disabled={!supportsVersion && !acknowledgedRisk}
|
||||
>
|
||||
Update Anyway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompatibilityCheckDialog;
|