Compare commits

...

10 commits

11 changed files with 751 additions and 587 deletions

View file

@ -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
View file

@ -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

View file

@ -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

View file

@ -1,3 +1,5 @@
#![allow(clippy::cargo_common_metadata)]
fn main() { fn main() {
tauri_build::build() tauri_build::build();
} }

View file

@ -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()

View file

@ -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
View 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());
}
}

View file

@ -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()
"rclone not found. Please install rclone: https://rclone.org/install/".to_string() .map_err(|e| {
} else { let err_str = e.to_string();
format!("Failed to start rclone: {}", e) if err_str.contains("NotFound") || err_str.contains("not found") {
} "rclone not found. Please install rclone: https://rclone.org/install/".to_string()
})?; } else {
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,90 +168,85 @@ 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));
let _ = child.wait();
std::thread::sleep(std::time::Duration::from_millis(500)); SyncStatus {
status: "stopped".to_string(),
let _ = child.kill(); is_running: false,
let _ = child.wait(); last_sync: Some(chrono::Utc::now().to_rfc3339()),
files_synced: 0,
Ok(SyncStatus { bytes_transferred: 0,
status: "stopped".to_string(), current_file: None,
is_running: false, error: None,
last_sync: Some(chrono::Utc::now().to_rfc3339()), }
files_synced: 0,
bytes_transferred: 0,
current_file: 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()))
}, },
}; };
let _ = window.emit("sync_completed", &status); if success {
break; let _ = window.emit("sync_completed", &status);
} 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);
} }
} }
} }

View file

@ -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)
} }

View file

@ -1 +1,2 @@
pub mod desktop; pub mod desktop;

View file

@ -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}");
}
} }