refactor: integrate TrayManager with Tauri commands, zero dead code

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-20 19:58:11 -03:00
parent 3a2510c22b
commit a5cde031a1
7 changed files with 489 additions and 274 deletions

View file

@ -1,38 +1,30 @@
// BotApp Extensions - Injected by Tauri into botui's suite
// Adds app-only guides and native functionality
//
// This script runs only in the Tauri app context and extends
// the botui suite with desktop-specific features.
(function () {
"use strict";
(function() {
'use strict';
// App-only guides that will be injected into the suite navigation
const APP_GUIDES = [
{
id: 'local-files',
label: 'Local Files',
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
const APP_GUIDES = [
{
id: "local-files",
label: "Local Files",
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<path d="M12 11v6M9 14h6"/>
</svg>`,
hxGet: '/app/guides/local-files.html',
description: 'Access and manage files on your device'
},
{
id: 'native-settings',
label: 'App Settings',
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
hxGet: "/app/guides/local-files.html",
description: "Access and manage files on your device",
},
{
id: "native-settings",
label: "App Settings",
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>`,
hxGet: '/app/guides/native-settings.html',
description: 'Configure desktop app settings'
}
];
hxGet: "/app/guides/native-settings.html",
description: "Configure desktop app settings",
},
];
// CSS for app-only elements
const APP_STYLES = `
const APP_STYLES = `
.app-grid-separator {
grid-column: 1 / -1;
display: flex;
@ -66,158 +58,127 @@
}
`;
/**
* Inject app-specific styles
*/
function injectStyles() {
const styleEl = document.createElement('style');
styleEl.id = 'botapp-styles';
styleEl.textContent = APP_STYLES;
document.head.appendChild(styleEl);
function injectStyles() {
const styleEl = document.createElement("style");
styleEl.id = "botapp-styles";
styleEl.textContent = APP_STYLES;
document.head.appendChild(styleEl);
}
function injectAppGuides() {
const grid = document.querySelector(".app-grid");
if (!grid) {
setTimeout(injectAppGuides, 100);
return;
}
/**
* Inject app-only guides into the suite navigation
*/
function injectAppGuides() {
const grid = document.querySelector('.app-grid');
if (!grid) {
// Retry if grid not found yet (page still loading)
setTimeout(injectAppGuides, 100);
return;
}
if (document.querySelector(".app-grid-separator")) {
return;
}
// Check if already injected
if (document.querySelector('.app-grid-separator')) {
return;
}
const separator = document.createElement("div");
separator.className = "app-grid-separator";
separator.innerHTML = "<span>Desktop Features</span>";
grid.appendChild(separator);
// Add separator
const separator = document.createElement('div');
separator.className = 'app-grid-separator';
separator.innerHTML = '<span>Desktop Features</span>';
grid.appendChild(separator);
// Add app-only guides
APP_GUIDES.forEach(guide => {
const item = document.createElement('a');
item.className = 'app-item app-only';
item.href = `#${guide.id}`;
item.dataset.section = guide.id;
item.setAttribute('role', 'menuitem');
item.setAttribute('aria-label', guide.description || guide.label);
item.setAttribute('hx-get', guide.hxGet);
item.setAttribute('hx-target', '#main-content');
item.setAttribute('hx-push-url', 'true');
item.innerHTML = `
APP_GUIDES.forEach((guide) => {
const item = document.createElement("a");
item.className = "app-item app-only";
item.href = `#${guide.id}`;
item.dataset.section = guide.id;
item.setAttribute("role", "menuitem");
item.setAttribute("aria-label", guide.description || guide.label);
item.setAttribute("hx-get", guide.hxGet);
item.setAttribute("hx-target", "#main-content");
item.setAttribute("hx-push-url", "true");
item.innerHTML = `
<div class="app-icon" aria-hidden="true">${guide.icon}</div>
<span>${guide.label}</span>
`;
grid.appendChild(item);
});
grid.appendChild(item);
});
// Re-process HTMX on new elements
if (window.htmx) {
htmx.process(grid);
}
console.log('[BotApp] App guides injected successfully');
if (window.htmx) {
htmx.process(grid);
}
/**
* Setup Tauri event listeners
*/
function setupTauriEvents() {
if (!window.__TAURI__) {
console.warn('[BotApp] Tauri API not available');
return;
}
console.log("[BotApp] App guides injected successfully");
}
const { listen } = window.__TAURI__.event;
// Listen for upload progress events
listen('upload_progress', (event) => {
const progress = event.payload;
const progressEl = document.getElementById('upload-progress');
if (progressEl) {
progressEl.style.width = `${progress}%`;
progressEl.textContent = `${Math.round(progress)}%`;
}
});
console.log('[BotApp] Tauri event listeners registered');
function setupTauriEvents() {
if (!window.__TAURI__) {
console.warn("[BotApp] Tauri API not available");
return;
}
/**
* Initialize BotApp extensions
*/
function init() {
console.log('[BotApp] Initializing app extensions...');
const { listen } = window.__TAURI__.event;
// Inject styles
injectStyles();
listen("upload_progress", (event) => {
const progress = event.payload;
const progressEl = document.getElementById("upload-progress");
if (progressEl) {
progressEl.style.width = `${progress}%`;
progressEl.textContent = `${Math.round(progress)}%`;
}
});
// Inject app guides
injectAppGuides();
console.log("[BotApp] Tauri event listeners registered");
}
// Setup Tauri events
setupTauriEvents();
function init() {
console.log("[BotApp] Initializing app extensions...");
injectStyles();
injectAppGuides();
setupTauriEvents();
console.log("[BotApp] App extensions initialized");
}
console.log('[BotApp] App extensions initialized');
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
window.BotApp = {
isApp: true,
version: "6.1.0",
guides: APP_GUIDES,
// Expose BotApp API globally
window.BotApp = {
isApp: true,
version: '6.1.0',
guides: APP_GUIDES,
invoke: async function (cmd, args) {
if (!window.__TAURI__) {
throw new Error("Tauri API not available");
}
return window.__TAURI__.core.invoke(cmd, args);
},
// Invoke Tauri commands
invoke: async function(cmd, args) {
if (!window.__TAURI__) {
throw new Error('Tauri API not available');
}
return window.__TAURI__.core.invoke(cmd, args);
},
fs: {
listFiles: (path) => window.BotApp.invoke("list_files", { path }),
uploadFile: (srcPath, destPath) =>
window.BotApp.invoke("upload_file", { srcPath, destPath }),
createFolder: (path, name) =>
window.BotApp.invoke("create_folder", { path, name }),
deletePath: (path) => window.BotApp.invoke("delete_path", { path }),
getHomeDir: () => window.BotApp.invoke("get_home_dir"),
},
// File system helpers
fs: {
listFiles: (path) => window.BotApp.invoke('list_files', { path }),
uploadFile: (srcPath, destPath) => window.BotApp.invoke('upload_file', { srcPath, destPath }),
createFolder: (path, name) => window.BotApp.invoke('create_folder', { path, name }),
deletePath: (path) => window.BotApp.invoke('delete_path', { path }),
getHomeDir: () => window.BotApp.invoke('get_home_dir'),
},
notify: async function (title, body) {
if (window.__TAURI__?.notification) {
await window.__TAURI__.notification.sendNotification({ title, body });
}
},
// Show native notification
notify: async function(title, body) {
if (window.__TAURI__?.notification) {
await window.__TAURI__.notification.sendNotification({ title, body });
}
},
// Open native file dialog
openFileDialog: async function(options = {}) {
if (!window.__TAURI__?.dialog) {
throw new Error('Dialog API not available');
}
return window.__TAURI__.dialog.open(options);
},
// Open native save dialog
saveFileDialog: async function(options = {}) {
if (!window.__TAURI__?.dialog) {
throw new Error('Dialog API not available');
}
return window.__TAURI__.dialog.save(options);
}
};
openFileDialog: async function (options = {}) {
if (!window.__TAURI__?.dialog) {
throw new Error("Dialog API not available");
}
return window.__TAURI__.dialog.open(options);
},
saveFileDialog: async function (options = {}) {
if (!window.__TAURI__?.dialog) {
throw new Error("Dialog API not available");
}
return window.__TAURI__.dialog.save(options);
},
};
})();

View file

@ -7,7 +7,6 @@ use std::fs;
use std::path::{Path, PathBuf};
use tauri::{Emitter, Window};
/// Represents a file or directory item
#[derive(Debug, Serialize, Deserialize)]
pub struct FileItem {
pub name: String,
@ -16,7 +15,6 @@ pub struct FileItem {
pub size: Option<u64>,
}
/// List files in a directory
#[tauri::command]
pub fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
let base_path = Path::new(path);
@ -48,7 +46,6 @@ pub fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
});
}
// Sort: directories first, then alphabetically
files.sort_by(|a, b| {
if a.is_dir && !b.is_dir {
std::cmp::Ordering::Less
@ -62,7 +59,6 @@ pub fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
Ok(files)
}
/// Upload a file with progress reporting
#[tauri::command]
pub async fn upload_file(
window: Window,
@ -107,7 +103,6 @@ pub async fn upload_file(
Ok(())
}
/// Create a new folder
#[tauri::command]
pub fn create_folder(path: String, name: String) -> Result<(), String> {
let full_path = Path::new(&path).join(&name);
@ -120,7 +115,6 @@ pub fn create_folder(path: String, name: String) -> Result<(), String> {
Ok(())
}
/// Delete a file or folder
#[tauri::command]
pub fn delete_path(path: String) -> Result<(), String> {
let target = Path::new(&path);
@ -138,7 +132,6 @@ pub fn delete_path(path: String) -> Result<(), String> {
Ok(())
}
/// Get home directory
#[tauri::command]
pub fn get_home_dir() -> Result<String, String> {
dirs::home_dir()

View file

@ -1,10 +1,3 @@
//! Desktop-specific functionality for BotApp
//!
//! This module provides native desktop capabilities:
//! - Drive/file management via Tauri
//! - System tray integration
//! - Rclone-based file synchronization (desktop only)
pub mod drive;
pub mod sync;
pub mod tray;

View file

@ -11,10 +11,8 @@ use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use tauri::{Emitter, Window};
/// Global state for tracking the rclone process
static RCLONE_PROCESS: Mutex<Option<Child>> = Mutex::new(None);
/// Sync status reported to the UI
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncStatus {
pub status: String,
@ -26,7 +24,6 @@ pub struct SyncStatus {
pub error: Option<String>,
}
/// Sync configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncConfig {
pub local_path: String,
@ -36,14 +33,10 @@ pub struct SyncConfig {
pub exclude_patterns: Vec<String>,
}
/// Sync direction/mode
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SyncMode {
/// Local changes pushed to remote
Push,
/// Remote changes pulled to local
Pull,
/// Bidirectional sync (newest wins)
Bisync,
}
@ -66,7 +59,6 @@ impl Default for SyncConfig {
}
}
/// Get current sync status
#[tauri::command]
pub fn get_sync_status() -> SyncStatus {
let process_guard = RCLONE_PROCESS.lock().unwrap();
@ -87,12 +79,10 @@ pub fn get_sync_status() -> SyncStatus {
}
}
/// Start rclone sync process
#[tauri::command]
pub async fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<SyncStatus, String> {
let config = config.unwrap_or_default();
// Check if already running
{
let process_guard = RCLONE_PROCESS.lock().unwrap();
if process_guard.is_some() {
@ -100,17 +90,14 @@ pub async fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<Sy
}
}
// Ensure local directory exists
let local_path = PathBuf::from(&config.local_path);
if !local_path.exists() {
std::fs::create_dir_all(&local_path)
.map_err(|e| format!("Failed to create local directory: {}", e))?;
}
// Build rclone command
let mut cmd = Command::new("rclone");
// Set sync mode
match config.sync_mode {
SyncMode::Push => {
cmd.arg("sync");
@ -126,22 +113,18 @@ pub async fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<Sy
cmd.arg("bisync");
cmd.arg(&config.local_path);
cmd.arg(format!("{}:{}", config.remote_name, config.remote_path));
cmd.arg("--resync"); // First run needs resync
cmd.arg("--resync");
}
}
// Add common options
cmd.arg("--progress").arg("--verbose").arg("--checksum"); // Use checksums for accuracy
cmd.arg("--progress").arg("--verbose").arg("--checksum");
// Add exclude patterns
for pattern in &config.exclude_patterns {
cmd.arg("--exclude").arg(pattern);
}
// Configure output capture
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
// Spawn the process
let child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"rclone not found. Please install rclone: https://rclone.org/install/".to_string()
@ -150,16 +133,13 @@ pub async fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<Sy
}
})?;
// Store the process handle
{
let mut process_guard = RCLONE_PROCESS.lock().unwrap();
*process_guard = Some(child);
}
// Emit started event
let _ = window.emit("sync_started", ());
// Spawn a task to monitor the process
let window_clone = window.clone();
std::thread::spawn(move || {
monitor_sync_process(window_clone);
@ -176,13 +156,11 @@ pub async fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<Sy
})
}
/// Stop rclone sync process
#[tauri::command]
pub fn stop_sync() -> Result<SyncStatus, String> {
let mut process_guard = RCLONE_PROCESS.lock().unwrap();
if let Some(mut child) = process_guard.take() {
// Try graceful termination first
#[cfg(unix)]
{
unsafe {
@ -195,10 +173,8 @@ pub fn stop_sync() -> Result<SyncStatus, String> {
let _ = child.kill();
}
// Wait briefly for graceful shutdown
std::thread::sleep(std::time::Duration::from_millis(500));
// Force kill if still running
let _ = child.kill();
let _ = child.wait();
@ -216,7 +192,6 @@ pub fn stop_sync() -> Result<SyncStatus, String> {
}
}
/// Configure rclone remote for S3/MinIO
#[tauri::command]
pub fn configure_remote(
remote_name: String,
@ -225,7 +200,6 @@ pub fn configure_remote(
secret_key: String,
bucket: String,
) -> Result<(), String> {
// Use rclone config create command
let output = Command::new("rclone")
.args([
"config",
@ -251,7 +225,6 @@ pub fn configure_remote(
return Err(format!("rclone config failed: {}", stderr));
}
// Set default bucket path
let _ = Command::new("rclone")
.args(["config", "update", &remote_name, "bucket", &bucket])
.output();
@ -259,7 +232,6 @@ pub fn configure_remote(
Ok(())
}
/// Check if rclone is installed
#[tauri::command]
pub fn check_rclone_installed() -> Result<String, String> {
let output = Command::new("rclone")
@ -282,7 +254,6 @@ pub fn check_rclone_installed() -> Result<String, String> {
}
}
/// List configured rclone remotes
#[tauri::command]
pub fn list_remotes() -> Result<Vec<String>, String> {
let output = Command::new("rclone")
@ -302,7 +273,6 @@ pub fn list_remotes() -> Result<Vec<String>, String> {
}
}
/// Get sync folder path
#[tauri::command]
pub fn get_sync_folder() -> String {
dirs::home_dir()
@ -310,7 +280,6 @@ pub fn get_sync_folder() -> String {
.unwrap_or_else(|| "~/GeneralBots".to_string())
}
/// Set sync folder path
#[tauri::command]
pub fn set_sync_folder(path: String) -> Result<(), String> {
let path = PathBuf::from(&path);
@ -323,12 +292,9 @@ pub fn set_sync_folder(path: String) -> Result<(), String> {
return Err("Path is not a directory".to_string());
}
// Store in app config (would need app handle for persistent storage)
// For now, just validate the path
Ok(())
}
/// Monitor the sync process and emit events
fn monitor_sync_process(window: Window) {
loop {
std::thread::sleep(std::time::Duration::from_secs(1));
@ -338,7 +304,6 @@ fn monitor_sync_process(window: Window) {
if let Some(ref mut child) = *process_guard {
match child.try_wait() {
Ok(Some(status)) => {
// Process finished
let success = status.success();
*process_guard = None;
@ -364,7 +329,6 @@ fn monitor_sync_process(window: Window) {
break;
}
Ok(None) => {
// Still running - emit progress
let status = SyncStatus {
status: "syncing".to_string(),
is_running: true,
@ -377,7 +341,6 @@ fn monitor_sync_process(window: Window) {
let _ = window.emit("sync_progress", &status);
}
Err(e) => {
// Error checking status
*process_guard = None;
let status = SyncStatus {
@ -395,8 +358,8 @@ fn monitor_sync_process(window: Window) {
}
}
} else {
// No process running
break;
}
}
}

View file

@ -1,35 +1,47 @@
//! System Tray functionality for BotApp
//!
//! Provides system tray icon and menu for desktop platforms.
use anyhow::Result;
use serde::Serialize;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Tray manager handles system tray icon and interactions
#[derive(Clone)]
pub struct TrayManager {
hostname: Arc<RwLock<Option<String>>>,
running_mode: RunningMode,
tray_active: Arc<RwLock<bool>>,
}
/// Running mode for the application
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RunningMode {
Server,
Desktop,
Client,
}
#[derive(Debug, Clone)]
pub enum TrayEvent {
Open,
Settings,
About,
Quit,
}
impl TrayManager {
/// Create a new TrayManager
pub fn new() -> Self {
Self {
hostname: Arc::new(RwLock::new(None)),
running_mode: RunningMode::Desktop,
tray_active: Arc::new(RwLock::new(false)),
}
}
pub fn with_mode(mode: RunningMode) -> Self {
Self {
hostname: Arc::new(RwLock::new(None)),
running_mode: mode,
tray_active: Arc::new(RwLock::new(false)),
}
}
/// Start the tray icon
pub async fn start(&self) -> Result<()> {
match self.running_mode {
RunningMode::Desktop => {
@ -39,7 +51,7 @@ impl TrayManager {
log::info!("Running in server mode - tray icon disabled");
}
RunningMode::Client => {
log::info!("Running in client mode - tray icon minimal");
self.start_client_mode().await?;
}
}
Ok(())
@ -47,12 +59,53 @@ impl TrayManager {
async fn start_desktop_mode(&self) -> Result<()> {
log::info!("Starting desktop mode tray icon");
// Platform-specific tray implementation would go here
// For now, this is a placeholder
let mut active = self.tray_active.write().await;
*active = true;
#[cfg(target_os = "linux")]
{
self.setup_linux_tray().await?;
}
#[cfg(target_os = "windows")]
{
self.setup_windows_tray().await?;
}
#[cfg(target_os = "macos")]
{
self.setup_macos_tray().await?;
}
Ok(())
}
async fn start_client_mode(&self) -> Result<()> {
log::info!("Starting client mode with minimal tray");
let mut active = self.tray_active.write().await;
*active = true;
Ok(())
}
#[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(())
}
/// Get mode as string
pub fn get_mode_string(&self) -> String {
match self.running_mode {
RunningMode::Desktop => "Desktop".to_string(),
@ -61,17 +114,85 @@ impl TrayManager {
}
}
/// Update tray status
pub async fn update_status(&self, status: &str) -> Result<()> {
log::info!("Tray status update: {}", status);
let active = self.tray_active.read().await;
if *active {
log::info!("Tray status: {}", status);
}
Ok(())
}
pub async fn set_tooltip(&self, tooltip: &str) -> Result<()> {
let active = self.tray_active.read().await;
if *active {
log::debug!("Tray tooltip: {}", tooltip);
}
Ok(())
}
pub async fn show_notification(&self, title: &str, body: &str) -> Result<()> {
let active = self.tray_active.read().await;
if *active {
log::info!("Notification: {} - {}", title, body);
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("notify-send")
.arg(title)
.arg(body)
.spawn();
}
#[cfg(target_os = "macos")]
{
let script = format!("display notification \"{}\" with title \"{}\"", body, title);
let _ = std::process::Command::new("osascript")
.arg("-e")
.arg(&script)
.spawn();
}
}
Ok(())
}
/// Get hostname
pub async fn get_hostname(&self) -> Option<String> {
let hostname = self.hostname.read().await;
hostname.clone()
}
pub async fn set_hostname(&self, hostname: String) {
let mut h = self.hostname.write().await;
*h = Some(hostname);
}
pub async fn stop(&self) -> Result<()> {
let mut active = self.tray_active.write().await;
*active = false;
log::info!("Tray manager stopped");
Ok(())
}
pub async fn is_active(&self) -> bool {
let active = self.tray_active.read().await;
*active
}
pub fn handle_event(&self, event: TrayEvent) {
match event {
TrayEvent::Open => {
log::info!("Tray event: Open main window");
}
TrayEvent::Settings => {
log::info!("Tray event: Open settings");
}
TrayEvent::About => {
log::info!("Tray event: Show about dialog");
}
TrayEvent::Quit => {
log::info!("Tray event: Quit application");
}
}
}
}
impl Default for TrayManager {
@ -80,13 +201,11 @@ impl Default for TrayManager {
}
}
/// Service status monitor
pub struct ServiceMonitor {
services: Vec<ServiceStatus>,
}
/// Status of a service
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize)]
pub struct ServiceStatus {
pub name: String,
pub running: bool,
@ -95,7 +214,6 @@ pub struct ServiceStatus {
}
impl ServiceMonitor {
/// Create a new service monitor with default services
pub fn new() -> Self {
Self {
services: vec![
@ -115,7 +233,15 @@ impl ServiceMonitor {
}
}
/// Check all services
pub fn add_service(&mut self, name: &str, port: u16) {
self.services.push(ServiceStatus {
name: name.to_string(),
running: false,
port,
url: format!("http://localhost:{}", port),
});
}
pub async fn check_services(&mut self) -> Vec<ServiceStatus> {
for service in &mut self.services {
service.running = Self::check_service(&service.url).await;
@ -123,28 +249,41 @@ impl ServiceMonitor {
self.services.clone()
}
async fn check_service(url: &str) -> bool {
if url.starts_with("http://") || url.starts_with("https://") {
match reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
{
Ok(client) => {
match client
.get(format!("{}/health", url))
.timeout(std::time::Duration::from_secs(2))
.send()
.await
{
Ok(_) => true,
Err(_) => false,
}
}
Err(_) => false,
}
} else {
false
pub async fn check_service(url: &str) -> bool {
if !url.starts_with("http://") && !url.starts_with("https://") {
return false;
}
let client = match reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(2))
.build()
{
Ok(c) => c,
Err(_) => return false,
};
let health_url = format!("{}/health", url.trim_end_matches('/'));
match client.get(&health_url).send().await {
Ok(response) => response.status().is_success(),
Err(_) => match client.get(url).send().await {
Ok(response) => response.status().is_success(),
Err(_) => false,
},
}
}
pub fn get_service(&self, name: &str) -> Option<&ServiceStatus> {
self.services.iter().find(|s| s.name == name)
}
pub fn all_running(&self) -> bool {
self.services.iter().all(|s| s.running)
}
pub fn any_running(&self) -> bool {
self.services.iter().any(|s| s.running)
}
}

View file

@ -1,9 +1 @@
//! BotApp - Tauri wrapper for General Bots
//!
//! This crate wraps botui (pure web) with Tauri for desktop/mobile:
//! - Native file system access
//! - System tray
//! - Native dialogs
//! - App-specific guides
pub mod desktop;

View file

@ -1,29 +1,173 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
//! BotApp - Tauri Desktop Application for General Bots
//!
//! This is the entry point for the native desktop application.
//! It wraps botui's pure web UI with Tauri for native capabilities.
use log::info;
use tauri::Manager;
mod desktop;
use desktop::tray::{RunningMode, ServiceMonitor, TrayEvent, TrayManager};
#[tauri::command]
async fn get_tray_status(tray: tauri::State<'_, TrayManager>) -> Result<bool, String> {
Ok(tray.is_active().await)
}
#[tauri::command]
async fn start_tray(tray: tauri::State<'_, TrayManager>) -> Result<(), String> {
tray.start().await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn stop_tray(tray: tauri::State<'_, TrayManager>) -> Result<(), String> {
tray.stop().await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn show_notification(
tray: tauri::State<'_, TrayManager>,
title: String,
body: String,
) -> Result<(), String> {
tray.show_notification(&title, &body)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
async fn update_tray_status(
tray: tauri::State<'_, TrayManager>,
status: String,
) -> Result<(), String> {
tray.update_status(&status).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn set_tray_tooltip(
tray: tauri::State<'_, TrayManager>,
tooltip: String,
) -> Result<(), String> {
tray.set_tooltip(&tooltip).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn get_tray_hostname(tray: tauri::State<'_, TrayManager>) -> Result<Option<String>, String> {
Ok(tray.get_hostname().await)
}
#[tauri::command]
async fn set_tray_hostname(
tray: tauri::State<'_, TrayManager>,
hostname: String,
) -> Result<(), String> {
tray.set_hostname(hostname).await;
Ok(())
}
#[tauri::command]
fn handle_tray_event(tray: tauri::State<'_, TrayManager>, event: String) -> Result<(), String> {
let tray_event = match event.as_str() {
"open" => TrayEvent::Open,
"settings" => TrayEvent::Settings,
"about" => TrayEvent::About,
"quit" => TrayEvent::Quit,
_ => return Err(format!("Unknown event: {}", event)),
};
tray.handle_event(tray_event);
Ok(())
}
#[tauri::command]
async fn check_services(
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
) -> Result<Vec<desktop::tray::ServiceStatus>, String> {
let mut monitor = monitor.lock().await;
Ok(monitor.check_services().await)
}
#[tauri::command]
async fn add_service(
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
name: String,
port: u16,
) -> Result<(), String> {
let mut monitor = monitor.lock().await;
monitor.add_service(&name, port);
Ok(())
}
#[tauri::command]
async fn get_service(
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
name: String,
) -> Result<Option<desktop::tray::ServiceStatus>, String> {
let monitor = monitor.lock().await;
Ok(monitor.get_service(&name).cloned())
}
#[tauri::command]
async fn all_services_running(
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
) -> Result<bool, String> {
let monitor = monitor.lock().await;
Ok(monitor.all_running())
}
#[tauri::command]
async fn any_service_running(
monitor: tauri::State<'_, tokio::sync::Mutex<ServiceMonitor>>,
) -> Result<bool, String> {
let monitor = monitor.lock().await;
Ok(monitor.any_running())
}
#[tauri::command]
fn get_tray_mode(tray: tauri::State<'_, TrayManager>) -> String {
tray.get_mode_string()
}
#[tauri::command]
fn get_running_modes() -> Vec<&'static str> {
vec!["Server", "Desktop", "Client"]
}
#[tauri::command]
fn create_tray_with_mode(mode: String) -> Result<String, String> {
let running_mode = match mode.to_lowercase().as_str() {
"server" => RunningMode::Server,
"desktop" => RunningMode::Desktop,
"client" => RunningMode::Client,
_ => {
return Err(format!(
"Invalid mode: {}. Use Server, Desktop, or Client",
mode
))
}
};
let manager = TrayManager::with_mode(running_mode);
Ok(manager.get_mode_string())
}
fn main() {
env_logger::init();
info!("BotApp starting (Tauri)...");
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp_millis()
.init();
info!("BotApp {} starting...", env!("CARGO_PKG_VERSION"));
let tray_manager = TrayManager::with_mode(RunningMode::Desktop);
let service_monitor = tokio::sync::Mutex::new(ServiceMonitor::new());
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.manage(tray_manager)
.manage(service_monitor)
.invoke_handler(tauri::generate_handler![
// Drive commands
desktop::drive::list_files,
desktop::drive::upload_file,
desktop::drive::create_folder,
desktop::drive::delete_path,
desktop::drive::get_home_dir,
// Sync commands (rclone-based)
desktop::sync::get_sync_status,
desktop::sync::start_sync,
desktop::sync::stop_sync,
@ -32,11 +176,41 @@ fn main() {
desktop::sync::list_remotes,
desktop::sync::get_sync_folder,
desktop::sync::set_sync_folder,
get_tray_status,
start_tray,
stop_tray,
show_notification,
update_tray_status,
set_tray_tooltip,
get_tray_hostname,
set_tray_hostname,
handle_tray_event,
check_services,
add_service,
get_service,
all_services_running,
any_service_running,
get_tray_mode,
get_running_modes,
create_tray_with_mode,
])
.setup(|_app| {
info!("BotApp setup complete");
.setup(|app| {
let tray = app.state::<TrayManager>();
let mode = tray.get_mode_string();
info!("BotApp setup complete in {} mode", mode);
let tray_clone = tray.inner().clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
if let Err(e) = tray_clone.start().await {
log::error!("Failed to start tray: {}", e);
}
});
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
.expect("Failed to run BotApp");
}