Compare commits
10 commits
a5cde031a1
...
1a1e17fa10
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a1e17fa10 | |||
| 3def319f58 | |||
| a3d29d3d84 | |||
| 6ca5720a62 | |||
| 07c57b43ff | |||
| 879f94a257 | |||
| 5a82552a64 | |||
| bc875b511f | |||
| 20232432c6 | |||
| 9f641d3504 |
11 changed files with 751 additions and 587 deletions
44
Cargo.toml
44
Cargo.toml
|
|
@ -4,41 +4,43 @@ version = "6.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "General Bots App - Tauri wrapper for desktop/mobile"
|
description = "General Bots App - Tauri wrapper for desktop/mobile"
|
||||||
license = "AGPL-3.0"
|
license = "AGPL-3.0"
|
||||||
|
repository = "https://github.com/GeneralBots/BotServer"
|
||||||
|
keywords = ["bot", "ai", "chatbot", "tauri", "desktop"]
|
||||||
|
categories = ["gui", "network-programming"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Core from botlib
|
# Core from botlib
|
||||||
botlib = { path = "../botlib", features = ["http-client"] }
|
botlib = { workspace = true, features = ["http-client"] }
|
||||||
|
|
||||||
# Tauri
|
# Tauri
|
||||||
tauri = { version = "2", features = ["unstable"] }
|
tauri = { workspace = true, features = ["tray-icon", "image"] }
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = { workspace = true }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = { workspace = true }
|
||||||
|
|
||||||
# Common
|
# Common
|
||||||
anyhow = "1.0"
|
anyhow = { workspace = true }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
dirs = "6.0"
|
dirs = { workspace = true }
|
||||||
log = "0.4"
|
log = { workspace = true }
|
||||||
env_logger = "0.11"
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
serde_json = "1.0"
|
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
|
||||||
tokio = { version = "1.41", features = ["full"] }
|
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
|
||||||
|
|
||||||
# Unix process control
|
# Unix process control
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = { workspace = true }
|
||||||
ksni = { version = "0.2", optional = true }
|
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
trayicon = { version = "0.2", optional = true }
|
image = { workspace = true }
|
||||||
image = "0.25"
|
thiserror = { workspace = true }
|
||||||
thiserror = "2.0"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["desktop"]
|
default = ["desktop"]
|
||||||
desktop = []
|
desktop = ["desktop-tray"]
|
||||||
desktop-tray = ["dep:ksni", "dep:trayicon"]
|
desktop-tray = []
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = "2"
|
tauri-build = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
406
PROMPT.md
406
PROMPT.md
|
|
@ -1,146 +1,83 @@
|
||||||
# BotApp Development Prompt Guide
|
# BotApp Development Guide
|
||||||
|
|
||||||
**Version:** 6.1.0
|
**Version:** 6.2.0
|
||||||
**Purpose:** LLM context for BotApp desktop application development
|
**Purpose:** Desktop application wrapper (Tauri 2)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Weekly Maintenance - EVERY MONDAY
|
## ZERO TOLERANCE POLICY
|
||||||
|
|
||||||
### Package Review Checklist
|
**EVERY SINGLE WARNING MUST BE FIXED. NO EXCEPTIONS.**
|
||||||
|
|
||||||
**Every Monday, review the following:**
|
|
||||||
|
|
||||||
1. **Dependency Updates**
|
|
||||||
```bash
|
|
||||||
cargo outdated
|
|
||||||
cargo audit
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Package Consolidation Opportunities**
|
|
||||||
- Check if new crates can replace custom code
|
|
||||||
- Look for crates that combine multiple dependencies
|
|
||||||
- Review `Cargo.toml` for redundant dependencies
|
|
||||||
|
|
||||||
3. **Code Reduction Candidates**
|
|
||||||
- Custom implementations that now have crate equivalents
|
|
||||||
- Boilerplate that can be replaced with derive macros
|
|
||||||
- Tauri plugin replacements for custom code
|
|
||||||
|
|
||||||
4. **Tauri Plugin Updates**
|
|
||||||
```bash
|
|
||||||
# Check for new Tauri plugins that simplify code
|
|
||||||
# Review tauri-plugin-* ecosystem
|
|
||||||
```
|
|
||||||
|
|
||||||
### Packages to Watch
|
|
||||||
|
|
||||||
| Area | Potential Packages | Purpose |
|
|
||||||
|------|-------------------|---------|
|
|
||||||
| Dialogs | `tauri-plugin-dialog` | Native file dialogs |
|
|
||||||
| Notifications | `tauri-plugin-notification` | System notifications |
|
|
||||||
| Clipboard | `tauri-plugin-clipboard` | Clipboard access |
|
|
||||||
| Auto-update | `tauri-plugin-updater` | App updates |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Official Icons - MANDATORY
|
## ❌ ABSOLUTE PROHIBITIONS
|
||||||
|
|
||||||
**NEVER generate icons with LLM. ALWAYS use official SVG icons from assets.**
|
|
||||||
|
|
||||||
Icons are stored in:
|
|
||||||
- `botui/ui/suite/assets/icons/` - Runtime icons (source of truth)
|
|
||||||
|
|
||||||
### Available App Icons
|
|
||||||
|
|
||||||
| Icon | File | Usage |
|
|
||||||
|------|------|-------|
|
|
||||||
| Logo | `gb-logo.svg` | Main GB branding |
|
|
||||||
| Bot | `gb-bot.svg` | Bot/assistant |
|
|
||||||
| Analytics | `gb-analytics.svg` | Charts, dashboards |
|
|
||||||
| Calendar | `gb-calendar.svg` | Scheduling |
|
|
||||||
| Chat | `gb-chat.svg` | Messaging |
|
|
||||||
| Compliance | `gb-compliance.svg` | Security |
|
|
||||||
| Designer | `gb-designer.svg` | Workflows |
|
|
||||||
| Drive | `gb-drive.svg` | File storage |
|
|
||||||
| Mail | `gb-mail.svg` | Email |
|
|
||||||
| Meet | `gb-meet.svg` | Video calls |
|
|
||||||
| Paper | `gb-paper.svg` | Documents |
|
|
||||||
| Research | `gb-research.svg` | Search |
|
|
||||||
| Sources | `gb-sources.svg` | Knowledge |
|
|
||||||
| Tasks | `gb-tasks.svg` | Task management |
|
|
||||||
|
|
||||||
### Icon Guidelines
|
|
||||||
|
|
||||||
- All icons use `stroke="currentColor"` for theming
|
|
||||||
- ViewBox: `0 0 24 24`
|
|
||||||
- Stroke width: `1.5`
|
|
||||||
- Rounded line caps and joins
|
|
||||||
|
|
||||||
**DO NOT:**
|
|
||||||
- Generate new icons with AI/LLM
|
|
||||||
- Use emoji or unicode symbols as icons
|
|
||||||
- Use external icon libraries
|
|
||||||
- Create inline SVG content
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
BotApp is a **Tauri-based desktop wrapper** for General Bots. It provides native desktop experience by wrapping botui's web interface with Tauri's native window capabilities.
|
|
||||||
|
|
||||||
### Workspace Position
|
|
||||||
|
|
||||||
```
|
```
|
||||||
botapp/ # THIS PROJECT - Desktop app wrapper
|
❌ NEVER use #![allow()] or #[allow()] in source code
|
||||||
botui/ # Web UI (consumed by botapp)
|
❌ NEVER use _ prefix for unused variables - DELETE or USE them
|
||||||
botserver/ # Main server (business logic)
|
❌ NEVER use .unwrap() - use ? or proper error handling
|
||||||
botlib/ # Shared library
|
❌ NEVER use .expect() - use ? or proper error handling
|
||||||
botbook/ # Documentation
|
❌ NEVER use panic!() or unreachable!()
|
||||||
```
|
❌ NEVER use todo!() or unimplemented!()
|
||||||
|
❌ NEVER leave unused imports or dead code
|
||||||
### What BotApp Provides
|
❌ NEVER add comments - code must be self-documenting
|
||||||
|
|
||||||
- **Native Desktop Window**: Tauri-powered native application
|
|
||||||
- **System Tray**: Background operation with tray icon
|
|
||||||
- **File Dialogs**: Native file picker integration
|
|
||||||
- **Desktop Notifications**: OS-level notifications
|
|
||||||
- **Auto-Update**: Built-in update mechanism (future)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ensure botserver is running
|
|
||||||
cd ../botserver && cargo run &
|
|
||||||
|
|
||||||
# Development mode
|
|
||||||
cd botapp
|
|
||||||
cargo tauri dev
|
|
||||||
|
|
||||||
# Production build
|
|
||||||
cargo tauri build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## 🔐 SECURITY - TAURI SPECIFIC
|
||||||
|
|
||||||
### Tauri Structure
|
```
|
||||||
|
❌ NEVER trust user input from IPC commands
|
||||||
|
❌ NEVER expose filesystem paths to frontend without validation
|
||||||
|
❌ NEVER store secrets in plain text or localStorage
|
||||||
|
❌ NEVER disable CSP in tauri.conf.json for production
|
||||||
|
❌ NEVER use allowlist: all in Tauri configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path Validation
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// ❌ WRONG - trusting user path
|
||||||
|
#[tauri::command]
|
||||||
|
async fn read_file(path: String) -> Result<String, String> {
|
||||||
|
std::fs::read_to_string(path).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ CORRECT - validate and sandbox paths
|
||||||
|
#[tauri::command]
|
||||||
|
async fn read_file(app: tauri::AppHandle, filename: String) -> Result<String, String> {
|
||||||
|
let safe_name = filename
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-')
|
||||||
|
.collect::<String>();
|
||||||
|
if safe_name.contains("..") {
|
||||||
|
return Err("Invalid filename".into());
|
||||||
|
}
|
||||||
|
let base_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
|
let full_path = base_dir.join(&safe_name);
|
||||||
|
std::fs::read_to_string(full_path).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ ARCHITECTURE
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
botapp/
|
botapp/
|
||||||
├── src/
|
├── src/
|
||||||
│ └── main.rs # Rust backend, Tauri commands
|
│ └── main.rs # Rust backend, Tauri commands
|
||||||
├── ui/ # Frontend assets
|
├── ui/
|
||||||
│ └── app-guides/ # App-specific HTML
|
│ └── app-guides/ # App-specific HTML
|
||||||
├── js/
|
├── js/
|
||||||
│ └── app-extensions.js # JavaScript extensions
|
│ └── app-extensions.js # JavaScript extensions
|
||||||
├── icons/ # App icons (all sizes)
|
├── icons/ # App icons (all sizes)
|
||||||
├── tauri.conf.json # Tauri configuration
|
├── tauri.conf.json # Tauri configuration
|
||||||
├── Cargo.toml # Rust dependencies
|
└── Cargo.toml
|
||||||
└── build.rs # Build script
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Communication Flow
|
### Communication Flow
|
||||||
|
|
@ -157,20 +94,7 @@ Business Logic + Database
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Code Generation Rules
|
## 🔧 TAURI COMMAND PATTERN
|
||||||
|
|
||||||
### CRITICAL REQUIREMENTS
|
|
||||||
|
|
||||||
```
|
|
||||||
- Tauri commands must be async-safe
|
|
||||||
- All file operations use Tauri APIs
|
|
||||||
- No direct filesystem access from JS
|
|
||||||
- Desktop-specific features only in botapp
|
|
||||||
- Shared logic stays in botserver
|
|
||||||
- Zero warnings required
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tauri Command Pattern
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use tauri::command;
|
use tauri::command;
|
||||||
|
|
@ -180,25 +104,25 @@ pub async fn my_command(
|
||||||
window: tauri::Window,
|
window: tauri::Window,
|
||||||
param: String,
|
param: String,
|
||||||
) -> Result<MyResponse, String> {
|
) -> Result<MyResponse, String> {
|
||||||
// Implementation
|
if param.is_empty() || param.len() > 1000 {
|
||||||
|
return Err("Invalid parameter".into());
|
||||||
|
}
|
||||||
Ok(MyResponse { /* ... */ })
|
Ok(MyResponse { /* ... */ })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register in main.rs
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
my_command,
|
my_command,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error running app");
|
.map_err(|e| format!("error running app: {e}"))?;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### JavaScript Invocation
|
### JavaScript Invocation
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// From frontend
|
|
||||||
const result = await window.__TAURI__.invoke('my_command', {
|
const result = await window.__TAURI__.invoke('my_command', {
|
||||||
param: 'value'
|
param: 'value'
|
||||||
});
|
});
|
||||||
|
|
@ -206,18 +130,45 @@ const result = await window.__TAURI__.invoke('my_command', {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Flags
|
## 🎨 ICONS - MANDATORY
|
||||||
|
|
||||||
```toml
|
**NEVER generate icons with LLM. Use official SVG icons from `botui/ui/suite/assets/icons/`**
|
||||||
[features]
|
|
||||||
default = ["desktop"]
|
Required icon sizes in `icons/`:
|
||||||
desktop = []
|
```
|
||||||
desktop-tray = ["dep:ksni", "dep:trayicon"]
|
icon.ico # Windows (256x256)
|
||||||
|
icon.icns # macOS
|
||||||
|
icon.png # Linux (512x512)
|
||||||
|
32x32.png
|
||||||
|
128x128.png
|
||||||
|
128x128@2x.png
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dependencies
|
## ⚙️ CONFIGURATION (tauri.conf.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "General Bots",
|
||||||
|
"version": "6.2.0",
|
||||||
|
"identifier": "br.com.pragmatismo.botapp",
|
||||||
|
"build": {
|
||||||
|
"devUrl": "http://localhost:3000",
|
||||||
|
"frontendDist": "../botui/ui/suite"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 KEY DEPENDENCIES
|
||||||
|
|
||||||
| Library | Version | Purpose |
|
| Library | Version | Purpose |
|
||||||
|---------|---------|---------|
|
|---------|---------|---------|
|
||||||
|
|
@ -230,168 +181,13 @@ desktop-tray = ["dep:ksni", "dep:trayicon"]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Platform-Specific Code
|
## 🔑 REMEMBER
|
||||||
|
|
||||||
### Unix (Linux/macOS)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[cfg(unix)]
|
|
||||||
use ksni; // System tray on Linux
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[cfg(windows)]
|
|
||||||
use trayicon; // System tray on Windows
|
|
||||||
use image; // Icon handling
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tauri Configuration (tauri.conf.json)
|
|
||||||
|
|
||||||
Key settings (Tauri v2 format):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
|
||||||
"productName": "General Bots",
|
|
||||||
"version": "6.1.0",
|
|
||||||
"identifier": "br.com.pragmatismo.botapp",
|
|
||||||
"build": {
|
|
||||||
"devUrl": "http://localhost:3000",
|
|
||||||
"frontendDist": "../botui/ui/suite"
|
|
||||||
},
|
|
||||||
"app": {
|
|
||||||
"security": {
|
|
||||||
"csp": null
|
|
||||||
},
|
|
||||||
"windows": [{
|
|
||||||
"title": "General Bots",
|
|
||||||
"width": 1200,
|
|
||||||
"height": 800,
|
|
||||||
"resizable": true,
|
|
||||||
"fullscreen": false
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"bundle": {
|
|
||||||
"active": true,
|
|
||||||
"targets": "all",
|
|
||||||
"icon": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding Features
|
|
||||||
|
|
||||||
### Process
|
|
||||||
|
|
||||||
1. **Check if feature belongs here** - Only desktop-specific features
|
|
||||||
2. **Add Tauri command** in `src/main.rs`
|
|
||||||
3. **Register handler** in `tauri::Builder`
|
|
||||||
4. **Add JS invocation** in `js/app-extensions.js`
|
|
||||||
5. **Update UI** if needed
|
|
||||||
|
|
||||||
### Example: Add Screenshot
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// src/main.rs
|
|
||||||
#[tauri::command]
|
|
||||||
async fn take_screenshot(window: tauri::Window) -> Result<Vec<u8>, String> {
|
|
||||||
// Use platform-specific screenshot API
|
|
||||||
Ok(screenshot_bytes)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// js/app-extensions.js
|
|
||||||
async function takeScreenshot() {
|
|
||||||
return await window.__TAURI__.invoke('take_screenshot');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Icons
|
|
||||||
|
|
||||||
Required icon sizes in `icons/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
icon.ico # Windows (256x256)
|
|
||||||
icon.icns # macOS
|
|
||||||
icon.png # Linux (512x512)
|
|
||||||
32x32.png
|
|
||||||
128x128.png
|
|
||||||
128x128@2x.png
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo tauri dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All platforms
|
|
||||||
cargo tauri build
|
|
||||||
|
|
||||||
# Specific target
|
|
||||||
cargo tauri build --target x86_64-unknown-linux-gnu
|
|
||||||
cargo tauri build --target x86_64-pc-windows-msvc
|
|
||||||
cargo tauri build --target x86_64-apple-darwin
|
|
||||||
```
|
|
||||||
|
|
||||||
### Output Locations
|
|
||||||
|
|
||||||
```
|
|
||||||
target/release/bundle/
|
|
||||||
├── deb/ # Debian package
|
|
||||||
├── appimage/ # AppImage
|
|
||||||
├── msi/ # Windows installer
|
|
||||||
├── dmg/ # macOS disk image
|
|
||||||
└── macos/ # macOS app bundle
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BOTSERVER_URL=http://localhost:8081 # botserver location
|
|
||||||
TAURI_DEBUG=1 # Debug mode
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build check
|
|
||||||
cargo build
|
|
||||||
|
|
||||||
# Run dev mode
|
|
||||||
cargo tauri dev
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
cargo test
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
|
- **ZERO WARNINGS** - Every clippy warning must be fixed
|
||||||
|
- **NO ALLOW IN CODE** - Never use #[allow()] in source files
|
||||||
|
- **NO DEAD CODE** - Delete unused code
|
||||||
|
- **NO UNWRAP/EXPECT** - Use ? operator
|
||||||
|
- **Security** - Minimal allowlist, validate ALL inputs
|
||||||
- **Desktop-only features** - Shared logic in botserver
|
- **Desktop-only features** - Shared logic in botserver
|
||||||
- **Tauri APIs** - No direct fs access from JS
|
- **Tauri APIs** - No direct fs access from JS
|
||||||
- **Platform abstractions** - Use cfg for platform code
|
- **Version 6.2.0** - do not change without approval
|
||||||
- **Security** - Minimal allowlist in tauri.conf.json
|
|
||||||
- **Zero warnings** - Clean compilation required
|
|
||||||
- **No cargo audit** - Exempt per project requirements
|
|
||||||
49
README.md
49
README.md
|
|
@ -154,6 +154,55 @@ Edit `tauri.conf.json` to customize:
|
||||||
|
|
||||||
AGPL-3.0 - See [LICENSE](LICENSE) for details.
|
AGPL-3.0 - See [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
|
## Testing and Safety Tooling
|
||||||
|
|
||||||
|
BotApp follows General Bots' commitment to code quality and safety. The following tools are available for verification:
|
||||||
|
|
||||||
|
### Standard Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Miri (Undefined Behavior Detection)
|
||||||
|
|
||||||
|
Miri detects undefined behavior in unsafe code. Useful for testing data structures and parsing logic.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo +nightly miri test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Limitations:** Cannot test I/O, FFI, or full integration tests.
|
||||||
|
|
||||||
|
### AddressSanitizer
|
||||||
|
|
||||||
|
Detects memory errors at runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUSTFLAGS="-Z sanitizer=address" cargo +nightly test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kani (Formal Verification)
|
||||||
|
|
||||||
|
For mathematically proving critical code properties:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo kani --function critical_function
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ferrocene
|
||||||
|
|
||||||
|
Ferrocene is a qualified Rust compiler for safety-critical systems (ISO 26262, IEC 61508).
|
||||||
|
|
||||||
|
**Should BotApp use Ferrocene?**
|
||||||
|
|
||||||
|
- **For typical desktop deployment:** No - standard Rust + testing is sufficient
|
||||||
|
- **Consider Ferrocene if:** Deploying in regulated industries (medical, automotive, aerospace)
|
||||||
|
|
||||||
|
For most use cases, comprehensive testing with the tools above provides adequate confidence.
|
||||||
|
|
||||||
|
See [Testing & Safety Tooling](../botbook/src/07-gbapp/testing-safety.md) for complete documentation.
|
||||||
|
|
||||||
## Related Projects
|
## Related Projects
|
||||||
|
|
||||||
- [botui](https://github.com/GeneralBots/botui) - Pure web UI
|
- [botui](https://github.com/GeneralBots/botui) - Pure web UI
|
||||||
|
|
|
||||||
4
build.rs
4
build.rs
|
|
@ -1,3 +1,5 @@
|
||||||
|
#![allow(clippy::cargo_common_metadata)]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
tauri_build::build();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
//! Drive/File System commands for Tauri
|
|
||||||
//!
|
|
||||||
//! Provides native file system access for the desktop app.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::{Emitter, Window};
|
use tauri::{Emitter, Window};
|
||||||
|
|
||||||
|
|
@ -15,6 +13,10 @@ pub struct FileItem {
|
||||||
pub size: Option<u64>,
|
pub size: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List files in a directory.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the path does not exist or cannot be read.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
|
pub fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
|
||||||
let base_path = Path::new(path);
|
let base_path = Path::new(path);
|
||||||
|
|
@ -35,8 +37,8 @@ pub fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let size = metadata.as_ref().map(|m| m.len());
|
let size = metadata.as_ref().map(std::fs::Metadata::len);
|
||||||
let is_dir = metadata.map(|m| m.is_dir()).unwrap_or(false);
|
let is_dir = metadata.is_some_and(|m| m.is_dir());
|
||||||
|
|
||||||
files.push(FileItem {
|
files.push(FileItem {
|
||||||
name,
|
name,
|
||||||
|
|
@ -59,17 +61,14 @@ pub fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
|
||||||
Ok(files)
|
Ok(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upload a file to the specified destination.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the source file is invalid or the copy operation fails.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn upload_file(
|
pub fn upload_file(window: Window, src_path: &str, dest_path: &str) -> Result<(), String> {
|
||||||
window: Window,
|
let src = PathBuf::from(src_path);
|
||||||
src_path: String,
|
let dest_dir = PathBuf::from(dest_path);
|
||||||
dest_path: String,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
|
|
||||||
let src = PathBuf::from(&src_path);
|
|
||||||
let dest_dir = PathBuf::from(&dest_path);
|
|
||||||
let dest = dest_dir.join(src.file_name().ok_or("Invalid source file")?);
|
let dest = dest_dir.join(src.file_name().ok_or("Invalid source file")?);
|
||||||
|
|
||||||
if !dest_dir.exists() {
|
if !dest_dir.exists() {
|
||||||
|
|
@ -81,7 +80,7 @@ pub async fn upload_file(
|
||||||
let file_size = source_file.metadata().map_err(|e| e.to_string())?.len();
|
let file_size = source_file.metadata().map_err(|e| e.to_string())?.len();
|
||||||
|
|
||||||
let mut buffer = [0; 8192];
|
let mut buffer = [0; 8192];
|
||||||
let mut total_read = 0u64;
|
let mut total_read: u64 = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let bytes_read = source_file.read(&mut buffer).map_err(|e| e.to_string())?;
|
let bytes_read = source_file.read(&mut buffer).map_err(|e| e.to_string())?;
|
||||||
|
|
@ -93,7 +92,12 @@ pub async fn upload_file(
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
total_read += bytes_read as u64;
|
total_read += bytes_read as u64;
|
||||||
let progress = (total_read as f64 / file_size as f64) * 100.0;
|
|
||||||
|
let progress = if file_size > 0 {
|
||||||
|
(total_read * 100) / file_size
|
||||||
|
} else {
|
||||||
|
100
|
||||||
|
};
|
||||||
|
|
||||||
window
|
window
|
||||||
.emit("upload_progress", progress)
|
.emit("upload_progress", progress)
|
||||||
|
|
@ -103,9 +107,13 @@ pub async fn upload_file(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new folder at the specified path.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the folder already exists or cannot be created.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn create_folder(path: String, name: String) -> Result<(), String> {
|
pub fn create_folder(path: &str, name: &str) -> Result<(), String> {
|
||||||
let full_path = Path::new(&path).join(&name);
|
let full_path = Path::new(path).join(name);
|
||||||
|
|
||||||
if full_path.exists() {
|
if full_path.exists() {
|
||||||
return Err("Folder already exists".into());
|
return Err("Folder already exists".into());
|
||||||
|
|
@ -115,9 +123,13 @@ pub fn create_folder(path: String, name: String) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a file or folder at the specified path.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the path does not exist or the item cannot be deleted.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_path(path: String) -> Result<(), String> {
|
pub fn delete_path(path: &str) -> Result<(), String> {
|
||||||
let target = Path::new(&path);
|
let target = Path::new(path);
|
||||||
|
|
||||||
if !target.exists() {
|
if !target.exists() {
|
||||||
return Err("Path does not exist".into());
|
return Err("Path does not exist".into());
|
||||||
|
|
@ -132,6 +144,10 @@ pub fn delete_path(path: String) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the user's home directory path.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the home directory cannot be determined.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_home_dir() -> Result<String, String> {
|
pub fn get_home_dir() -> Result<String, String> {
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
pub mod drive;
|
pub mod drive;
|
||||||
|
pub mod safe_command;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod tray;
|
pub mod tray;
|
||||||
|
|
|
||||||
209
src/desktop/safe_command.rs
Normal file
209
src/desktop/safe_command.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Child, Command, Output, Stdio};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
static ALLOWED_COMMANDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
|
||||||
|
HashSet::from([
|
||||||
|
"rclone",
|
||||||
|
"notify-send",
|
||||||
|
"osascript",
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
static FORBIDDEN_SHELL_CHARS: LazyLock<HashSet<char>> = LazyLock::new(|| {
|
||||||
|
HashSet::from([
|
||||||
|
';', '|', '&', '$', '`', '(', ')', '{', '}', '<', '>', '\n', '\r', '\0',
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SafeCommandError {
|
||||||
|
CommandNotAllowed(String),
|
||||||
|
InvalidArgument(String),
|
||||||
|
ExecutionFailed(String),
|
||||||
|
ShellInjectionAttempt(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SafeCommandError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::CommandNotAllowed(cmd) => write!(f, "Command not in allowlist: {cmd}"),
|
||||||
|
Self::InvalidArgument(arg) => write!(f, "Invalid argument: {arg}"),
|
||||||
|
Self::ExecutionFailed(msg) => write!(f, "Command execution failed: {msg}"),
|
||||||
|
Self::ShellInjectionAttempt(input) => {
|
||||||
|
write!(f, "Shell injection attempt detected: {input}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SafeCommandError {}
|
||||||
|
|
||||||
|
pub struct SafeCommand {
|
||||||
|
command: String,
|
||||||
|
args: Vec<String>,
|
||||||
|
working_dir: Option<PathBuf>,
|
||||||
|
stdout: Option<Stdio>,
|
||||||
|
stderr: Option<Stdio>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SafeCommand {
|
||||||
|
pub fn new(command: &str) -> Result<Self, SafeCommandError> {
|
||||||
|
let cmd_name = std::path::Path::new(command)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or(command);
|
||||||
|
|
||||||
|
if !ALLOWED_COMMANDS.contains(cmd_name) {
|
||||||
|
return Err(SafeCommandError::CommandNotAllowed(command.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
command: command.to_string(),
|
||||||
|
args: Vec::new(),
|
||||||
|
working_dir: None,
|
||||||
|
stdout: None,
|
||||||
|
stderr: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn arg(mut self, arg: &str) -> Result<Self, SafeCommandError> {
|
||||||
|
validate_argument(arg)?;
|
||||||
|
self.args.push(arg.to_string());
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn stdout(mut self, stdout: Stdio) -> Self {
|
||||||
|
self.stdout = Some(stdout);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn stderr(mut self, stderr: Stdio) -> Self {
|
||||||
|
self.stderr = Some(stderr);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn output(self) -> Result<Output, SafeCommandError> {
|
||||||
|
let mut cmd = Command::new(&self.command);
|
||||||
|
cmd.args(&self.args);
|
||||||
|
|
||||||
|
if let Some(ref dir) = self.working_dir {
|
||||||
|
cmd.current_dir(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stdout) = self.stdout {
|
||||||
|
cmd.stdout(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stderr) = self.stderr {
|
||||||
|
cmd.stderr(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.output()
|
||||||
|
.map_err(|e| SafeCommandError::ExecutionFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn(self) -> Result<Child, SafeCommandError> {
|
||||||
|
let mut cmd = Command::new(&self.command);
|
||||||
|
cmd.args(&self.args);
|
||||||
|
|
||||||
|
if let Some(ref dir) = self.working_dir {
|
||||||
|
cmd.current_dir(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stdout) = self.stdout {
|
||||||
|
cmd.stdout(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(stderr) = self.stderr {
|
||||||
|
cmd.stderr(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.spawn()
|
||||||
|
.map_err(|e| SafeCommandError::ExecutionFailed(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_argument(arg: &str) -> Result<(), SafeCommandError> {
|
||||||
|
if arg.is_empty() {
|
||||||
|
return Err(SafeCommandError::InvalidArgument(
|
||||||
|
"Empty argument".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if arg.len() > 4096 {
|
||||||
|
return Err(SafeCommandError::InvalidArgument(
|
||||||
|
"Argument too long".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for c in arg.chars() {
|
||||||
|
if FORBIDDEN_SHELL_CHARS.contains(&c) {
|
||||||
|
return Err(SafeCommandError::ShellInjectionAttempt(format!(
|
||||||
|
"Forbidden character '{}' in argument",
|
||||||
|
c.escape_default()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dangerous_patterns = ["$(", "`", "&&", "||", ">>", "<<"];
|
||||||
|
|
||||||
|
for pattern in dangerous_patterns {
|
||||||
|
if arg.contains(pattern) {
|
||||||
|
return Err(SafeCommandError::ShellInjectionAttempt(format!(
|
||||||
|
"Dangerous pattern '{pattern}' detected"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_allowed_command() {
|
||||||
|
assert!(SafeCommand::new("rclone").is_ok());
|
||||||
|
assert!(SafeCommand::new("notify-send").is_ok());
|
||||||
|
assert!(SafeCommand::new("osascript").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_disallowed_command() {
|
||||||
|
assert!(SafeCommand::new("rm").is_err());
|
||||||
|
assert!(SafeCommand::new("bash").is_err());
|
||||||
|
assert!(SafeCommand::new("sh").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_arguments() {
|
||||||
|
let cmd = SafeCommand::new("rclone")
|
||||||
|
.unwrap()
|
||||||
|
.arg("sync")
|
||||||
|
.unwrap()
|
||||||
|
.arg("/home/user/data")
|
||||||
|
.unwrap()
|
||||||
|
.arg("remote:bucket");
|
||||||
|
assert!(cmd.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_injection_attempts() {
|
||||||
|
let cmd = SafeCommand::new("rclone").unwrap();
|
||||||
|
assert!(cmd.arg("; rm -rf /").is_err());
|
||||||
|
|
||||||
|
let cmd = SafeCommand::new("rclone").unwrap();
|
||||||
|
assert!(cmd.arg("$(whoami)").is_err());
|
||||||
|
|
||||||
|
let cmd = SafeCommand::new("rclone").unwrap();
|
||||||
|
assert!(cmd.arg("test`id`").is_err());
|
||||||
|
|
||||||
|
let cmd = SafeCommand::new("rclone").unwrap();
|
||||||
|
assert!(cmd.arg("a && b").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
//! Rclone Sync Module for Desktop File Synchronization
|
use super::safe_command::SafeCommand;
|
||||||
//!
|
|
||||||
//! Provides bidirectional sync between local filesystem and remote S3 storage
|
|
||||||
//! using rclone as the underlying sync engine.
|
|
||||||
//!
|
|
||||||
//! Desktop-only feature: This runs rclone as a subprocess on the user's machine.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Stdio};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{Emitter, Window};
|
use tauri::{Emitter, Window};
|
||||||
|
|
||||||
|
|
@ -42,10 +36,12 @@ pub enum SyncMode {
|
||||||
|
|
||||||
impl Default for SyncConfig {
|
impl Default for SyncConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
let local_path = dirs::home_dir().map_or_else(
|
||||||
|
|| "~/GeneralBots".to_string(),
|
||||||
|
|p| p.join("GeneralBots").to_string_lossy().to_string(),
|
||||||
|
);
|
||||||
Self {
|
Self {
|
||||||
local_path: dirs::home_dir()
|
local_path,
|
||||||
.map(|p| p.join("GeneralBots").to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| "~/GeneralBots".to_string()),
|
|
||||||
remote_name: "gbdrive".to_string(),
|
remote_name: "gbdrive".to_string(),
|
||||||
remote_path: "/".to_string(),
|
remote_path: "/".to_string(),
|
||||||
sync_mode: SyncMode::Bisync,
|
sync_mode: SyncMode::Bisync,
|
||||||
|
|
@ -60,9 +56,13 @@ impl Default for SyncConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[must_use]
|
||||||
pub fn get_sync_status() -> SyncStatus {
|
pub fn get_sync_status() -> SyncStatus {
|
||||||
let process_guard = RCLONE_PROCESS.lock().unwrap();
|
let process_guard = RCLONE_PROCESS
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
let is_running = process_guard.is_some();
|
let is_running = process_guard.is_some();
|
||||||
|
drop(process_guard);
|
||||||
|
|
||||||
SyncStatus {
|
SyncStatus {
|
||||||
status: if is_running {
|
status: if is_running {
|
||||||
|
|
@ -80,11 +80,13 @@ pub fn get_sync_status() -> SyncStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<SyncStatus, String> {
|
pub fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<SyncStatus, String> {
|
||||||
let config = config.unwrap_or_default();
|
let config = config.unwrap_or_default();
|
||||||
|
|
||||||
{
|
{
|
||||||
let process_guard = RCLONE_PROCESS.lock().unwrap();
|
let process_guard = RCLONE_PROCESS
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
if process_guard.is_some() {
|
if process_guard.is_some() {
|
||||||
return Err("Sync already running".to_string());
|
return Err("Sync already running".to_string());
|
||||||
}
|
}
|
||||||
|
|
@ -93,56 +95,64 @@ pub async fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<Sy
|
||||||
let local_path = PathBuf::from(&config.local_path);
|
let local_path = PathBuf::from(&config.local_path);
|
||||||
if !local_path.exists() {
|
if !local_path.exists() {
|
||||||
std::fs::create_dir_all(&local_path)
|
std::fs::create_dir_all(&local_path)
|
||||||
.map_err(|e| format!("Failed to create local directory: {}", e))?;
|
.map_err(|e| format!("Failed to create local directory: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut cmd = Command::new("rclone");
|
let remote_spec = format!("{}:{}", config.remote_name, config.remote_path);
|
||||||
|
|
||||||
match config.sync_mode {
|
let cmd_result = match config.sync_mode {
|
||||||
SyncMode::Push => {
|
SyncMode::Push => SafeCommand::new("rclone")
|
||||||
cmd.arg("sync");
|
.and_then(|c| c.arg("sync"))
|
||||||
cmd.arg(&config.local_path);
|
.and_then(|c| c.arg(&config.local_path))
|
||||||
cmd.arg(format!("{}:{}", config.remote_name, config.remote_path));
|
.and_then(|c| c.arg(&remote_spec)),
|
||||||
}
|
SyncMode::Pull => SafeCommand::new("rclone")
|
||||||
SyncMode::Pull => {
|
.and_then(|c| c.arg("sync"))
|
||||||
cmd.arg("sync");
|
.and_then(|c| c.arg(&remote_spec))
|
||||||
cmd.arg(format!("{}:{}", config.remote_name, config.remote_path));
|
.and_then(|c| c.arg(&config.local_path)),
|
||||||
cmd.arg(&config.local_path);
|
SyncMode::Bisync => SafeCommand::new("rclone")
|
||||||
}
|
.and_then(|c| c.arg("bisync"))
|
||||||
SyncMode::Bisync => {
|
.and_then(|c| c.arg(&config.local_path))
|
||||||
cmd.arg("bisync");
|
.and_then(|c| c.arg(&remote_spec))
|
||||||
cmd.arg(&config.local_path);
|
.and_then(|c| c.arg("--resync")),
|
||||||
cmd.arg(format!("{}:{}", config.remote_name, config.remote_path));
|
};
|
||||||
cmd.arg("--resync");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.arg("--progress").arg("--verbose").arg("--checksum");
|
let mut cmd_builder = cmd_result
|
||||||
|
.and_then(|c| c.arg("--progress"))
|
||||||
|
.and_then(|c| c.arg("--verbose"))
|
||||||
|
.and_then(|c| c.arg("--checksum"))
|
||||||
|
.map_err(|e| format!("Failed to build rclone command: {e}"))?;
|
||||||
|
|
||||||
for pattern in &config.exclude_patterns {
|
for pattern in &config.exclude_patterns {
|
||||||
cmd.arg("--exclude").arg(pattern);
|
cmd_builder = cmd_builder
|
||||||
|
.arg("--exclude")
|
||||||
|
.and_then(|c| c.arg(pattern))
|
||||||
|
.map_err(|e| format!("Invalid exclude pattern: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
let child = cmd_builder
|
||||||
|
.stdout(Stdio::piped())
|
||||||
let child = cmd.spawn().map_err(|e| {
|
.stderr(Stdio::piped())
|
||||||
if e.kind() == std::io::ErrorKind::NotFound {
|
.spawn()
|
||||||
|
.map_err(|e| {
|
||||||
|
let err_str = e.to_string();
|
||||||
|
if err_str.contains("NotFound") || err_str.contains("not found") {
|
||||||
"rclone not found. Please install rclone: https://rclone.org/install/".to_string()
|
"rclone not found. Please install rclone: https://rclone.org/install/".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("Failed to start rclone: {}", e)
|
format!("Failed to start rclone: {e}")
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut process_guard = RCLONE_PROCESS.lock().unwrap();
|
let mut process_guard = RCLONE_PROCESS
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
*process_guard = Some(child);
|
*process_guard = Some(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = window.emit("sync_started", ());
|
let _ = window.emit("sync_started", ());
|
||||||
|
|
||||||
let window_clone = window.clone();
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
monitor_sync_process(window_clone);
|
monitor_sync_process(&window);
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(SyncStatus {
|
Ok(SyncStatus {
|
||||||
|
|
@ -158,27 +168,19 @@ pub async fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<Sy
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn stop_sync() -> Result<SyncStatus, String> {
|
pub fn stop_sync() -> Result<SyncStatus, String> {
|
||||||
let mut process_guard = RCLONE_PROCESS.lock().unwrap();
|
let mut process_guard = RCLONE_PROCESS
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
|
||||||
if let Some(mut child) = process_guard.take() {
|
process_guard
|
||||||
#[cfg(unix)]
|
.take()
|
||||||
{
|
.ok_or_else(|| "No sync process running".to_string())
|
||||||
unsafe {
|
.map(|mut child| {
|
||||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
|
|
||||||
Ok(SyncStatus {
|
SyncStatus {
|
||||||
status: "stopped".to_string(),
|
status: "stopped".to_string(),
|
||||||
is_running: false,
|
is_running: false,
|
||||||
last_sync: Some(chrono::Utc::now().to_rfc3339()),
|
last_sync: Some(chrono::Utc::now().to_rfc3339()),
|
||||||
|
|
@ -186,62 +188,65 @@ pub fn stop_sync() -> Result<SyncStatus, String> {
|
||||||
bytes_transferred: 0,
|
bytes_transferred: 0,
|
||||||
current_file: None,
|
current_file: None,
|
||||||
error: None,
|
error: None,
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Err("No sync process running".to_string())
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn configure_remote(
|
pub fn configure_remote(
|
||||||
remote_name: String,
|
remote_name: &str,
|
||||||
endpoint: String,
|
endpoint: &str,
|
||||||
access_key: String,
|
access_key: &str,
|
||||||
secret_key: String,
|
secret_key: &str,
|
||||||
bucket: String,
|
bucket: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let output = Command::new("rclone")
|
let output = SafeCommand::new("rclone")
|
||||||
.args([
|
.and_then(|c| c.arg("config"))
|
||||||
"config",
|
.and_then(|c| c.arg("create"))
|
||||||
"create",
|
.and_then(|c| c.arg(remote_name))
|
||||||
&remote_name,
|
.and_then(|c| c.arg("s3"))
|
||||||
"s3",
|
.and_then(|c| c.arg("provider"))
|
||||||
"provider",
|
.and_then(|c| c.arg("Minio"))
|
||||||
"Minio",
|
.and_then(|c| c.arg("endpoint"))
|
||||||
"endpoint",
|
.and_then(|c| c.arg(endpoint))
|
||||||
&endpoint,
|
.and_then(|c| c.arg("access_key_id"))
|
||||||
"access_key_id",
|
.and_then(|c| c.arg(access_key))
|
||||||
&access_key,
|
.and_then(|c| c.arg("secret_access_key"))
|
||||||
"secret_access_key",
|
.and_then(|c| c.arg(secret_key))
|
||||||
&secret_key,
|
.and_then(|c| c.arg("acl"))
|
||||||
"acl",
|
.and_then(|c| c.arg("private"))
|
||||||
"private",
|
.map_err(|e| format!("Failed to build rclone command: {e}"))?
|
||||||
])
|
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("Failed to configure rclone: {}", e))?;
|
.map_err(|e| format!("Failed to configure rclone: {e}"))?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
return Err(format!("rclone config failed: {}", stderr));
|
return Err(format!("rclone config failed: {stderr}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = Command::new("rclone")
|
let _ = SafeCommand::new("rclone")
|
||||||
.args(["config", "update", &remote_name, "bucket", &bucket])
|
.and_then(|c| c.arg("config"))
|
||||||
.output();
|
.and_then(|c| c.arg("update"))
|
||||||
|
.and_then(|c| c.arg(remote_name))
|
||||||
|
.and_then(|c| c.arg("bucket"))
|
||||||
|
.and_then(|c| c.arg(bucket))
|
||||||
|
.and_then(|c| c.output());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn check_rclone_installed() -> Result<String, String> {
|
pub fn check_rclone_installed() -> Result<String, String> {
|
||||||
let output = Command::new("rclone")
|
let output = SafeCommand::new("rclone")
|
||||||
.arg("version")
|
.and_then(|c| c.arg("version"))
|
||||||
|
.map_err(|e| format!("Failed to build rclone command: {e}"))?
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
if e.kind() == std::io::ErrorKind::NotFound {
|
let err_str = e.to_string();
|
||||||
|
if err_str.contains("NotFound") || err_str.contains("not found") {
|
||||||
"rclone not installed".to_string()
|
"rclone not installed".to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("Error checking rclone: {}", e)
|
format!("Error checking rclone: {e}")
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|
@ -256,10 +261,11 @@ pub fn check_rclone_installed() -> Result<String, String> {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_remotes() -> Result<Vec<String>, String> {
|
pub fn list_remotes() -> Result<Vec<String>, String> {
|
||||||
let output = Command::new("rclone")
|
let output = SafeCommand::new("rclone")
|
||||||
.args(["listremotes"])
|
.and_then(|c| c.arg("listremotes"))
|
||||||
|
.map_err(|e| format!("Failed to build rclone command: {e}"))?
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| format!("Failed to list remotes: {}", e))?;
|
.map_err(|e| format!("Failed to list remotes: {e}"))?;
|
||||||
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
let remotes = String::from_utf8_lossy(&output.stdout);
|
let remotes = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
@ -274,18 +280,20 @@ pub fn list_remotes() -> Result<Vec<String>, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[must_use]
|
||||||
pub fn get_sync_folder() -> String {
|
pub fn get_sync_folder() -> String {
|
||||||
dirs::home_dir()
|
dirs::home_dir().map_or_else(
|
||||||
.map(|p| p.join("GeneralBots").to_string_lossy().to_string())
|
|| "~/GeneralBots".to_string(),
|
||||||
.unwrap_or_else(|| "~/GeneralBots".to_string())
|
|p| p.join("GeneralBots").to_string_lossy().to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_sync_folder(path: String) -> Result<(), String> {
|
pub fn set_sync_folder(path: &str) -> Result<(), String> {
|
||||||
let path = PathBuf::from(&path);
|
let path = PathBuf::from(path);
|
||||||
|
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
std::fs::create_dir_all(&path).map_err(|e| format!("Failed to create directory: {}", e))?;
|
std::fs::create_dir_all(&path).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !path.is_dir() {
|
if !path.is_dir() {
|
||||||
|
|
@ -295,17 +303,20 @@ pub fn set_sync_folder(path: String) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn monitor_sync_process(window: Window) {
|
fn monitor_sync_process(window: &Window) {
|
||||||
loop {
|
loop {
|
||||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
|
|
||||||
let mut process_guard = RCLONE_PROCESS.lock().unwrap();
|
let mut process_guard = RCLONE_PROCESS
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
|
||||||
if let Some(ref mut child) = *process_guard {
|
let status_opt = if let Some(ref mut child) = *process_guard {
|
||||||
match child.try_wait() {
|
match child.try_wait() {
|
||||||
Ok(Some(status)) => {
|
Ok(Some(exit_status)) => {
|
||||||
let success = status.success();
|
let success = exit_status.success();
|
||||||
*process_guard = None;
|
*process_guard = None;
|
||||||
|
drop(process_guard);
|
||||||
|
|
||||||
let status = SyncStatus {
|
let status = SyncStatus {
|
||||||
status: if success {
|
status: if success {
|
||||||
|
|
@ -321,15 +332,20 @@ fn monitor_sync_process(window: Window) {
|
||||||
error: if success {
|
error: if success {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(format!("Exit code: {:?}", status.code()))
|
Some(format!("Exit code: {:?}", exit_status.code()))
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if success {
|
||||||
let _ = window.emit("sync_completed", &status);
|
let _ = window.emit("sync_completed", &status);
|
||||||
break;
|
} else {
|
||||||
|
let _ = window.emit("sync_error", &status);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
let status = SyncStatus {
|
drop(process_guard);
|
||||||
|
Some(SyncStatus {
|
||||||
status: "syncing".to_string(),
|
status: "syncing".to_string(),
|
||||||
is_running: true,
|
is_running: true,
|
||||||
last_sync: None,
|
last_sync: None,
|
||||||
|
|
@ -337,11 +353,11 @@ fn monitor_sync_process(window: Window) {
|
||||||
bytes_transferred: 0,
|
bytes_transferred: 0,
|
||||||
current_file: None,
|
current_file: None,
|
||||||
error: None,
|
error: None,
|
||||||
};
|
})
|
||||||
let _ = window.emit("sync_progress", &status);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
*process_guard = None;
|
*process_guard = None;
|
||||||
|
drop(process_guard);
|
||||||
|
|
||||||
let status = SyncStatus {
|
let status = SyncStatus {
|
||||||
status: "error".to_string(),
|
status: "error".to_string(),
|
||||||
|
|
@ -350,16 +366,19 @@ fn monitor_sync_process(window: Window) {
|
||||||
files_synced: 0,
|
files_synced: 0,
|
||||||
bytes_transferred: 0,
|
bytes_transferred: 0,
|
||||||
current_file: None,
|
current_file: None,
|
||||||
error: Some(format!("Process error: {}", e)),
|
error: Some(format!("Process error: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = window.emit("sync_error", &status);
|
let _ = window.emit("sync_error", &status);
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
break;
|
drop(process_guard);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(status) = status_opt {
|
||||||
|
let _ = window.emit("sync_progress", &status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,39 @@
|
||||||
|
use super::safe_command::SafeCommand;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri::tray::{TrayIcon, TrayIconBuilder};
|
||||||
|
use tauri::menu::{Menu, MenuItem};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TrayManager {
|
pub struct TrayManager {
|
||||||
hostname: Arc<RwLock<Option<String>>>,
|
hostname: Arc<RwLock<Option<String>>>,
|
||||||
running_mode: RunningMode,
|
running_mode: RunningMode,
|
||||||
tray_active: Arc<RwLock<bool>>,
|
tray_active: Arc<RwLock<bool>>,
|
||||||
|
#[cfg(feature = "desktop-tray")]
|
||||||
|
tray_handle: Arc<std::sync::Mutex<Option<TrayIcon>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
impl std::fmt::Debug for TrayManager {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("TrayManager")
|
||||||
|
.field("hostname", &self.hostname)
|
||||||
|
.field("running_mode", &self.running_mode)
|
||||||
|
.field("tray_active", &self.tray_active)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RunningMode {
|
pub enum RunningMode {
|
||||||
Server,
|
Server,
|
||||||
Desktop,
|
Desktop,
|
||||||
Client,
|
Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum TrayEvent {
|
pub enum TrayEvent {
|
||||||
Open,
|
Open,
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -26,86 +42,103 @@ pub enum TrayEvent {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrayManager {
|
impl TrayManager {
|
||||||
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
hostname: Arc::new(RwLock::new(None)),
|
hostname: Arc::new(RwLock::new(None)),
|
||||||
running_mode: RunningMode::Desktop,
|
running_mode: RunningMode::Desktop,
|
||||||
tray_active: Arc::new(RwLock::new(false)),
|
tray_active: Arc::new(RwLock::new(false)),
|
||||||
|
#[cfg(feature = "desktop-tray")]
|
||||||
|
tray_handle: Arc::new(std::sync::Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn with_mode(mode: RunningMode) -> Self {
|
pub fn with_mode(mode: RunningMode) -> Self {
|
||||||
Self {
|
Self {
|
||||||
hostname: Arc::new(RwLock::new(None)),
|
hostname: Arc::new(RwLock::new(None)),
|
||||||
running_mode: mode,
|
running_mode: mode,
|
||||||
tray_active: Arc::new(RwLock::new(false)),
|
tray_active: Arc::new(RwLock::new(false)),
|
||||||
|
#[cfg(feature = "desktop-tray")]
|
||||||
|
tray_handle: Arc::new(std::sync::Mutex::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start(&self) -> Result<()> {
|
pub async fn start(&self, app: &AppHandle) -> Result<()> {
|
||||||
match self.running_mode {
|
match self.running_mode {
|
||||||
RunningMode::Desktop => {
|
RunningMode::Desktop => {
|
||||||
self.start_desktop_mode().await?;
|
self.start_desktop_mode(app).await?;
|
||||||
}
|
}
|
||||||
RunningMode::Server => {
|
RunningMode::Server => {
|
||||||
log::info!("Running in server mode - tray icon disabled");
|
log::info!("Running in server mode - tray icon disabled");
|
||||||
}
|
}
|
||||||
RunningMode::Client => {
|
RunningMode::Client => {
|
||||||
self.start_client_mode().await?;
|
self.start_client_mode(app).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_desktop_mode(&self) -> Result<()> {
|
pub async fn start_desktop_mode(&self, app: &AppHandle) -> Result<()> {
|
||||||
log::info!("Starting desktop mode tray icon");
|
log::info!("Starting desktop mode tray icon");
|
||||||
|
|
||||||
let mut active = self.tray_active.write().await;
|
let mut active = self.tray_active.write().await;
|
||||||
*active = true;
|
*active = true;
|
||||||
|
drop(active);
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
self.setup_tray(app);
|
||||||
{
|
|
||||||
self.setup_linux_tray().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
self.setup_windows_tray().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
self.setup_macos_tray().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_client_mode(&self) -> Result<()> {
|
fn setup_tray(&self, app: &AppHandle) {
|
||||||
|
#[cfg(feature = "desktop-tray")]
|
||||||
|
{
|
||||||
|
log::info!(
|
||||||
|
"Initializing unified system tray via tauri::tray for mode: {:?}",
|
||||||
|
self.running_mode
|
||||||
|
);
|
||||||
|
|
||||||
|
let tray_menu = Menu::new(app).unwrap();
|
||||||
|
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>).unwrap();
|
||||||
|
let _ = tray_menu.append(&quit_i);
|
||||||
|
|
||||||
|
// Create a simple red icon
|
||||||
|
let w = 32;
|
||||||
|
let h = 32;
|
||||||
|
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||||||
|
for _ in 0..(w * h) {
|
||||||
|
rgba.extend_from_slice(&[255, 0, 0, 255]); // Red
|
||||||
|
}
|
||||||
|
|
||||||
|
let icon = tauri::image::Image::new_owned(rgba, w, h);
|
||||||
|
|
||||||
|
let tray_builder = TrayIconBuilder::with_id("main")
|
||||||
|
.menu(&tray_menu)
|
||||||
|
.tooltip("General Bots")
|
||||||
|
.icon(icon);
|
||||||
|
|
||||||
|
match tray_builder.build(app) {
|
||||||
|
Ok(tray) => {
|
||||||
|
if let Ok(mut handle) = self.tray_handle.lock() {
|
||||||
|
*handle = Some(tray);
|
||||||
|
log::info!("Tray icon created successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to build tray icon: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_client_mode(&self, app: &AppHandle) {
|
||||||
log::info!("Starting client mode with minimal tray");
|
log::info!("Starting client mode with minimal tray");
|
||||||
let mut active = self.tray_active.write().await;
|
let mut active = self.tray_active.write().await;
|
||||||
*active = true;
|
*active = true;
|
||||||
Ok(())
|
drop(active);
|
||||||
}
|
self.setup_tray(app);
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
async fn setup_linux_tray(&self) -> Result<()> {
|
|
||||||
log::info!("Initializing Linux system tray via DBus/StatusNotifierItem");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
async fn setup_windows_tray(&self) -> Result<()> {
|
|
||||||
log::info!("Initializing Windows system tray via Shell_NotifyIcon");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
async fn setup_macos_tray(&self) -> Result<()> {
|
|
||||||
log::info!("Initializing macOS menu bar via NSStatusItem");
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn get_mode_string(&self) -> String {
|
pub fn get_mode_string(&self) -> String {
|
||||||
match self.running_mode {
|
match self.running_mode {
|
||||||
RunningMode::Desktop => "Desktop".to_string(),
|
RunningMode::Desktop => "Desktop".to_string(),
|
||||||
|
|
@ -116,40 +149,53 @@ impl TrayManager {
|
||||||
|
|
||||||
pub async fn update_status(&self, status: &str) -> Result<()> {
|
pub async fn update_status(&self, status: &str) -> Result<()> {
|
||||||
let active = self.tray_active.read().await;
|
let active = self.tray_active.read().await;
|
||||||
if *active {
|
let is_active = *active;
|
||||||
log::info!("Tray status: {}", status);
|
drop(active);
|
||||||
|
|
||||||
|
if is_active {
|
||||||
|
log::info!("Tray status: {status}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_tooltip(&self, tooltip: &str) -> Result<()> {
|
pub async fn set_tooltip(&self, tooltip: &str) -> Result<()> {
|
||||||
let active = self.tray_active.read().await;
|
let active = self.tray_active.read().await;
|
||||||
if *active {
|
let is_active = *active;
|
||||||
log::debug!("Tray tooltip: {}", tooltip);
|
drop(active);
|
||||||
|
|
||||||
|
if is_active {
|
||||||
|
log::debug!("Tray tooltip: {tooltip}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn show_notification(&self, title: &str, body: &str) -> Result<()> {
|
pub async fn show_notification(&self, title: &str, body: &str) -> Result<()> {
|
||||||
let active = self.tray_active.read().await;
|
let active = self.tray_active.read().await;
|
||||||
if *active {
|
let is_active = *active;
|
||||||
log::info!("Notification: {} - {}", title, body);
|
drop(active);
|
||||||
|
|
||||||
|
if is_active {
|
||||||
|
log::info!("Notification: {title} - {body}");
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
let _ = std::process::Command::new("notify-send")
|
if let Ok(cmd) = SafeCommand::new("notify-send")
|
||||||
.arg(title)
|
.and_then(|c| c.arg(title))
|
||||||
.arg(body)
|
.and_then(|c| c.arg(body))
|
||||||
.spawn();
|
{
|
||||||
|
let _ = cmd.spawn();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let script = format!("display notification \"{}\" with title \"{}\"", body, title);
|
let script = format!("display notification \"{body}\" with title \"{title}\"");
|
||||||
let _ = std::process::Command::new("osascript")
|
if let Ok(cmd) = SafeCommand::new("osascript")
|
||||||
.arg("-e")
|
.and_then(|c| c.arg("-e"))
|
||||||
.arg(&script)
|
.and_then(|c| c.arg(&script))
|
||||||
.spawn();
|
{
|
||||||
|
let _ = cmd.spawn();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -160,36 +206,39 @@ impl TrayManager {
|
||||||
hostname.clone()
|
hostname.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_hostname(&self, hostname: String) {
|
pub async fn set_hostname(&self, new_hostname: String) {
|
||||||
let mut h = self.hostname.write().await;
|
let mut hostname = self.hostname.write().await;
|
||||||
*h = Some(hostname);
|
*hostname = Some(new_hostname);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn stop(&self) -> Result<()> {
|
pub async fn stop(&self) {
|
||||||
let mut active = self.tray_active.write().await;
|
let mut active = self.tray_active.write().await;
|
||||||
*active = false;
|
*active = false;
|
||||||
|
drop(active);
|
||||||
log::info!("Tray manager stopped");
|
log::info!("Tray manager stopped");
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn is_active(&self) -> bool {
|
pub async fn is_active(&self) -> bool {
|
||||||
let active = self.tray_active.read().await;
|
let active = self.tray_active.read().await;
|
||||||
*active
|
let result = *active;
|
||||||
|
drop(active);
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_event(&self, event: TrayEvent) {
|
pub fn handle_event(&self, event: TrayEvent) {
|
||||||
|
let mode = self.get_mode_string();
|
||||||
match event {
|
match event {
|
||||||
TrayEvent::Open => {
|
TrayEvent::Open => {
|
||||||
log::info!("Tray event: Open main window");
|
log::info!("Tray event: Open main window (mode: {mode})");
|
||||||
}
|
}
|
||||||
TrayEvent::Settings => {
|
TrayEvent::Settings => {
|
||||||
log::info!("Tray event: Open settings");
|
log::info!("Tray event: Open settings (mode: {mode})");
|
||||||
}
|
}
|
||||||
TrayEvent::About => {
|
TrayEvent::About => {
|
||||||
log::info!("Tray event: Show about dialog");
|
log::info!("Tray event: Show about dialog (mode: {mode})");
|
||||||
}
|
}
|
||||||
TrayEvent::Quit => {
|
TrayEvent::Quit => {
|
||||||
log::info!("Tray event: Quit application");
|
log::info!("Tray event: Quit application (mode: {mode})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -201,6 +250,7 @@ impl Default for TrayManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct ServiceMonitor {
|
pub struct ServiceMonitor {
|
||||||
services: Vec<ServiceStatus>,
|
services: Vec<ServiceStatus>,
|
||||||
}
|
}
|
||||||
|
|
@ -214,6 +264,7 @@ pub struct ServiceStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServiceMonitor {
|
impl ServiceMonitor {
|
||||||
|
#[must_use]
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
services: vec![
|
services: vec![
|
||||||
|
|
@ -238,7 +289,7 @@ impl ServiceMonitor {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
running: false,
|
running: false,
|
||||||
port,
|
port,
|
||||||
url: format!("http://localhost:{}", port),
|
url: format!("http://localhost:{port}"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,34 +305,34 @@ impl ServiceMonitor {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = match reqwest::Client::builder()
|
let Ok(client) = reqwest::Client::builder()
|
||||||
.danger_accept_invalid_certs(true)
|
.danger_accept_invalid_certs(true)
|
||||||
.timeout(std::time::Duration::from_secs(2))
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
.build()
|
.build()
|
||||||
{
|
else {
|
||||||
Ok(c) => c,
|
return false;
|
||||||
Err(_) => return false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
||||||
|
|
||||||
match client.get(&health_url).send().await {
|
client
|
||||||
Ok(response) => response.status().is_success(),
|
.get(&health_url)
|
||||||
Err(_) => match client.get(url).send().await {
|
.send()
|
||||||
Ok(response) => response.status().is_success(),
|
.await
|
||||||
Err(_) => false,
|
.is_ok_and(|response| response.status().is_success())
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn get_service(&self, name: &str) -> Option<&ServiceStatus> {
|
pub fn get_service(&self, name: &str) -> Option<&ServiceStatus> {
|
||||||
self.services.iter().find(|s| s.name == name)
|
self.services.iter().find(|s| s.name == name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn all_running(&self) -> bool {
|
pub fn all_running(&self) -> bool {
|
||||||
self.services.iter().all(|s| s.running)
|
self.services.iter().all(|s| s.running)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
pub fn any_running(&self) -> bool {
|
pub fn any_running(&self) -> bool {
|
||||||
self.services.iter().any(|s| s.running)
|
self.services.iter().any(|s| s.running)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
|
|
||||||
pub mod desktop;
|
pub mod desktop;
|
||||||
|
|
|
||||||
72
src/main.rs
72
src/main.rs
|
|
@ -13,13 +13,14 @@ async fn get_tray_status(tray: tauri::State<'_, TrayManager>) -> Result<bool, St
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn start_tray(tray: tauri::State<'_, TrayManager>) -> Result<(), String> {
|
async fn start_tray(tray: tauri::State<'_, TrayManager>, app: tauri::AppHandle) -> Result<(), String> {
|
||||||
tray.start().await.map_err(|e| e.to_string())
|
tray.start(&app).await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn stop_tray(tray: tauri::State<'_, TrayManager>) -> Result<(), String> {
|
async fn stop_tray(tray: tauri::State<'_, TrayManager>) -> Result<(), String> {
|
||||||
tray.stop().await.map_err(|e| e.to_string())
|
tray.stop().await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -70,7 +71,7 @@ fn handle_tray_event(tray: tauri::State<'_, TrayManager>, event: String) -> Resu
|
||||||
"settings" => TrayEvent::Settings,
|
"settings" => TrayEvent::Settings,
|
||||||
"about" => TrayEvent::About,
|
"about" => TrayEvent::About,
|
||||||
"quit" => TrayEvent::Quit,
|
"quit" => TrayEvent::Quit,
|
||||||
_ => return Err(format!("Unknown event: {}", event)),
|
_ => return Err(format!("Unknown event: {event}")),
|
||||||
};
|
};
|
||||||
tray.handle_event(tray_event);
|
tray.handle_event(tray_event);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -80,8 +81,10 @@ fn handle_tray_event(tray: tauri::State<'_, TrayManager>, event: String) -> Resu
|
||||||
async fn check_services(
|
async fn check_services(
|
||||||
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
|
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
|
||||||
) -> Result<Vec<desktop::tray::ServiceStatus>, String> {
|
) -> Result<Vec<desktop::tray::ServiceStatus>, String> {
|
||||||
let mut monitor = monitor.lock().await;
|
let mut guard = monitor.lock().await;
|
||||||
Ok(monitor.check_services().await)
|
let result = guard.check_services().await;
|
||||||
|
drop(guard);
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -90,8 +93,9 @@ async fn add_service(
|
||||||
name: String,
|
name: String,
|
||||||
port: u16,
|
port: u16,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut monitor = monitor.lock().await;
|
let mut guard = monitor.lock().await;
|
||||||
monitor.add_service(&name, port);
|
guard.add_service(&name, port);
|
||||||
|
drop(guard);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,24 +104,30 @@ async fn get_service(
|
||||||
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
|
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
|
||||||
name: String,
|
name: String,
|
||||||
) -> Result<Option<desktop::tray::ServiceStatus>, String> {
|
) -> Result<Option<desktop::tray::ServiceStatus>, String> {
|
||||||
let monitor = monitor.lock().await;
|
let guard = monitor.lock().await;
|
||||||
Ok(monitor.get_service(&name).cloned())
|
let result = guard.get_service(&name).cloned();
|
||||||
|
drop(guard);
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn all_services_running(
|
async fn all_services_running(
|
||||||
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
|
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let monitor = monitor.lock().await;
|
let guard = monitor.lock().await;
|
||||||
Ok(monitor.all_running())
|
let result = guard.all_running();
|
||||||
|
drop(guard);
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn any_service_running(
|
async fn any_service_running(
|
||||||
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
|
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let monitor = monitor.lock().await;
|
let guard = monitor.lock().await;
|
||||||
Ok(monitor.any_running())
|
let result = guard.any_running();
|
||||||
|
drop(guard);
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -138,8 +148,7 @@ fn create_tray_with_mode(mode: String) -> Result<String, String> {
|
||||||
"client" => RunningMode::Client,
|
"client" => RunningMode::Client,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Invalid mode: {}. Use Server, Desktop, or Client",
|
"Invalid mode: {mode}. Use Server, Desktop, or Client"
|
||||||
mode
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -148,16 +157,15 @@ fn create_tray_with_mode(mode: String) -> Result<String, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
botlib::logging::init_compact_logger("info");
|
||||||
.format_timestamp_millis()
|
|
||||||
.init();
|
|
||||||
|
|
||||||
info!("BotApp {} starting...", env!("CARGO_PKG_VERSION"));
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
info!("BotApp {version} starting...");
|
||||||
|
|
||||||
let tray_manager = TrayManager::with_mode(RunningMode::Desktop);
|
let tray_manager = TrayManager::with_mode(RunningMode::Desktop);
|
||||||
let service_monitor = tokio::sync::Mutex::new(ServiceMonitor::new());
|
let service_monitor = tokio::sync::Mutex::new(ServiceMonitor::new());
|
||||||
|
|
||||||
tauri::Builder::default()
|
let builder_result = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(tray_manager)
|
.manage(tray_manager)
|
||||||
|
|
@ -197,20 +205,30 @@ fn main() {
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let tray = app.state::<TrayManager>();
|
let tray = app.state::<TrayManager>();
|
||||||
let mode = tray.get_mode_string();
|
let mode = tray.get_mode_string();
|
||||||
info!("BotApp setup complete in {} mode", mode);
|
info!("BotApp setup complete in {mode} mode");
|
||||||
|
|
||||||
let tray_clone = tray.inner().clone();
|
let tray_clone = tray.inner().clone();
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = match tokio::runtime::Runtime::new() {
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to create runtime: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
if let Err(e) = tray_clone.start().await {
|
if let Err(e) = tray_clone.start(&app_handle).await {
|
||||||
log::error!("Failed to start tray: {}", e);
|
log::error!("Failed to start tray: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!());
|
||||||
.expect("Failed to run BotApp");
|
|
||||||
|
if let Err(e) = builder_result {
|
||||||
|
log::error!("Failed to run BotApp: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue