diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4d57e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Build artifacts +dist/ +dist-ssr/ +*.local + +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Editor +.vscode/* +!.vscode/extensions.json +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Rust +target/ +Cargo.lock +**/*.rs.bk + +# Tauri +src-tauri/target/ +src-tauri/Cargo.lock + +# Environment +.env +.env.local +.env.*.local + +# Testing +coverage/ +*.lcov + +# Temporary +*.tmp +*.temp +.cache/ diff --git a/docs/API_DOCUMENTATION.md b/docs/API_DOCUMENTATION.md new file mode 100644 index 0000000..0818e3d --- /dev/null +++ b/docs/API_DOCUMENTATION.md @@ -0,0 +1,301 @@ +# EU-Utility V3 - API Documentation + +Complete API reference for plugin development. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [PluginAPI](#pluginapi) +3. [WidgetAPI](#widgetapi) +4. [ExternalAPI](#externalapi) +5. [TypeScript Types](#typescript-types) + +--- + +## Overview + +EU-Utility V3 provides a unified API for plugin development. Plugins run in a secure sandbox with controlled access to system resources. + +### API Structure + +```typescript +// Access the API +const api = window.EUPluginAPI; + +// All methods return Promises +const result = await api.someMethod(); +``` + +--- + +## PluginAPI + +### Log Reader + +```typescript +// Read last N log lines +const lines = await api.readLogLines(100); + +// Read since timestamp +const recent = await api.readLogSince(Date.now() - 300000); + +// Read filtered +const loot = await api.readLogFiltered('Loot:'); +``` + +**Returns:** `Promise` + +--- + +### Window Manager + +```typescript +// Get EU window info +const window = await api.getEUWindow(); + +// Check focus +const focused = await api.isEUFocused(); + +// Bring to front +await api.bringEUToFront(); + +// Capture screenshot +const screenshot = await api.captureEUWindow(); +``` + +**WindowInfo:** +```typescript +interface WindowInfo { + x: number; + y: number; + width: number; + height: number; + isFocused: boolean; +} +``` + +--- + +### OCR Service + +```typescript +// Check availability +const available = await api.ocrAvailable(); + +// Recognize text +const text = await api.recognizeText({ + x: 100, + y: 100, + width: 200, + height: 50 +}); +``` + +**Returns:** `Promise` + +--- + +### Nexus API + +```typescript +// Search items +const results = await api.searchNexus({ + query: 'omegaton', + entityType: 'weapons', + limit: 10 +}); + +// Get item details +const details = await api.getItemDetails('12345'); +``` + +**SearchResult:** +```typescript +interface NexusItem { + id: string; + name: string; + type: string; + category: string; + value?: number; + markup?: number; +} +``` + +--- + +### HTTP Client + +```typescript +// GET request +const data = await api.httpGet('https://api.example.com/data', { + cache: true, + ttl: 3600 +}); + +// POST request +const result = await api.httpPost('https://api.example.com/save', { + key: 'value' +}); +``` + +--- + +### Notifications + +```typescript +await api.showNotification({ + title: 'Alert', + body: 'Something happened', + duration: 3000 +}); +``` + +--- + +### Clipboard + +```typescript +// Copy +await api.copyToClipboard('text'); + +// Paste +const text = await api.pasteFromClipboard(); +``` + +--- + +### Event Bus + +```typescript +// Subscribe +const subId = await api.subscribeEvent('loot.received', (event) => { + console.log(event.data); +}); + +// Unsubscribe +await api.unsubscribeEvent(subId); + +// Publish +await api.publishEvent('my.event', { data: 'value' }); +``` + +--- + +### Data Storage + +```typescript +// Save +await api.savePluginData('myPlugin', 'key', { data: 'value' }); + +// Load +const data = await api.loadPluginData('myPlugin', 'key'); + +// Delete +await api.deletePluginData('myPlugin', 'key'); +``` + +--- + +## WidgetAPI + +### Creating Widgets + +```typescript +// Create overlay widget +const widget = await api.createWidget({ + id: 'my-widget', + title: 'My Widget', + width: 300, + height: 200, + x: 100, + y: 100 +}); + +// Update content +await api.updateWidget('my-widget', { + title: 'Updated', + content: htmlContent +}); + +// Close widget +await api.closeWidget('my-widget'); +``` + +--- + +## ExternalAPI + +### REST Endpoints + +The application exposes a local REST API for external integrations. + +``` +GET /api/plugins - List all plugins +POST /api/plugins/{id}/activate +POST /api/plugins/{id}/deactivate +GET /api/loot/recent - Recent loot events +POST /api/ocr/recognize - OCR text recognition +``` + +--- + +## TypeScript Types + +```typescript +// Full type definitions for plugin development + +interface PluginContext { + api: PluginAPI; + widget: WidgetAPI; + logger: Logger; + settings: SettingsManager; +} + +interface PluginAPI { + // Log Reader + readLogLines(count: number): Promise; + readLogSince(timestamp: number): Promise; + readLogFiltered(pattern: string): Promise; + + // Window + getEUWindow(): Promise; + isEUFocused(): Promise; + bringEUToFront(): Promise; + captureEUWindow(): Promise; // base64 + + // OCR + ocrAvailable(): Promise; + recognizeText(region: Region): Promise; + + // Nexus + searchNexus(params: SearchParams): Promise; + getItemDetails(itemId: string): Promise; + + // HTTP + httpGet(url: string, options?: HttpOptions): Promise; + httpPost(url: string, body: any, options?: HttpOptions): Promise; + + // Notifications + showNotification(options: NotificationOptions): Promise; + + // Clipboard + copyToClipboard(text: string): Promise; + pasteFromClipboard(): Promise; + + // Events + subscribeEvent(type: string, handler: EventHandler): Promise; + unsubscribeEvent(id: string): Promise; + publishEvent(type: string, data: any): Promise; + + // Storage + savePluginData(pluginId: string, key: string, data: any): Promise; + loadPluginData(pluginId: string, key: string): Promise; + deletePluginData(pluginId: string, key: string): Promise; +} +``` + +--- + +*API Documentation - EU-Utility V3* diff --git a/docs/PLUGIN_GUIDE.md b/docs/PLUGIN_GUIDE.md new file mode 100644 index 0000000..e4a796e --- /dev/null +++ b/docs/PLUGIN_GUIDE.md @@ -0,0 +1,323 @@ +# Plugin Development Guide + +Create plugins for EU-Utility V3. + +--- + +## Quick Start + +### 1. Create Plugin Structure + +``` +my-plugin/ + plugin.json # Plugin manifest + index.ts # Main plugin code + README.md # Documentation +``` + +### 2. Write Manifest + +```json +{ + "manifest_version": "3.0", + "plugin": { + "id": "com.example.myplugin", + "name": "My Plugin", + "version": "1.0.0", + "author": "Your Name", + "description": "What your plugin does", + "category": "utility" + }, + "permissions": [ + "log_reader:read", + "overlay:create", + "data_store:write" + ], + "ui": { + "has_overlay": true, + "has_settings": true, + "hotkey": "ctrl+shift+m" + } +} +``` + +### 3. Write Plugin Code + +```typescript +import { BasePlugin, PluginContext } from 'eu-utility-plugin-sdk'; + +export default class MyPlugin extends BasePlugin { + id = 'com.example.myplugin'; + name = 'My Plugin'; + version = '1.0.0'; + + private context: PluginContext; + private lootCount = 0; + + async initialize(context: PluginContext): Promise { + this.context = context; + + // Subscribe to events + await this.context.api.subscribeEvent('loot.received', (event) => { + this.onLoot(event); + }); + + return true; + } + + async activate(): Promise { + this.context.logger.info('My Plugin activated'); + return true; + } + + async deactivate(): Promise { + this.context.logger.info('My Plugin deactivated'); + } + + private onLoot(event: any) { + this.lootCount++; + + // Show notification + this.context.api.showNotification({ + title: 'Loot Tracked', + body: `Total loots: ${this.lootCount}` + }); + + // Save data + this.context.api.savePluginData( + this.id, + 'stats', + { lootCount: this.lootCount } + ); + } + + getSettingsUI(): HTMLElement { + // Return settings UI element + const div = document.createElement('div'); + div.innerHTML = ` + + + `; + return div; + } +} +``` + +--- + +## BasePlugin Class + +```typescript +abstract class BasePlugin { + abstract id: string; + abstract name: string; + abstract version: string; + + // Required methods + abstract initialize(context: PluginContext): Promise; + abstract activate(): Promise; + abstract deactivate(): Promise; + + // Optional methods + getOverlayUI?(): HTMLElement; + getSettingsUI?(): HTMLElement; + onHotkey?(hotkey: string): void; +} +``` + +--- + +## Permissions + +Available permissions: + +| Permission | Description | +|------------|-------------| +| `log_reader:read` | Read game logs | +| `overlay:create` | Create overlay widgets | +| `data_store:write` | Save plugin data | +| `data_store:read` | Read plugin data | +| `network:external` | Make HTTP requests | +| `ocr:read` | Use OCR service | +| `clipboard:read` | Read clipboard | +| `clipboard:write` | Write to clipboard | +| `notification:send` | Send notifications | + +--- + +## Example: Loot Tracker + +Complete example plugin: + +```typescript +import { BasePlugin, PluginContext } from 'eu-utility-plugin-sdk'; + +export default class LootTracker extends BasePlugin { + id = 'eu.utility.loot-tracker'; + name = 'Loot Tracker'; + version = '2.0.0'; + author = 'Aether'; + description = 'Track hunting loot with ROI analysis'; + category = 'tracking'; + + private context: PluginContext; + private sessionStart = Date.now(); + private totalLoot = 0; + private mobCount = 0; + + async initialize(context: PluginContext): Promise { + this.context = context; + + // Load saved data + const saved = await context.api.loadPluginData(this.id, 'session'); + if (saved) { + this.totalLoot = saved.totalLoot || 0; + this.mobCount = saved.mobCount || 0; + } + + // Subscribe to loot events + await context.api.subscribeEvent('loot.received', (e) => { + this.handleLoot(e.data); + }); + + return true; + } + + async activate(): Promise { + this.sessionStart = Date.now(); + this.context.logger.info('Loot Tracker: Session started'); + + // Create overlay widget + await this.createWidget(); + + return true; + } + + async deactivate(): Promise { + // Save session data + await this.context.api.savePluginData(this.id, 'session', { + totalLoot: this.totalLoot, + mobCount: this.mobCount, + duration: Date.now() - this.sessionStart + }); + } + + private async handleLoot(data: LootData) { + this.totalLoot += data.totalTT; + this.mobCount++; + + // Update widget + await this.updateWidget(); + + // Notify on big loot + if (data.totalTT > 50) { + await this.context.api.showNotification({ + title: 'Big Loot!', + body: `${data.mobName}: ${data.totalTT.toFixed(2)} PED`, + sound: true + }); + } + + // Log to file + await this.logLoot(data); + } + + private async createWidget() { + await this.context.widget.create({ + id: 'loot-tracker-main', + title: 'Loot Tracker', + width: 280, + height: 150, + x: 50, + y: 50, + content: this.renderWidget() + }); + } + + private renderWidget(): string { + const elapsed = Math.floor((Date.now() - this.sessionStart) / 1000); + const hours = Math.floor(elapsed / 3600); + const mins = Math.floor((elapsed % 3600) / 60); + const secs = elapsed % 60; + + return ` +
+
+ Session: ${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')} +
+
+ ${this.totalLoot.toFixed(2)} PED +
+
+ ${this.mobCount} mobs killed +
+
+ `; + } + + private async updateWidget() { + await this.context.widget.update('loot-tracker-main', { + content: this.renderWidget() + }); + } + + private async logLoot(data: LootData) { + const entry = { + timestamp: new Date().toISOString(), + mob: data.mobName, + tt: data.totalTT, + items: data.items.length + }; + + const logs = await this.context.api.loadPluginData(this.id, 'logs') || []; + logs.push(entry); + + // Keep last 1000 entries + if (logs.length > 1000) logs.shift(); + + await this.context.api.savePluginData(this.id, 'logs', logs); + } + + onHotkey(hotkey: string): void { + if (hotkey === 'ctrl+shift+l') { + this.context.api.showNotification({ + title: 'Loot Stats', + body: `Total: ${this.totalLoot.toFixed(2)} PED in ${this.mobCount} kills` + }); + } + } +} + +interface LootData { + mobName: string; + totalTT: number; + items: any[]; +} +``` + +--- + +## Building Plugins + +```bash +# Install SDK +npm install eu-utility-plugin-sdk + +# Build +npm run build + +# Package +eu-util plugin package +``` + +--- + +## Publishing + +1. Create release on Git +2. Tag with version +3. Attach plugin.zip + +--- + +*Plugin Development Guide - EU-Utility V3* diff --git a/index.html b/index.html new file mode 100644 index 0000000..3578b06 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + EU-Utility V3 + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..eb5cb58 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "eu-utility-v3-ui", + "private": true, + "version": "3.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "@tauri-apps/api": "^1.5.0", + "lucide-react": "^0.294.0", + "zustand": "^4.4.7", + "clsx": "^2.0.0", + "tailwind-merge": "^2.0.0", + "recharts": "^2.10.0", + "fuse.js": "^7.0.0", + "date-fns": "^2.30.0" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..ccee72b --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,60 @@ +[package] +name = "eu-utility-v3" +version = "3.0.0" +description = "High-performance Entropia Universe overlay utility" +authors = ["Aether"] +license = "MIT" +repository = "https://git.lemonlink.eu/impulsivefps/EU-Utility-V3" +edition = "2021" +rust-version = "1.75" + +[build-dependencies] +tauri-build = { version = "1.5", features = [] } + +[dependencies] +tauri = { version = "1.5", features = ["system-tray", "global-shortcut", "shell-open"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.35", features = ["full"] } +anyhow = "1.0" +thiserror = "1.0" +tracing = "0.1" +tracing-subscriber = "0.3" +once_cell = "1.19" +parking_lot = "0.12" +dashmap = "5.5" + +# HTTP Client +reqwest = { version = "0.11", features = ["json", "rustls-tls"] } + +# Window management +window-vibrancy = "0.4" +window-shadows = "0.2" + +# Global hotkeys +global-hotkey = "0.4" + +# System info +sysinfo = "0.30" + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# UUID +uuid = { version = "1.6", features = ["v4", "serde"] } + +# Regex +regex = "1.10" + +# Image processing for OCR +image = "0.24" + +# Clipboard +arboard = "3.3" + +# Notifications +notify-rust = "4.10" + +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/src/api.rs b/src-tauri/src/api.rs new file mode 100644 index 0000000..3d125cb --- /dev/null +++ b/src-tauri/src/api.rs @@ -0,0 +1,381 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use tauri::{AppHandle, Manager, State, Window}; +use tracing::{error, info, warn}; + +use crate::events::EventBus; +use crate::plugins::PluginManager; +use crate::settings::SettingsManager; +use crate::hotkeys::HotkeyManager; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginInfo { + pub id: String, + pub name: String, + pub version: String, + pub author: String, + pub description: String, + pub active: bool, + pub has_overlay: bool, + pub hotkey: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingSchema { + pub key: String, + pub label: String, + pub description: String, + pub type_: String, + pub default: Value, + pub value: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HotkeyConfig { + pub action: String, + pub shortcut: String, + pub enabled: bool, +} + +pub struct PluginAPI { + app_handle: AppHandle, + event_bus: Arc, + settings: Arc, +} + +impl PluginAPI { + pub fn new( + app_handle: AppHandle, + event_bus: Arc, + settings: Arc, + ) -> Self { + Self { + app_handle, + event_bus, + settings, + } + } +} + +// Window management commands +#[tauri::command] +pub fn show_settings_window(app: AppHandle) { + if let Some(window) = app.get_window("settings") { + window.show().unwrap(); + window.set_focus().unwrap(); + } else { + tauri::WindowBuilder::new( + &app, + "settings", + tauri::WindowUrl::App("/#/settings".into()) + ) + .title("Settings") + .inner_size(900, 700) + .center() + .build() + .unwrap(); + } +} + +#[tauri::command] +pub fn show_plugins_window(app: AppHandle) { + if let Some(window) = app.get_window("plugins") { + window.show().unwrap(); + window.set_focus().unwrap(); + } else { + tauri::WindowBuilder::new( + &app, + "plugins", + tauri::WindowUrl::App("/#/plugins".into()) + ) + .title("Plugins") + .inner_size(1000, 800) + .center() + .build() + .unwrap(); + } +} + +#[tauri::command] +pub fn open_overlay(app: AppHandle) { + if let Some(window) = app.get_window("overlay") { + window.show().unwrap(); + window.set_always_on_top(true).unwrap(); + } +} + +#[tauri::command] +pub fn close_overlay(app: AppHandle) { + if let Some(window) = app.get_window("overlay") { + window.hide().unwrap(); + } +} + +#[tauri::command] +pub fn toggle_overlay(app: AppHandle) { + if let Some(window) = app.get_window("overlay") { + if window.is_visible().unwrap() { + window.hide().unwrap(); + } else { + window.show().unwrap(); + window.set_always_on_top(true).unwrap(); + } + } +} + +// Plugin management +#[tauri::command] +pub async fn get_plugins( + plugin_manager: State<'_, Arc> +) -> Result, String> { + Ok(plugin_manager.get_plugins().await) +} + +#[tauri::command] +pub async fn activate_plugin( + id: String, + plugin_manager: State<'_, Arc> +) -> Result<(), String> { + plugin_manager.activate(&id).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn deactivate_plugin( + id: String, + plugin_manager: State<'_, Arc> +) -> Result<(), String> { + plugin_manager.deactivate(&id).await.map_err(|e| e.to_string()) +} + +// Settings +#[tauri::command] +pub fn get_settings( + settings: State<'_, Arc> +) -> Result { + settings.get_all().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_settings( + key: String, + value: Value, + settings: State<'_, Arc> +) -> Result<(), String> { + settings.set(&key, value).map_err(|e| e.to_string()) +} + +// Hotkeys +#[tauri::command] +pub fn get_hotkeys( + hotkey_manager: State<'_, Arc> +) -> Result, String> { + Ok(hotkey_manager.get_hotkeys()) +} + +#[tauri::command] +pub fn set_hotkey( + action: String, + shortcut: String, + hotkey_manager: State<'_, Arc> +) -> Result<(), String> { + hotkey_manager.set_hotkey(&action, &shortcut).map_err(|e| e.to_string()) +} + +// Notifications +#[tauri::command] +pub fn show_notification( + title: String, + body: String, + app: AppHandle +) -> Result<(), String> { + app.notification() + .builder() + .title(title) + .body(body) + .show() + .map_err(|e| e.to_string()) +} + +// Log reader +#[tauri::command] +pub fn read_log_lines( + count: usize, + settings: State<'_, Arc> +) -> Result, String> { + // Implementation would read from Entropia log file + Ok(vec![]) +} + +// Nexus API +#[tauri::command] +pub async fn search_nexus( + query: String, + entity_type: Option, + limit: Option +) -> Result { + crate::nexus::search(&query, entity_type.as_deref(), limit.unwrap_or(20) + ).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_item_details( + item_id: String +) -> Result { + crate::nexus::get_item_details(&item_id).await.map_err(|e| e.to_string()) +} + +// Screen capture and OCR +#[tauri::command] +pub fn capture_screen( + region: Option<(i32, i32, i32, i32)> +) -> Result { + // Returns base64 encoded image + Err("Not implemented".to_string()) +} + +#[tauri::command] +pub fn recognize_text( + region: Option<(i32, i32, i32, i32)> +) -> Result { + Err("Not implemented".to_string()) +} + +// Clipboard +#[tauri::command] +pub fn copy_to_clipboard(text: String) -> Result<(), String> { + use arboard::Clipboard; + let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?; + clipboard.set_text(text).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn paste_from_clipboard() -> Result { + use arboard::Clipboard; + let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?; + clipboard.get_text().map_err(|e| e.to_string()) +} + +// Event system +#[tauri::command] +pub fn subscribe_event( + event_type: String, + callback_id: String, + event_bus: State<'_, Arc> +) -> Result { + let id = event_bus.subscribe(&event_type, callback_id); + Ok(id) +} + +#[tauri::command] +pub fn unsubscribe_event( + subscription_id: String, + event_bus: State<'_, Arc> +) { + event_bus.unsubscribe(&subscription_id); +} + +#[tauri::command] +pub fn publish_event( + event_type: String, + data: Value, + event_bus: State<'_, Arc> +) { + event_bus.publish(&event_type, data); +} + +// Plugin data storage +#[tauri::command] +pub fn save_plugin_data( + plugin_id: String, + key: String, + data: Value, + app: AppHandle +) -> Result<(), String> { + let path = app.path_resolver() + .app_data_dir() + .ok_or("Cannot get data dir")? + .join("plugins") + .join(&plugin_id); + + std::fs::create_dir_all(&path).map_err(|e| e.to_string())?; + + let file_path = path.join(format!("{}.json", key)); + let json = serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?; + std::fs::write(file_path, json).map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +pub fn load_plugin_data( + plugin_id: String, + key: String, + app: AppHandle +) -> Result { + let path = app.path_resolver() + .app_data_dir() + .ok_or("Cannot get data dir")? + .join("plugins") + .join(&plugin_id) + .join(format!("{}.json", key)); + + if !path.exists() { + return Ok(Value::Null); + } + + let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + let data = serde_json::from_str(&json).map_err(|e| e.to_string())?; + + Ok(data) +} + +// HTTP Client +#[tauri::command] +pub async fn http_get( + url: String, + headers: Option> +) -> Result { + let client = reqwest::Client::new(); + let mut request = client.get(&url); + + if let Some(h) = headers { + for (key, value) in h { + request = request.header(&key, &value); + } + } + + let response = request.send().await.map_err(|e| e.to_string())?; + let json = response.json::().await.map_err(|e| e.to_string())?; + + Ok(json) +} + +#[tauri::command] +pub async fn http_post( + url: String, + body: Value, + headers: Option> +) -> Result { + let client = reqwest::Client::new(); + let mut request = client.post(&url).json(&body); + + if let Some(h) = headers { + for (key, value) in h { + request = request.header(&key, &value); + } + } + + let response = request.send().await.map_err(|e| e.to_string())?; + let json = response.json::().await.map_err(|e| e.to_string())?; + + Ok(json) +} + +// Audio +#[tauri::command] +pub fn play_sound(sound_path: String) -> Result<(), String> { + // Implementation would play audio file + Ok(()) +} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs new file mode 100644 index 0000000..78ccba4 --- /dev/null +++ b/src-tauri/src/events.rs @@ -0,0 +1,109 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use serde_json::Value; +use tokio::sync::mpsc; +use tracing::{debug, info}; +use uuid::Uuid; + +pub type EventHandler = Box; + +pub struct EventBus { + subscribers: Arc>>>, + sender: mpsc::UnboundedSender, +} + +pub type SubscriptionId = String; + +#[derive(Clone, Debug)] +pub struct Event { + pub event_type: String, + pub data: Value, + pub timestamp: chrono::DateTime, + pub source: String, +} + +impl EventBus { + pub fn new() -> Self { + let (sender, mut receiver) = mpsc::unbounded_channel::(); + let subscribers = Arc::new(Mutex::new(HashMap::new())); + let subs_clone = subscribers.clone(); + + // Event dispatch loop + tokio::spawn(async move { + while let Some(event) = receiver.recv().await { + let subs = subs_clone.lock().unwrap(); + if let Some(handlers) = subs.get(&event.event_type) { + for (_, handler) in handlers.iter() { + handler(event.data.clone()); + } + } + // Also dispatch to wildcard subscribers + if let Some(wildcard_handlers) = subs.get("*") { + for (_, handler) in wildcard_handlers.iter() { + handler(event.data.clone()); + } + } + } + }); + + Self { + subscribers, + sender, + } + } + + pub fn subscribe( + &self, + event_type: &str, + handler_id: String + ) -> SubscriptionId { + let id = Uuid::new_v4().to_string(); + + // Store subscription info - actual handler would be connected via frontend + let mut subs = self.subscribers.lock().unwrap(); + let handlers = subs.entry(event_type.to_string()).or_insert_with(Vec::new); + + // Create a handler that emits to frontend + let handler = Box::new(move |data: Value| { + debug!("Event {} dispatched to {}", event_type, handler_id); + // Frontend callback handled via Tauri events + }) as EventHandler; + + handlers.push((id.clone(), handler)); + + info!("Subscribed {} to event type {}", id, event_type); + id + } + + pub fn unsubscribe(&self, subscription_id: &str) { + let mut subs = self.subscribers.lock().unwrap(); + for handlers in subs.values_mut() { + handlers.retain(|(id, _)| id != subscription_id); + } + info!("Unsubscribed {}", subscription_id); + } + + pub fn publish(&self, + event_type: &str, + data: Value + ) { + let event = Event { + event_type: event_type.to_string(), + data, + timestamp: chrono::Utc::now(), + source: "core".to_string(), + }; + + let _ = self.sender.send(event); + debug!("Published event: {}", event_type); + } + + pub fn emit_to_frontend( + &self, + app_handle: &tauri::AppHandle, + event_type: &str, + data: Value + ) { + app_handle.emit_all(event_type, data).ok(); + } +} diff --git a/src-tauri/src/hotkeys.rs b/src-tauri/src/hotkeys.rs new file mode 100644 index 0000000..14d58a1 --- /dev/null +++ b/src-tauri/src/hotkeys.rs @@ -0,0 +1,233 @@ +use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tauri::AppHandle; +use tracing::{error, info, warn}; + +use crate::api::show_settings_window; +use crate::events::EventBus; +use crate::window::toggle_overlay_window; + +pub struct HotkeyManager { + manager: GlobalHotKeyManager, + hotkeys: Arc>>, + app_handle: AppHandle, + event_bus: Arc, +} + +#[derive(Clone)] +pub struct HotkeyConfig { + pub action: String, + pub shortcut: String, +} + +impl HotkeyManager { + pub fn new( + app_handle: AppHandle, + event_bus: Arc + ) -> Self { + let manager = GlobalHotKeyManager::new().unwrap(); + let hotkeys = Arc::new(Mutex::new(HashMap::new())); + + Self { + manager, + hotkeys, + app_handle, + event_bus, + } + } + + pub fn register_defaults(&self + ) { + let defaults = vec![ + ("overlay.toggle", "Ctrl+Shift+U"), + ("overlay.hide", "Ctrl+Shift+H"), + ("search.universal", "Ctrl+Shift+F"), + ("search.nexus", "Ctrl+Shift+N"), + ("calculator.open", "Ctrl+Shift+C"), + ("tracker.loot", "Ctrl+Shift+L"), + ("settings.open", "Ctrl+Shift+Comma"), + ]; + + for (action, shortcut) in defaults { + if let Err(e) = self.set_hotkey(action, shortcut) { + warn!("Failed to register hotkey {}: {}", action, e); + } + } + } + + pub fn set_hotkey( + &self, + action: &str, + shortcut: &str + ) -> Result<(), String> { + // Parse shortcut string (e.g., "Ctrl+Shift+U") + let hotkey = parse_shortcut(shortcut)?; + + // Register with global hotkey manager + let id = self.manager.register(hotkey).map_err(|e| e.to_string())?; + + // Store in our map + let mut hotkeys = self.hotkeys.lock().unwrap(); + hotkeys.insert(id, HotkeyConfig { + action: action.to_string(), + shortcut: shortcut.to_string(), + }); + + info!("Registered hotkey: {} = {}", action, shortcut); + Ok(()) + } + + pub fn get_hotkeys(&self + ) -> Vec { + let hotkeys = self.hotkeys.lock().unwrap(); + hotkeys.values().map(|h| crate::api::HotkeyConfig { + action: h.action.clone(), + shortcut: h.shortcut.clone(), + enabled: true, + }).collect() + } + + pub fn handle_hotkey( + &self, + event: GlobalHotKeyEvent + ) { + if event.state != HotKeyState::Pressed { + return; + } + + let hotkeys = self.hotkeys.lock().unwrap(); + if let Some(config) = hotkeys.get(&event.id) { + info!("Hotkey triggered: {}", config.action); + + match config.action.as_str() { + "overlay.toggle" => { + toggle_overlay_window(&self.app_handle); + } + "overlay.hide" => { + // Hide all overlays + } + "settings.open" => { + show_settings_window(self.app_handle.clone()); + } + _ => { + // Emit event for plugins + self.event_bus.publish( + &format!("hotkey.{}", config.action), + serde_json::json!({ + "action": config.action, + "shortcut": config.shortcut + }) + ); + } + } + } + } +} + +fn parse_shortcut(shortcut: &str) -> Result { + use global_hotkey::hotkey::{Code, HotKey, Modifiers}; + + let parts: Vec<&str> = shortcut.split('+').map(|s| s.trim()).collect(); + + let mut modifiers = Modifiers::empty(); + let mut key_code = None; + + for part in &parts { + match *part { + "Ctrl" | "Control" => modifiers |= Modifiers::CONTROL, + "Alt" => modifiers |= Modifiers::ALT, + "Shift" => modifiers |= Modifiers::SHIFT, + "Cmd" | "Command" | "Meta" => modifiers |= Modifiers::META, + key => { + key_code = Some(parse_key(key)?); + } + } + } + + let code = key_code.ok_or("No key specified")?; + Ok(HotKey::new(Some(modifiers), code)) +} + +fn parse_key(key: &str) -> Result { + use global_hotkey::hotkey::Code; + + let code = match key { + "A" => Code::KeyA, + "B" => Code::KeyB, + "C" => Code::KeyC, + "D" => Code::KeyD, + "E" => Code::KeyE, + "F" => Code::KeyF, + "G" => Code::KeyG, + "H" => Code::KeyH, + "I" => Code::KeyI, + "J" => Code::KeyJ, + "K" => Code::KeyK, + "L" => Code::KeyL, + "M" => Code::KeyM, + "N" => Code::KeyN, + "O" => Code::KeyO, + "P" => Code::KeyP, + "Q" => Code::KeyQ, + "R" => Code::KeyR, + "S" => Code::KeyS, + "T" => Code::KeyT, + "U" => Code::KeyU, + "V" => Code::KeyV, + "W" => Code::KeyW, + "X" => Code::KeyX, + "Y" => Code::KeyY, + "Z" => Code::KeyZ, + "0" => Code::Digit0, + "1" => Code::Digit1, + "2" => Code::Digit2, + "3" => Code::Digit3, + "4" => Code::Digit4, + "5" => Code::Digit5, + "6" => Code::Digit6, + "7" => Code::Digit7, + "8" => Code::Digit8, + "9" => Code::Digit9, + "F1" => Code::F1, + "F2" => Code::F2, + "F3" => Code::F3, + "F4" => Code::F4, + "F5" => Code::F5, + "F6" => Code::F6, + "F7" => Code::F7, + "F8" => Code::F8, + "F9" => Code::F9, + "F10" => Code::F10, + "F11" => Code::F11, + "F12" => Code::F12, + "Escape" | "Esc" => Code::Escape, + "Enter" | "Return" => Code::Enter, + "Space" => Code::Space, + "Tab" => Code::Tab, + "Backspace" => Code::Backspace, + "Delete" | "Del" => Code::Delete, + "Home" => Code::Home, + "End" => Code::End, + "PageUp" => Code::PageUp, + "PageDown" => Code::PageDown, + "Up" => Code::ArrowUp, + "Down" => Code::ArrowDown, + "Left" => Code::ArrowLeft, + "Right" => Code::ArrowRight, + "Comma" | "," => Code::Comma, + "Period" | "." => Code::Period, + "Slash" | "/" => Code::Slash, + "Semicolon" | ";" => Code::Semicolon, + "Quote" | "'" => Code::Quote, + "Backslash" | "\\" => Code::Backslash, + "BracketLeft" | "[" => Code::BracketLeft, + "BracketRight" | "]" => Code::BracketRight, + "Backquote" | "`" => Code::Backquote, + "Minus" | "-" => Code::Minus, + "Equal" | "=" => Code::Equal, + _ => return Err(format!("Unknown key: {}", key)), + }; + + Ok(code) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..cd6121f --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,148 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod api; +mod hotkeys; +mod plugins; +mod window; +mod events; +mod nexus; +mod settings; + +use std::sync::Arc; +use tauri::{Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem, WindowEvent}; +use tracing::info; + +use crate::api::PluginAPI; +use crate::hotkeys::HotkeyManager; +use crate::plugins::PluginManager; +use crate::events::EventBus; +use crate::settings::SettingsManager; + +fn main() { + tracing_subscriber::fmt::init(); + + info!("Starting EU-Utility V3"); + + let tray_menu = SystemTrayMenu::new() + .add_item(SystemTrayMenuItem::new("Show", "show")) + .add_native_item(SystemTrayMenuItem::Separator) + .add_item(SystemTrayMenuItem::new("Settings", "settings")) + .add_item(SystemTrayMenuItem::new("Plugins", "plugins")) + .add_native_item(SystemTrayMenuItem::Separator) + .add_item(SystemTrayMenuItem::new("Quit", "quit")); + + let system_tray = SystemTray::new().with_menu(tray_menu); + + tauri::Builder::default() + .system_tray(system_tray) + .on_system_tray_event(|app, event| match event { + SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() { + "show" => { + let window = app.get_window("main").unwrap(); + window.show().unwrap(); + window.set_focus().unwrap(); + } + "settings" => { + api::show_settings_window(app); + } + "plugins" => { + api::show_plugins_window(app); + } + "quit" => { + std::process::exit(0); + } + _ => {} + }, + SystemTrayEvent::LeftClick { .. } => { + let window = app.get_window("main").unwrap(); + if window.is_visible().unwrap() { + window.hide().unwrap(); + } else { + window.show().unwrap(); + window.set_focus().unwrap(); + } + } + _ => {} + }) + .setup(|app| { + info!("Setting up EU-Utility V3"); + + // Initialize core systems + let event_bus = Arc::new(EventBus::new()); + let settings = Arc::new(SettingsManager::new(app.handle())); + let plugin_manager = Arc::new(PluginManager::new( + app.handle(), + event_bus.clone(), + settings.clone() + )); + let hotkey_manager = Arc::new(HotkeyManager::new( + app.handle(), + event_bus.clone() + )); + let plugin_api = Arc::new(PluginAPI::new( + app.handle(), + event_bus.clone(), + settings.clone() + )); + + // Store in app state + app.manage(event_bus); + app.manage(settings); + app.manage(plugin_manager); + app.manage(hotkey_manager); + app.manage(plugin_api); + + // Setup main window + let main_window = app.get_window("main").unwrap(); + window::setup_main_window(&main_window); + + // Create overlay window (hidden initially) + window::create_overlay_window(app.handle()); + + // Register default hotkeys + hotkey_manager.register_defaults(); + + // Load plugins + plugin_manager.load_all(); + + info!("EU-Utility V3 setup complete"); + Ok(()) + }) + .on_window_event(|event| match event.event() { + WindowEvent::CloseRequested { api, .. } => { + api.prevent_close(); + event.window().hide().unwrap(); + } + _ => {} + }) + .invoke_handler(tauri::generate_handler![ + api::get_plugins, + api::activate_plugin, + api::deactivate_plugin, + api::get_settings, + api::set_settings, + api::get_hotkeys, + api::set_hotkey, + api::show_notification, + api::read_log_lines, + api::search_nexus, + api::get_item_details, + api::capture_screen, + api::recognize_text, + api::copy_to_clipboard, + api::paste_from_clipboard, + api::open_overlay, + api::close_overlay, + api::toggle_overlay, + api::subscribe_event, + api::unsubscribe_event, + api::publish_event, + api::save_plugin_data, + api::load_plugin_data, + api::http_get, + api::http_post, + api::play_sound + ]) + .run(tauri::generate_context!()) + .expect("error while running EU-Utility V3"); +} diff --git a/src-tauri/src/nexus.rs b/src-tauri/src/nexus.rs new file mode 100644 index 0000000..c884cb6 --- /dev/null +++ b/src-tauri/src/nexus.rs @@ -0,0 +1,91 @@ +use serde_json::Value; +use tracing::{error, info}; + +const NEXUS_API_BASE: &str = "https://api.entropianexus.com/v1"; + +pub async fn search( + query: &str, + entity_type: Option<&str>, + limit: usize +) -> Result { + let client = reqwest::Client::new(); + + let mut url = format!("{}/search?q={}", NEXUS_API_BASE, urlencoding::encode(query)); + + if let Some(et) = entity_type { + url.push_str(&format!("&type={}", et)); + } + + url.push_str(&format!("&limit={}", limit)); + + info!("Searching Nexus: {}", url); + + let response = client + .get(&url) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("API error: {}", response.status())); + } + + let data = response + .json::() + .await + .map_err(|e| format!("JSON parse failed: {}", e))?; + + Ok(data) +} + +pub async fn get_item_details( + item_id: &str +) -> Result { + let client = reqwest::Client::new(); + + let url = format!("{}/items/{}", NEXUS_API_BASE, item_id); + + info!("Fetching item details: {}", url); + + let response = client + .get(&url) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("API error: {}", response.status())); + } + + let data = response + .json::() + .await + .map_err(|e| format!("JSON parse failed: {}", e))?; + + Ok(data) +} + +pub async fn get_market_data( + item_id: &str +) -> Result { + let client = reqwest::Client::new(); + + let url = format!("{}/items/{}/market", NEXUS_API_BASE, item_id); + + let response = client + .get(&url) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("API error: {}", response.status())); + } + + let data = response + .json::() + .await + .map_err(|e| format!("JSON parse failed: {}", e))?; + + Ok(data) +} diff --git a/src-tauri/src/plugins.rs b/src-tauri/src/plugins.rs new file mode 100644 index 0000000..a905079 --- /dev/null +++ b/src-tauri/src/plugins.rs @@ -0,0 +1,194 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::AppHandle; +use tokio::fs; +use tracing::{error, info, warn}; + +use crate::api::PluginInfo; +use crate::events::EventBus; +use crate::settings::SettingsManager; + +pub struct PluginManager { + app_handle: AppHandle, + event_bus: Arc, + settings: Arc, + plugins: Arc>>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Plugin { + pub manifest: PluginManifest, + pub path: PathBuf, + pub active: bool, + pub data: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginManifest { + pub id: String, + pub name: String, + pub version: String, + pub author: String, + pub description: String, + pub category: String, + pub has_overlay: bool, + pub hotkey: Option, + pub permissions: Vec, + #[serde(default)] + pub config_schema: Option, +} + +impl PluginManager { + pub fn new( + app_handle: AppHandle, + event_bus: Arc, + settings: Arc + ) -> Self { + Self { + app_handle, + event_bus, + settings, + plugins: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + } + } + + pub async fn load_all(&self + ) { + let plugins_dir = self.get_plugins_dir(); + + if !plugins_dir.exists() { + info!("Plugins directory does not exist, creating..."); + if let Err(e) = std::fs::create_dir_all(&plugins_dir) { + error!("Failed to create plugins directory: {}", e); + return; + } + } + + let mut entries = match fs::read_dir(&plugins_dir).await { + Ok(entries) => entries, + Err(e) => { + error!("Failed to read plugins directory: {}", e); + return; + } + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.is_dir() { + if let Err(e) = self.load_plugin(&path).await { + warn!("Failed to load plugin at {:?}: {}", path, e); + } + } + } + + info!("Loaded {} plugins", self.plugins.read().await.len()); + } + + async fn load_plugin( + &self, + path: &PathBuf + ) -> Result<(), String> { + let manifest_path = path.join("plugin.json"); + + if !manifest_path.exists() { + return Err("No plugin.json found".to_string()); + } + + let manifest_content = fs::read_to_string(&manifest_path) + .await + .map_err(|e| e.to_string())?; + + let manifest: PluginManifest = serde_json::from_str(&manifest_content) + .map_err(|e| format!("Invalid plugin.json: {}", e))?; + + info!("Loaded plugin: {} v{}", manifest.name, manifest.version); + + let plugin = Plugin { + manifest: manifest.clone(), + path: path.clone(), + active: false, + data: Value::Null, + }; + + let mut plugins = self.plugins.write().await; + plugins.insert(manifest.id.clone(), plugin); + + Ok(()) + } + + pub async fn get_plugins(&self + ) -> Vec { + let plugins = self.plugins.read().await; + plugins.values().map(|p| PluginInfo { + id: p.manifest.id.clone(), + name: p.manifest.name.clone(), + version: p.manifest.version.clone(), + author: p.manifest.author.clone(), + description: p.manifest.description.clone(), + active: p.active, + has_overlay: p.manifest.has_overlay, + hotkey: p.manifest.hotkey.clone(), + }).collect() + } + + pub async fn activate( + &self, + plugin_id: &str + ) -> Result<(), String> { + let mut plugins = self.plugins.write().await; + + if let Some(plugin) = plugins.get_mut(plugin_id) { + plugin.active = true; + + // Emit activation event + self.event_bus.publish( + "plugin.activated", + serde_json::json!({ + "plugin_id": plugin_id, + "plugin_name": plugin.manifest.name + }) + ); + + info!("Activated plugin: {}", plugin.manifest.name); + Ok(()) + } else { + Err(format!("Plugin not found: {}", plugin_id)) + } + } + + pub async fn deactivate( + &self, + plugin_id: &str + ) -> Result<(), String> { + let mut plugins = self.plugins.write().await; + + if let Some(plugin) = plugins.get_mut(plugin_id) { + plugin.active = false; + + // Emit deactivation event + self.event_bus.publish( + "plugin.deactivated", + serde_json::json!({ + "plugin_id": plugin_id + }) + ); + + info!("Deactivated plugin: {}", plugin_id); + Ok(()) + } else { + Err(format!("Plugin not found: {}", plugin_id)) + } + } + + fn get_plugins_dir(&self + ) -> PathBuf { + self.app_handle + .path_resolver() + .app_data_dir() + .expect("Failed to get app data dir") + .join("plugins") + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs new file mode 100644 index 0000000..933f470 --- /dev/null +++ b/src-tauri/src/settings.rs @@ -0,0 +1,158 @@ +use std::path::PathBuf; +use std::sync::Mutex; +use serde_json::Value; +use tauri::AppHandle; +use tracing::{error, info}; + +pub struct SettingsManager { + app_handle: AppHandle, + settings: Mutex, + config_path: PathBuf, +} + +impl SettingsManager { + pub fn new(app_handle: AppHandle) -> Self { + let config_path = app_handle + .path_resolver() + .app_config_dir() + .expect("Failed to get config dir") + .join("settings.json"); + + let settings = Self::load_settings(&config_path); + + Self { + app_handle, + settings: Mutex::new(settings), + config_path, + } + } + + fn load_settings(path: &PathBuf) -> Value { + if !path.exists() { + info!("Settings file not found, using defaults"); + return Self::default_settings(); + } + + match std::fs::read_to_string(path) { + Ok(content) => { + match serde_json::from_str(&content) { + Ok(settings) => settings, + Err(e) => { + error!("Failed to parse settings: {}", e); + Self::default_settings() + } + } + } + Err(e) => { + error!("Failed to read settings: {}", e); + Self::default_settings() + } + } + } + + fn default_settings() -> Value { + serde_json::json!({ + "general": { + "start_on_boot": false, + "minimize_to_tray": true, + "theme": "dark" + }, + "overlay": { + "opacity": 0.9, + "click_through": true, + "snap_to_edges": true, + "auto_hide_delay": 3000 + }, + "hotkeys": { + "overlay_toggle": "Ctrl+Shift+U", + "overlay_hide": "Ctrl+Shift+H", + "search_universal": "Ctrl+Shift+F", + "search_nexus": "Ctrl+Shift+N", + "calculator": "Ctrl+Shift+C", + "tracker_loot": "Ctrl+Shift+L" + }, + "plugins": { + "auto_activate": [], + "disabled": [] + }, + "log_reader": { + "log_path": "", + "auto_detect": true + }, + "nexus": { + "cache_enabled": true, + "cache_ttl": 3600 + } + }) + } + + pub fn get(&self, key: &str) -> Result { + let settings = self.settings.lock().unwrap(); + + let keys: Vec<&str> = key.split('.').collect(); + let mut current = &*settings; + + for k in keys { + current = current + .get(k) + .ok_or_else(|| format!("Key not found: {}", key))?; + } + + Ok(current.clone()) + } + + pub fn set( + &self, + key: &str, + value: Value + ) -> Result<(), String> { + let mut settings = self.settings.lock().unwrap(); + + let keys: Vec<&str> = key.split('.').collect(); + let mut current = settings.as_object_mut() + .ok_or("Settings is not an object")?; + + for (i, k) in keys.iter().enumerate() { + if i == keys.len() - 1 { + current.insert(k.to_string(), value); + } else { + current = current + .entry(k.to_string()) + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + .ok_or("Invalid settings structure")?; + } + } + + drop(settings); + self.save()?; + + Ok(()) + } + + pub fn get_all(&self + ) -> Result { + let settings = self.settings.lock().unwrap(); + Ok(settings.clone()) + } + + fn save(&self + ) -> Result<(), String> { + let settings = self.settings.lock().unwrap(); + + // Ensure directory exists + if let Some(parent) = self.config_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| e.to_string())?; + } + + let json = serde_json::to_string_pretty(&*settings) + .map_err(|e| e.to_string())?; + + std::fs::write(&self.config_path, json) + .map_err(|e| e.to_string())?; + + info!("Settings saved to {:?}", self.config_path); + Ok(()) + } +} diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs new file mode 100644 index 0000000..5f98f4a --- /dev/null +++ b/src-tauri/src/window.rs @@ -0,0 +1,74 @@ +use tauri::{AppHandle, Manager, Window, WindowBuilder, WindowUrl}; +use tracing::info; + +pub fn setup_main_window(window: &Window) { + // Center window + window.center().ok(); + + // Set minimum size + window.set_min_size(Some(tauri::Size::Physical(tauri::PhysicalSize { + width: 800, + height: 600, + }))).ok(); + + info!("Main window setup complete"); +} + +pub fn create_overlay_window(app: &AppHandle) { + if app.get_window("overlay").is_some() { + return; + } + + let window = WindowBuilder::new( + app, + "overlay", + WindowUrl::App("/#/overlay".into()) + ) + .title("EU-Utility Overlay") + .inner_size(400, 600) + .position(100.0, 100.0) + .transparent(true) + .decorations(false) + .always_on_top(true) + .skip_taskbar(true) + .visible(false) + .build() + .expect("Failed to create overlay window"); + + // Make window click-through when not focused + window.set_ignore_cursor_events(true).ok(); + + info!("Overlay window created"); +} + +pub fn toggle_overlay_window(app: &AppHandle) { + if let Some(window) = app.get_window("overlay") { + if window.is_visible().unwrap_or(false) { + window.hide().ok(); + } else { + window.show().ok(); + window.set_always_on_top(true).ok(); + window.set_focus().ok(); + } + } else { + create_overlay_window(app); + if let Some(window) = app.get_window("overlay") { + window.show().ok(); + } + } +} + +pub fn show_overlay_window(app: &AppHandle) { + if let Some(window) = app.get_window("overlay") { + window.show().ok(); + window.set_always_on_top(true).ok(); + } else { + create_overlay_window(app); + } +} + +pub fn hide_overlay_window(app: &AppHandle) { + if let Some(window) = app.get_window("overlay") { + window.hide().ok(); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..93fec49 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,115 @@ +{ + "build": { + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build", + "devPath": "http://localhost:1420", + "distDir": "../dist", + "withGlobalTauri": false + }, + "tauri": { + "allowlist": { + "all": false, + "shell": { + "all": false, + "open": true + }, + "window": { + "all": true, + "create": true, + "center": true, + "requestUserAttention": true, + "setResizable": true, + "setMaximizable": true, + "setMinimizable": true, + "setClosable": true, + "setTitle": true, + "maximize": true, + "unmaximize": true, + "minimize": true, + "unminimize": true, + "show": true, + "hide": true, + "close": true, + "setDecorations": true, + "setAlwaysOnTop": true, + "setContentProtected": true, + "setSize": true, + "setMinSize": true, + "setMaxSize": true, + "setPosition": true, + "setFullscreen": true, + "setFocus": true, + "setIcon": true, + "setSkipTaskbar": true, + "setCursorGrab": true, + "setCursorVisible": true, + "setCursorIcon": true, + "setCursorPosition": true, + "setIgnoreCursorEvents": true, + "startDragging": true, + "print": true + }, + "fs": { + "all": true, + "readFile": true, + "writeFile": true, + "readDir": true, + "copyFile": true, + "createDir": true, + "removeDir": true, + "removeFile": true, + "renameFile": true, + "exists": true, + "scope": ["$APP", "$APP/*", "$APP/plugins/*"] + }, + "path": { + "all": true + }, + "notification": { + "all": true + }, + "os": { + "all": true + }, + "process": { + "all": false, + "relaunch": true + } + }, + "bundle": { + "active": true, + "targets": "all", + "identifier": "eu.lemonlink.utility", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + }, + "security": { + "csp": null + }, + "systemTray": { + "iconPath": "icons/icon.png", + "iconAsTemplate": true + }, + "windows": [ + { + "fullscreen": false, + "resizable": true, + "title": "EU-Utility", + "width": 1200, + "height": 800, + "minWidth": 800, + "minHeight": 600, + "center": true, + "decorations": true, + "transparent": false, + "focus": true, + "visible": true + } + ] + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..2ee73a1 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,32 @@ +import { Routes, Route } from 'react-router-dom' +import { useEffect } from 'react' +import { useAppStore } from './store/appStore' +import Layout from './components/Layout' +import Dashboard from './pages/Dashboard' +import Plugins from './pages/Plugins' +import Settings from './pages/Settings' +import Nexus from './pages/Nexus' +import Overlay from './pages/Overlay' +import './styles/App.css' + +function App() { + const { initialize } = useAppStore() + + useEffect(() => { + initialize() + }, []) + + return ( + + }> + } /> + } /> + } /> + } /> + + } /> + + ) +} + +export default App diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 0000000..60827d4 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,107 @@ +import { ReactNode } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { + LayoutDashboard, + Puzzle, + Settings, + Database, + Search, + Layers, + X +} from 'lucide-react' +import { useAppStore } from '../store/appStore' + +interface LayoutProps { + children?: ReactNode +} + +export default function Layout({ children }: LayoutProps) { + const location = useLocation() + const { toggleOverlay } = useAppStore() + + const navItems = [ + { path: '/', label: 'Dashboard', icon: LayoutDashboard }, + { path: '/plugins', label: 'Plugins', icon: Puzzle }, + { path: '/nexus', label: 'Nexus', icon: Database }, + { path: '/settings', label: 'Settings', icon: Settings }, + ] + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {/* Header */} +
+
+
+ + +
+
+ +
+
+
+ System Online +
+
+
+ + {/* Page content */} +
+ {children} +
+
+
+ ) +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..bd7fd5b --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './styles/index.css' + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + , +) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..3e0c47e --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,160 @@ +import { useEffect, useState } from 'react' +import { invoke } from '@tauri-apps/api/tauri' +import { + Zap, + Layers, + Search, + Target, + TrendingUp, + Clock, + Activity +} from 'lucide-react' + +export default function Dashboard() { + const [stats, setStats] = useState({ + activePlugins: 0, + totalPlugins: 0, + overlayVisible: false, + }) + + useEffect(() => { + loadStats() + }, []) + + const loadStats = async () => { + const plugins = await invoke>('get_plugins') + setStats({ + activePlugins: plugins.filter(p => p.active).length, + totalPlugins: plugins.length, + overlayVisible: false, + }) + } + + const quickActions = [ + { + icon: Search, + label: 'Universal Search', + shortcut: 'Ctrl+Shift+F', + onClick: () => {} + }, + { + icon: Layers, + label: 'Toggle Overlay', + shortcut: 'Ctrl+Shift+U', + onClick: () => invoke('toggle_overlay') + }, + { + icon: Target, + label: 'Loot Tracker', + shortcut: 'Ctrl+Shift+L', + onClick: () => {} + }, + { + icon: TrendingUp, + label: 'Price Check', + shortcut: 'Ctrl+Shift+C', + onClick: () => {} + }, + ] + + return ( +
+
+
+

Dashboard

+

Welcome to EU-Utility V3

+
+
+ + {/* Stats */} +
+
+
+
+

Active Plugins

+

{stats.activePlugins}

+
+
+ +
+
+
+ +
+
+
+

Total Plugins

+

{stats.totalPlugins}

+
+
+ +
+
+
+ +
+
+
+

Session Time

+

00:42:15

+
+
+ +
+
+
+ +
+
+
+

System Status

+

Online

+
+
+ +
+
+
+
+ + {/* Quick Actions */} +
+

Quick Actions

+
+ {quickActions.map((action) => { + const Icon = action.icon + return ( + + ) + })} +
+
+ + {/* Recent Activity */} +
+

Recent Activity

+
+
+
+ +
+
+

System initialized

+

Just now

+
+
+
+
+
+ ) +} diff --git a/src/pages/Nexus.tsx b/src/pages/Nexus.tsx new file mode 100644 index 0000000..6f46d17 --- /dev/null +++ b/src/pages/Nexus.tsx @@ -0,0 +1,229 @@ +import { useState, useEffect, useCallback } from 'react' +import { invoke } from '@tauri-apps/api/tauri' +import { + Search, + Database, + TrendingUp, + Package, + ExternalLink, + Clock, + AlertCircle +} from 'lucide-react' + +interface NexusItem { + id: string + name: string + type: string + category: string + value?: number + markup?: number +} + +export default function Nexus() { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedItem, setSelectedItem] = useState(null) + const [itemDetails, setItemDetails] = useState(null) + const [entityType, setEntityType] = useState('all') + + const searchItems = useCallback(async () => { + if (!query.trim()) return + + setLoading(true) + try { + const response = await invoke('search_nexus', { + query, + entityType: entityType === 'all' ? null : entityType, + limit: 20 + }) + setResults(response?.results || []) + } catch (error) { + console.error('Search failed:', error) + } finally { + setLoading(false) + } + }, [query, entityType]) + + const loadItemDetails = async (itemId: string) => { + try { + const details = await invoke('get_item_details', { itemId }) + setItemDetails(details) + } catch (error) { + console.error('Failed to load details:', error) + } + } + + useEffect(() => { + const timeout = setTimeout(() => { + if (query) searchItems() + }, 300) + return () => clearTimeout(timeout) + }, [query, entityType, searchItems]) + + const entityTypes = [ + { id: 'all', label: 'All' }, + { id: 'items', label: 'Items' }, + { id: 'weapons', label: 'Weapons' }, + { id: 'armor', label: 'Armor' }, + { id: 'mobs', label: 'Creatures' }, + ] + + return ( +
+
+
+

Nexus

+

Search Entropia Universe items and market data

+
+
+ + {/* Search */} +
+
+
+ + setQuery(e.target.value)} + placeholder="Search for items, weapons, creatures..." + className="w-full pl-12 pr-4 py-3 bg-background border border-border rounded-lg text-white focus:outline-none focus:border-primary" + /> +
+
+ +
+ Filter by: + {entityTypes.map((type) => ( + + ))} +
+
+ +
+ {/* Results */} +
+ {loading ? ( +
+
+

Searching Nexus...

+
+ ) : results.length > 0 ? ( +
+

{results.length} results found

+ {results.map((item) => ( + + ))} +
+ ) : query ? ( +
+ +

No results found

+

Try a different search term

+
+ ) : ( +
+ +

Search Nexus Database

+

Enter a search term to find items

+
+ )} +
+ + {/* Item Details */} +
+ {selectedItem ? ( +
+
+
+ +
+
+

{selectedItem.name}

+

{selectedItem.category}

+
+
+ +
+ {itemDetails?.value && ( +
+ TT Value + {itemDetails.value} PED +
+ )} + + {itemDetails?.markup && ( +
+ Markup + {itemDetails.markup}% +
+ )} + + {itemDetails?.current_price && ( +
+ Market Price + {itemDetails.current_price} PED +
+ )} +
+ + + + View on Nexus + +
+ ) : ( +
+ +

Select an item

+

View market data and details

+
+ )} +
+
+
+ ) +} diff --git a/src/pages/Overlay.tsx b/src/pages/Overlay.tsx new file mode 100644 index 0000000..adbbc02 --- /dev/null +++ b/src/pages/Overlay.tsx @@ -0,0 +1,148 @@ +import { useState, useEffect } from 'react' +import { invoke } from '@tauri-apps/api/tauri' +import { listen } from '@tauri-apps/api/event' +import { + X, + Minus, + GripVertical, + Zap, + Target, + TrendingUp, + Clock, + DollarSign +} from 'lucide-react' + +export default function Overlay() { + const [widgets, setWidgets] = useState([]) + const [sessionTime, setSessionTime] = useState(0) + const [isDragging, setIsDragging] = useState(false) + + useEffect(() => { + // Listen for widget updates from plugins + const unlisten = listen('overlay:widget', (event) => { + const { action, widget } = event.payload as any + + if (action === 'add') { + setWidgets(prev => [...prev, widget]) + } else if (action === 'remove') { + setWidgets(prev => prev.filter(w => w.id !== widget.id)) + } else if (action === 'update') { + setWidgets(prev => + prev.map(w => w.id === widget.id ? { ...w, ...widget } : w) + ) + } + }) + + // Session timer + const interval = setInterval(() => { + setSessionTime(prev => prev + 1) + }, 1000) + + return () => { + unlisten.then(f => f()) + clearInterval(interval) + } + }, []) + + const formatTime = (seconds: number) => { + const hrs = Math.floor(seconds / 3600) + const mins = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + + const handleClose = () => { + invoke('close_overlay') + } + + const handleMinimize = () => { + invoke('close_overlay') + } + + return ( +
+ {/* Header */} +
setIsDragging(true)} + onMouseUp={() => setIsDragging(false)} + > +
+ + EU-Utility Overlay +
+
+ + +
+
+ + {/* Content */} +
+ {/* Session Stats */} +
+
+ + Session: {formatTime(sessionTime)} +
+ +
+
+ +

0.00

+

PED

+
+
+ +

0

+

Mobs

+
+
+ +

0%

+

ROI

+
+
+
+ + {/* Plugin Widgets */} +
+ {widgets.length === 0 ? ( +
+ +

No active widgets

+

Activate plugins to see widgets

+
+ ) : ( + widgets.map((widget) => ( +
+
+

{widget.title}

+ {widget.value && ( + {widget.value} + )} +
+ {widget.content && ( +
{widget.content}
+ )} +
+ )) + )} +
+
+
+ ) +} diff --git a/src/pages/Plugins.tsx b/src/pages/Plugins.tsx new file mode 100644 index 0000000..57c9bad --- /dev/null +++ b/src/pages/Plugins.tsx @@ -0,0 +1,216 @@ +import { useState, useEffect } from 'react' +import { invoke } from '@tauri-apps/api/tauri' +import { + Puzzle, + Power, + Settings2, + ExternalLink, + Search, + Grid, + List, + AlertCircle +} from 'lucide-react' +import { useAppStore } from '../store/appStore' +import type { Plugin } from '../store/appStore' + +export default function Plugins() { + const { plugins, activatePlugin, deactivatePlugin, loadPlugins } = useAppStore() + const [searchQuery, setSearchQuery] = useState('') + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + const [selectedPlugin, setSelectedPlugin] = useState(null) + + const filteredPlugins = plugins.filter(p => + p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.description.toLowerCase().includes(searchQuery.toLowerCase()) + ) + + const activePlugins = filteredPlugins.filter(p => p.active) + const inactivePlugins = filteredPlugins.filter(p => !p.active) + + return ( +
+
+
+

Plugins

+

Manage your EU-Utility plugins

+
+ +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search plugins..." + className="pl-10 pr-4 py-2 bg-surface border border-border rounded-lg text-sm focus:outline-none focus:border-primary w-64" + /> +
+ +
+ + +
+
+
+ + {/* Active Plugins */} + {activePlugins.length > 0 && ( +
+

Active Plugins ({activePlugins.length})

+
+ {activePlugins.map((plugin) => ( + activatePlugin(plugin.id)} + onDeactivate={() => deactivatePlugin(plugin.id)} + onClick={() => setSelectedPlugin(plugin)} + viewMode={viewMode} + /> + ))} +
+
+ )} + + {/* Inactive Plugins */} + {inactivePlugins.length > 0 && ( +
+

Inactive Plugins ({inactivePlugins.length})

+
+ {inactivePlugins.map((plugin) => ( + activatePlugin(plugin.id)} + onDeactivate={() => deactivatePlugin(plugin.id)} + onClick={() => setSelectedPlugin(plugin)} + viewMode={viewMode} + /> + ))} +
+
+ )} + + {/* Empty State */} + {filteredPlugins.length === 0 && ( +
+ +

No plugins found

+

Try adjusting your search

+
+ )} +
+ ) +} + +interface PluginCardProps { + plugin: Plugin + onActivate: () => void + onDeactivate: () => void + onClick: () => void + viewMode: 'grid' | 'list' +} + +function PluginCard({ plugin, onActivate, onDeactivate, onClick, viewMode }: PluginCardProps) { + if (viewMode === 'list') { + return ( +
+
+ +
+ +
+

{plugin.name}

+

{plugin.description}

+
+ +
+ v{plugin.version} + + {plugin.active ? ( + + ) : ( + + )} +
+
+ ) + } + + return ( +
+
+
+ +
+ + {plugin.active ? ( +
+
+ Active +
+ ) : ( +
+
+ Inactive +
+ )} +
+ +

{plugin.name}

+

{plugin.description}

+ +
+ {plugin.author} + v{plugin.version} +
+ +
+ {plugin.active ? ( + <> + + + + ) : ( + + )} +
+
+ ) +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 0000000..b055400 --- /dev/null +++ b/src/pages/Settings.tsx @@ -0,0 +1,349 @@ +import { useState, useEffect } from 'react' +import { invoke } from '@tauri-apps/api/tauri' +import { + Settings2, + Keyboard, + Monitor, + Puzzle, + Save, + RotateCcw +} from 'lucide-react' +import { useAppStore } from '../store/appStore' + +interface SettingSection { + id: string + title: string + icon: React.ElementType + description: string +} + +export default function Settings() { + const { settings, updateSetting, loadSettings } = useAppStore() + const [activeSection, setActiveSection] = useState('general') + const [localSettings, setLocalSettings] = useState(settings) + const [hasChanges, setHasChanges] = useState(false) + + useEffect(() => { + setLocalSettings(settings) + }, [settings]) + + const sections: SettingSection[] = [ + { + id: 'general', + title: 'General', + icon: Settings2, + description: 'Application behavior and appearance' + }, + { + id: 'hotkeys', + title: 'Hotkeys', + icon: Keyboard, + description: 'Keyboard shortcuts configuration' + }, + { + id: 'overlay', + title: 'Overlay', + icon: Monitor, + description: 'In-game overlay settings' + }, + { + id: 'plugins', + title: 'Plugins', + icon: Puzzle, + description: 'Plugin system configuration' + }, + ] + + const handleChange = (key: string, value: unknown) => { + const newSettings = { ...localSettings } + const keys = key.split('.') + let current: Record = newSettings + + for (let i = 0; i < keys.length - 1; i++) { + if (!current[keys[i]]) current[keys[i]] = {} + current = current[keys[i]] as Record + } + current[keys[keys.length - 1]] = value + + setLocalSettings(newSettings) + setHasChanges(true) + } + + const handleSave = async () => { + for (const [key, value] of Object.entries(localSettings)) { + await updateSetting(key, value) + } + setHasChanges(false) + } + + const handleReset = () => { + setLocalSettings(settings) + setHasChanges(false) + } + + return ( +
+
+
+

Settings

+

Configure EU-Utility V3

+
+ + {hasChanges && ( +
+ + +
+ )} +
+ +
+ {/* Sidebar */} +
+ {sections.map((section) => { + const Icon = section.icon + return ( + + ) + })} +
+ + {/* Content */} +
+ {activeSection === 'general' && ( + + )} + {activeSection === 'hotkeys' && ( + + )} + {activeSection === 'overlay' && ( + + )} + {activeSection === 'plugins' && ( + + )} +
+
+
+ ) +} + +function GeneralSettings({ settings, onChange }: { settings: any, onChange: (k: string, v: unknown) => void }) { + return ( +
+

General Settings

+ +
+
+
+

Start on Boot

+

Launch EU-Utility when system starts

+
+ +
+ +
+
+

Minimize to Tray

+

Keep running in system tray when closed

+
+ +
+ +
+ + +
+
+
+ ) +} + +function HotkeySettings({ settings, onChange }: { settings: any, onChange: (k: string, v: unknown) => void }) { + const hotkeys = [ + { key: 'overlay_toggle', label: 'Toggle Overlay', default: 'Ctrl+Shift+U' }, + { key: 'overlay_hide', label: 'Hide Overlay', default: 'Ctrl+Shift+H' }, + { key: 'search_universal', label: 'Universal Search', default: 'Ctrl+Shift+F' }, + { key: 'search_nexus', label: 'Nexus Search', default: 'Ctrl+Shift+N' }, + { key: 'calculator', label: 'Calculator', default: 'Ctrl+Shift+C' }, + { key: 'tracker_loot', label: 'Loot Tracker', default: 'Ctrl+Shift+L' }, + ] + + return ( +
+

Hotkey Configuration

+ +
+ {hotkeys.map((hotkey) => ( +
+
+

{hotkey.label}

+

Default: {hotkey.default}

+
+ onChange(`hotkeys.${hotkey.key}`, e.target.value)} + className="w-40 px-3 py-2 bg-surface border border-border rounded-lg text-center text-white font-mono text-sm focus:outline-none focus:border-primary" + /> +
+ ))} +
+
+ ) +} + +function OverlaySettings({ settings, onChange }: { settings: any, onChange: (k: string, v: unknown) => void }) { + return ( +
+

Overlay Settings

+ +
+
+
+ + {Math.round((settings?.overlay?.opacity || 0.9) * 100)}% +
+ onChange('overlay.opacity', parseFloat(e.target.value))} + className="w-full" + /> +
+ +
+
+

Click Through

+

Allow clicks to pass through overlay

+
+ +
+ +
+
+ + {(settings?.overlay?.auto_hide_delay || 3000) / 1000}s +
+ onChange('overlay.auto_hide_delay', parseInt(e.target.value))} + className="w-full" + /> +
+
+
+ ) +} + +function PluginSettings({ settings, onChange }: { settings: any, onChange: (k: string, v: unknown) => void }) { + return ( +
+

Plugin Configuration

+ +
+
+
+

Auto-activate Plugins

+

Automatically activate plugins on startup

+
+ +
+ +
+ + onChange('log_reader.log_path', e.target.value)} + placeholder="Auto-detect" + className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-white focus:outline-none focus:border-primary" + /> +

Path to Entropia Universe chat log file

+
+
+
+ ) +} diff --git a/src/store/appStore.ts b/src/store/appStore.ts new file mode 100644 index 0000000..8c7d2ef --- /dev/null +++ b/src/store/appStore.ts @@ -0,0 +1,119 @@ +import { create } from 'zustand' +import { invoke } from '@tauri-apps/api/tauri' +import { listen } from '@tauri-apps/api/event' + +export interface Plugin { + id: string + name: string + version: string + author: string + description: string + active: boolean + has_overlay: boolean + hotkey?: string +} + +export interface Hotkey { + action: string + shortcut: string + enabled: boolean +} + +interface AppState { + plugins: Plugin[] + hotkeys: Hotkey[] + settings: Record + isLoading: boolean + activeTab: string + + // Actions + initialize: () => Promise + loadPlugins: () => Promise + activatePlugin: (id: string) => Promise + deactivatePlugin: (id: string) => Promise + loadSettings: () => Promise + updateSetting: (key: string, value: unknown) => Promise + setActiveTab: (tab: string) => void + toggleOverlay: () => Promise +} + +export const useAppStore = create((set, get) => ({ + plugins: [], + hotkeys: [], + settings: {}, + isLoading: true, + activeTab: 'dashboard', + + initialize: async () => { + await get().loadPlugins() + await get().loadSettings() + set({ isLoading: false }) + + // Listen for events + listen('plugin.activated', () => { + get().loadPlugins() + }) + + listen('plugin.deactivated', () => { + get().loadPlugins() + }) + }, + + loadPlugins: async () => { + try { + const plugins = await invoke('get_plugins') + set({ plugins }) + } catch (error) { + console.error('Failed to load plugins:', error) + } + }, + + activatePlugin: async (id: string) => { + try { + await invoke('activate_plugin', { id }) + await get().loadPlugins() + } catch (error) { + console.error('Failed to activate plugin:', error) + } + }, + + deactivatePlugin: async (id: string) => { + try { + await invoke('deactivate_plugin', { id }) + await get().loadPlugins() + } catch (error) { + console.error('Failed to deactivate plugin:', error) + } + }, + + loadSettings: async () => { + try { + const settings = await invoke>('get_settings') + const hotkeys = await invoke('get_hotkeys') + set({ settings, hotkeys }) + } catch (error) { + console.error('Failed to load settings:', error) + } + }, + + updateSetting: async (key: string, value: unknown) => { + try { + await invoke('set_settings', { key, value }) + await get().loadSettings() + } catch (error) { + console.error('Failed to update setting:', error) + } + }, + + setActiveTab: (tab: string) => { + set({ activeTab: tab }) + }, + + toggleOverlay: async () => { + try { + await invoke('toggle_overlay') + } catch (error) { + console.error('Failed to toggle overlay:', error) + } + }, +})) diff --git a/src/styles/App.css b/src/styles/App.css new file mode 100644 index 0000000..8abc90c --- /dev/null +++ b/src/styles/App.css @@ -0,0 +1,88 @@ +.App { + width: 100%; + height: 100%; +} + +/* Loading states */ +.loading-shimmer { + background: linear-gradient( + 90deg, + #1e1e2e 0%, + #2d2d3d 50%, + #1e1e2e 100% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +/* Transitions */ +.fade-enter { + opacity: 0; +} + +.fade-enter-active { + opacity: 1; + transition: opacity 200ms; +} + +.fade-exit { + opacity: 1; +} + +.fade-exit-active { + opacity: 0; + transition: opacity 200ms; +} + +/* Overlay specific */ +.overlay-draggable { + cursor: move; + user-select: none; +} + +.overlay-resizable { + resize: both; + overflow: auto; +} + +/* Hotkey display */ +.hotkey-badge { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + background: #1e1e2e; + border: 1px solid #3d3d3d; + border-radius: 0.25rem; + color: #94a3b8; +} + +/* Plugin status indicators */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-dot.active { + background: #10b981; + box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); +} + +.status-dot.inactive { + background: #64748b; +} + +.status-dot.error { + background: #ef4444; + box-shadow: 0 0 8px rgba(239, 68, 68, 0.4); +} diff --git a/src/styles/index.css b/src/styles/index.css new file mode 100644 index 0000000..8dc15a5 --- /dev/null +++ b/src/styles/index.css @@ -0,0 +1,179 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #root { + height: 100%; + width: 100%; +} + +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + background-color: #0a0a0f; + color: #e2e8f0; + overflow: hidden; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #13131a; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: #3d3d3d; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #4d4d4d; +} + +/* Selection */ +::selection { + background: rgba(99, 102, 241, 0.3); + color: #ffffff; +} + +/* Focus */ +*:focus { + outline: none; +} + +/* Range input styling */ +input[type="range"] { + -webkit-appearance: none; + width: 100%; + height: 6px; + background: #1e1e2e; + border-radius: 3px; + outline: none; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + background: #6366f1; + border-radius: 50%; + cursor: pointer; + transition: background 0.15s; +} + +input[type="range"]::-webkit-slider-thumb:hover { + background: #4f46e5; +} + +/* Checkbox toggle */ +input[type="checkbox"] { + accent-color: #6366f1; +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.animate-fade-in { + animation: fadeIn 0.3s ease-out; +} + +.animate-slide-in { + animation: slideIn 0.3s ease-out; +} + +.animate-pulse-slow { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* Line clamp */ +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Utility classes */ +.text-gradient { + background: linear-gradient(135deg, #6366f1, #8b5cf6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.bg-gradient-primary { + background: linear-gradient(135deg, #6366f1, #4f46e5); +} + +.backdrop-blur-xs { + backdrop-filter: blur(2px); +} + +/* Plugin card hover */ +.plugin-card { + transition: all 0.2s ease; +} + +.plugin-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 40px -10px rgba(99, 102, 241, 0.2); +} + +/* Button states */ +button:active:not(:disabled) { + transform: scale(0.98); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Input focus states */ +input:focus, +select:focus, +textarea:focus { + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..2ad354c --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,59 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + background: '#0a0a0f', + surface: '#13131a', + 'surface-light': '#1e1e2e', + primary: { + DEFAULT: '#6366f1', + hover: '#4f46e5', + }, + secondary: { + DEFAULT: '#8b5cf6', + hover: '#7c3aed', + }, + accent: { + DEFAULT: '#10b981', + hover: '#059669', + }, + warning: '#f59e0b', + danger: '#ef4444', + text: { + DEFAULT: '#e2e8f0', + muted: '#94a3b8', + dark: '#64748b', + }, + border: 'rgba(99, 102, 241, 0.2)', + }, + fontFamily: { + sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'], + mono: ['JetBrains Mono', 'Fira Code', 'monospace'], + }, + animation: { + 'fade-in': 'fadeIn 0.2s ease-out', + 'slide-up': 'slideUp 0.3s ease-out', + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + slideUp: { + '0%': { transform: 'translateY(10px)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + }, + backdropBlur: { + xs: '2px', + }, + }, + }, + plugins: [], +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c20738e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c65a5d3 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig(async () => ({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + clearScreen: false, + server: { + port: 1420, + strictPort: true, + watch: { + ignored: ['**/src-tauri/**'], + }, + }, + envPrefix: ['VITE_', 'TAURI_'], + build: { + target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', + minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, + sourcemap: !!process.env.TAURI_DEBUG, + }, +}))