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 (function () {
// Adds app-only guides and native functionality "use strict";
//
// This script runs only in the Tauri app context and extends
// the botui suite with desktop-specific features.
(function() { const APP_GUIDES = [
'use strict'; {
id: "local-files",
// App-only guides that will be injected into the suite navigation label: "Local Files",
const APP_GUIDES = [ icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{
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="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"/> <path d="M12 11v6M9 14h6"/>
</svg>`, </svg>`,
hxGet: '/app/guides/local-files.html', hxGet: "/app/guides/local-files.html",
description: 'Access and manage files on your device' description: "Access and manage files on your device",
}, },
{ {
id: 'native-settings', id: "native-settings",
label: 'App Settings', label: "App Settings",
icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 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"/> <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"/> <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>`, </svg>`,
hxGet: '/app/guides/native-settings.html', hxGet: "/app/guides/native-settings.html",
description: 'Configure desktop app settings' description: "Configure desktop app settings",
} },
]; ];
// CSS for app-only elements const APP_STYLES = `
const APP_STYLES = `
.app-grid-separator { .app-grid-separator {
grid-column: 1 / -1; grid-column: 1 / -1;
display: flex; display: flex;
@ -66,158 +58,127 @@
} }
`; `;
/** function injectStyles() {
* Inject app-specific styles const styleEl = document.createElement("style");
*/ styleEl.id = "botapp-styles";
function injectStyles() { styleEl.textContent = APP_STYLES;
const styleEl = document.createElement('style'); document.head.appendChild(styleEl);
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;
} }
/** if (document.querySelector(".app-grid-separator")) {
* Inject app-only guides into the suite navigation return;
*/ }
function injectAppGuides() {
const grid = document.querySelector('.app-grid');
if (!grid) {
// Retry if grid not found yet (page still loading)
setTimeout(injectAppGuides, 100);
return;
}
// Check if already injected const separator = document.createElement("div");
if (document.querySelector('.app-grid-separator')) { separator.className = "app-grid-separator";
return; separator.innerHTML = "<span>Desktop Features</span>";
} grid.appendChild(separator);
// Add separator APP_GUIDES.forEach((guide) => {
const separator = document.createElement('div'); const item = document.createElement("a");
separator.className = 'app-grid-separator'; item.className = "app-item app-only";
separator.innerHTML = '<span>Desktop Features</span>'; item.href = `#${guide.id}`;
grid.appendChild(separator); item.dataset.section = guide.id;
item.setAttribute("role", "menuitem");
// Add app-only guides item.setAttribute("aria-label", guide.description || guide.label);
APP_GUIDES.forEach(guide => { item.setAttribute("hx-get", guide.hxGet);
const item = document.createElement('a'); item.setAttribute("hx-target", "#main-content");
item.className = 'app-item app-only'; item.setAttribute("hx-push-url", "true");
item.href = `#${guide.id}`; item.innerHTML = `
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> <div class="app-icon" aria-hidden="true">${guide.icon}</div>
<span>${guide.label}</span> <span>${guide.label}</span>
`; `;
grid.appendChild(item); grid.appendChild(item);
}); });
// Re-process HTMX on new elements if (window.htmx) {
if (window.htmx) { htmx.process(grid);
htmx.process(grid);
}
console.log('[BotApp] App guides injected successfully');
} }
/** console.log("[BotApp] App guides injected successfully");
* Setup Tauri event listeners }
*/
function setupTauriEvents() {
if (!window.__TAURI__) {
console.warn('[BotApp] Tauri API not available');
return;
}
const { listen } = window.__TAURI__.event; function setupTauriEvents() {
if (!window.__TAURI__) {
// Listen for upload progress events console.warn("[BotApp] Tauri API not available");
listen('upload_progress', (event) => { return;
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');
} }
/** const { listen } = window.__TAURI__.event;
* Initialize BotApp extensions
*/
function init() {
console.log('[BotApp] Initializing app extensions...');
// Inject styles listen("upload_progress", (event) => {
injectStyles(); const progress = event.payload;
const progressEl = document.getElementById("upload-progress");
if (progressEl) {
progressEl.style.width = `${progress}%`;
progressEl.textContent = `${Math.round(progress)}%`;
}
});
// Inject app guides console.log("[BotApp] Tauri event listeners registered");
injectAppGuides(); }
// Setup Tauri events function init() {
setupTauriEvents(); 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 window.BotApp = {
if (document.readyState === 'loading') { isApp: true,
document.addEventListener('DOMContentLoaded', init); version: "6.1.0",
} else { guides: APP_GUIDES,
init();
}
// Expose BotApp API globally invoke: async function (cmd, args) {
window.BotApp = { if (!window.__TAURI__) {
isApp: true, throw new Error("Tauri API not available");
version: '6.1.0', }
guides: APP_GUIDES, return window.__TAURI__.core.invoke(cmd, args);
},
// Invoke Tauri commands fs: {
invoke: async function(cmd, args) { listFiles: (path) => window.BotApp.invoke("list_files", { path }),
if (!window.__TAURI__) { uploadFile: (srcPath, destPath) =>
throw new Error('Tauri API not available'); window.BotApp.invoke("upload_file", { srcPath, destPath }),
} createFolder: (path, name) =>
return window.__TAURI__.core.invoke(cmd, args); 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 notify: async function (title, body) {
fs: { if (window.__TAURI__?.notification) {
listFiles: (path) => window.BotApp.invoke('list_files', { path }), await window.__TAURI__.notification.sendNotification({ title, body });
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'),
},
// Show native notification openFileDialog: async function (options = {}) {
notify: async function(title, body) { if (!window.__TAURI__?.dialog) {
if (window.__TAURI__?.notification) { throw new Error("Dialog API not available");
await window.__TAURI__.notification.sendNotification({ title, body }); }
} return window.__TAURI__.dialog.open(options);
}, },
// 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);
}
};
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 std::path::{Path, PathBuf};
use tauri::{Emitter, Window}; use tauri::{Emitter, Window};
/// Represents a file or directory item
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct FileItem { pub struct FileItem {
pub name: String, pub name: String,
@ -16,7 +15,6 @@ pub struct FileItem {
pub size: Option<u64>, pub size: Option<u64>,
} }
/// List files in a directory
#[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);
@ -48,7 +46,6 @@ pub fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
}); });
} }
// Sort: directories first, then alphabetically
files.sort_by(|a, b| { files.sort_by(|a, b| {
if a.is_dir && !b.is_dir { if a.is_dir && !b.is_dir {
std::cmp::Ordering::Less std::cmp::Ordering::Less
@ -62,7 +59,6 @@ pub fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
Ok(files) Ok(files)
} }
/// Upload a file with progress reporting
#[tauri::command] #[tauri::command]
pub async fn upload_file( pub async fn upload_file(
window: Window, window: Window,
@ -107,7 +103,6 @@ pub async fn upload_file(
Ok(()) Ok(())
} }
/// Create a new folder
#[tauri::command] #[tauri::command]
pub fn create_folder(path: String, name: String) -> Result<(), String> { pub fn create_folder(path: String, name: String) -> Result<(), String> {
let full_path = Path::new(&path).join(&name); let full_path = Path::new(&path).join(&name);
@ -120,7 +115,6 @@ pub fn create_folder(path: String, name: String) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Delete a file or folder
#[tauri::command] #[tauri::command]
pub fn delete_path(path: String) -> Result<(), String> { pub fn delete_path(path: String) -> Result<(), String> {
let target = Path::new(&path); let target = Path::new(&path);
@ -138,7 +132,6 @@ pub fn delete_path(path: String) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Get home directory
#[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,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 drive;
pub mod sync; pub mod sync;
pub mod tray; pub mod tray;

View file

@ -11,10 +11,8 @@ use std::process::{Child, Command, Stdio};
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{Emitter, Window}; use tauri::{Emitter, Window};
/// Global state for tracking the rclone process
static RCLONE_PROCESS: Mutex<Option<Child>> = Mutex::new(None); static RCLONE_PROCESS: Mutex<Option<Child>> = Mutex::new(None);
/// Sync status reported to the UI
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncStatus { pub struct SyncStatus {
pub status: String, pub status: String,
@ -26,7 +24,6 @@ pub struct SyncStatus {
pub error: Option<String>, pub error: Option<String>,
} }
/// Sync configuration
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncConfig { pub struct SyncConfig {
pub local_path: String, pub local_path: String,
@ -36,14 +33,10 @@ pub struct SyncConfig {
pub exclude_patterns: Vec<String>, pub exclude_patterns: Vec<String>,
} }
/// Sync direction/mode
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SyncMode { pub enum SyncMode {
/// Local changes pushed to remote
Push, Push,
/// Remote changes pulled to local
Pull, Pull,
/// Bidirectional sync (newest wins)
Bisync, Bisync,
} }
@ -66,7 +59,6 @@ impl Default for SyncConfig {
} }
} }
/// Get current sync status
#[tauri::command] #[tauri::command]
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();
@ -87,12 +79,10 @@ pub fn get_sync_status() -> SyncStatus {
} }
} }
/// Start rclone sync process
#[tauri::command] #[tauri::command]
pub async fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<SyncStatus, String> { pub async fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<SyncStatus, String> {
let config = config.unwrap_or_default(); let config = config.unwrap_or_default();
// Check if already running
{ {
let process_guard = RCLONE_PROCESS.lock().unwrap(); let process_guard = RCLONE_PROCESS.lock().unwrap();
if process_guard.is_some() { 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); 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))?;
} }
// Build rclone command
let mut cmd = Command::new("rclone"); let mut cmd = Command::new("rclone");
// Set sync mode
match config.sync_mode { match config.sync_mode {
SyncMode::Push => { SyncMode::Push => {
cmd.arg("sync"); cmd.arg("sync");
@ -126,22 +113,18 @@ pub async fn start_sync(window: Window, config: Option<SyncConfig>) -> Result<Sy
cmd.arg("bisync"); cmd.arg("bisync");
cmd.arg(&config.local_path); cmd.arg(&config.local_path);
cmd.arg(format!("{}:{}", config.remote_name, config.remote_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");
cmd.arg("--progress").arg("--verbose").arg("--checksum"); // Use checksums for accuracy
// Add exclude patterns
for pattern in &config.exclude_patterns { for pattern in &config.exclude_patterns {
cmd.arg("--exclude").arg(pattern); cmd.arg("--exclude").arg(pattern);
} }
// Configure output capture
cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
// Spawn the process
let child = cmd.spawn().map_err(|e| { let child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound { if e.kind() == std::io::ErrorKind::NotFound {
"rclone not found. Please install rclone: https://rclone.org/install/".to_string() "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(); let mut process_guard = RCLONE_PROCESS.lock().unwrap();
*process_guard = Some(child); *process_guard = Some(child);
} }
// Emit started event
let _ = window.emit("sync_started", ()); let _ = window.emit("sync_started", ());
// Spawn a task to monitor the process
let window_clone = window.clone(); let window_clone = window.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
monitor_sync_process(window_clone); 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] #[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();
if let Some(mut child) = process_guard.take() { if let Some(mut child) = process_guard.take() {
// Try graceful termination first
#[cfg(unix)] #[cfg(unix)]
{ {
unsafe { unsafe {
@ -195,10 +173,8 @@ pub fn stop_sync() -> Result<SyncStatus, String> {
let _ = child.kill(); let _ = child.kill();
} }
// Wait briefly for graceful shutdown
std::thread::sleep(std::time::Duration::from_millis(500)); std::thread::sleep(std::time::Duration::from_millis(500));
// Force kill if still running
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
@ -216,7 +192,6 @@ pub fn stop_sync() -> Result<SyncStatus, String> {
} }
} }
/// Configure rclone remote for S3/MinIO
#[tauri::command] #[tauri::command]
pub fn configure_remote( pub fn configure_remote(
remote_name: String, remote_name: String,
@ -225,7 +200,6 @@ pub fn configure_remote(
secret_key: String, secret_key: String,
bucket: String, bucket: String,
) -> Result<(), String> { ) -> Result<(), String> {
// Use rclone config create command
let output = Command::new("rclone") let output = Command::new("rclone")
.args([ .args([
"config", "config",
@ -251,7 +225,6 @@ pub fn configure_remote(
return Err(format!("rclone config failed: {}", stderr)); return Err(format!("rclone config failed: {}", stderr));
} }
// Set default bucket path
let _ = Command::new("rclone") let _ = Command::new("rclone")
.args(["config", "update", &remote_name, "bucket", &bucket]) .args(["config", "update", &remote_name, "bucket", &bucket])
.output(); .output();
@ -259,7 +232,6 @@ pub fn configure_remote(
Ok(()) Ok(())
} }
/// Check if rclone is installed
#[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 = Command::new("rclone")
@ -282,7 +254,6 @@ pub fn check_rclone_installed() -> Result<String, String> {
} }
} }
/// List configured rclone remotes
#[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 = Command::new("rclone")
@ -302,7 +273,6 @@ pub fn list_remotes() -> Result<Vec<String>, String> {
} }
} }
/// Get sync folder path
#[tauri::command] #[tauri::command]
pub fn get_sync_folder() -> String { pub fn get_sync_folder() -> String {
dirs::home_dir() dirs::home_dir()
@ -310,7 +280,6 @@ pub fn get_sync_folder() -> String {
.unwrap_or_else(|| "~/GeneralBots".to_string()) .unwrap_or_else(|| "~/GeneralBots".to_string())
} }
/// Set sync folder path
#[tauri::command] #[tauri::command]
pub fn set_sync_folder(path: String) -> Result<(), String> { pub fn set_sync_folder(path: String) -> Result<(), String> {
let path = PathBuf::from(&path); 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()); 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(()) Ok(())
} }
/// Monitor the sync process and emit events
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));
@ -338,7 +304,6 @@ fn monitor_sync_process(window: Window) {
if let Some(ref mut child) = *process_guard { if let Some(ref mut child) = *process_guard {
match child.try_wait() { match child.try_wait() {
Ok(Some(status)) => { Ok(Some(status)) => {
// Process finished
let success = status.success(); let success = status.success();
*process_guard = None; *process_guard = None;
@ -364,7 +329,6 @@ fn monitor_sync_process(window: Window) {
break; break;
} }
Ok(None) => { Ok(None) => {
// Still running - emit progress
let status = SyncStatus { let status = SyncStatus {
status: "syncing".to_string(), status: "syncing".to_string(),
is_running: true, is_running: true,
@ -377,7 +341,6 @@ fn monitor_sync_process(window: Window) {
let _ = window.emit("sync_progress", &status); let _ = window.emit("sync_progress", &status);
} }
Err(e) => { Err(e) => {
// Error checking status
*process_guard = None; *process_guard = None;
let status = SyncStatus { let status = SyncStatus {
@ -395,8 +358,8 @@ fn monitor_sync_process(window: Window) {
} }
} }
} else { } else {
// No process running
break; 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 anyhow::Result;
use serde::Serialize;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
/// Tray manager handles system tray icon and interactions #[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>>,
} }
/// Running mode for the application #[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, PartialEq)]
pub enum RunningMode { pub enum RunningMode {
Server, Server,
Desktop, Desktop,
Client, Client,
} }
#[derive(Debug, Clone)]
pub enum TrayEvent {
Open,
Settings,
About,
Quit,
}
impl TrayManager { impl TrayManager {
/// Create a new TrayManager
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)),
}
}
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<()> { pub async fn start(&self) -> Result<()> {
match self.running_mode { match self.running_mode {
RunningMode::Desktop => { RunningMode::Desktop => {
@ -39,7 +51,7 @@ impl TrayManager {
log::info!("Running in server mode - tray icon disabled"); log::info!("Running in server mode - tray icon disabled");
} }
RunningMode::Client => { RunningMode::Client => {
log::info!("Running in client mode - tray icon minimal"); self.start_client_mode().await?;
} }
} }
Ok(()) Ok(())
@ -47,12 +59,53 @@ impl TrayManager {
async fn start_desktop_mode(&self) -> Result<()> { async fn start_desktop_mode(&self) -> Result<()> {
log::info!("Starting desktop mode tray icon"); 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(()) Ok(())
} }
/// Get mode as string
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(),
@ -61,17 +114,85 @@ impl TrayManager {
} }
} }
/// Update tray status
pub async fn update_status(&self, status: &str) -> Result<()> { 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(()) Ok(())
} }
/// Get hostname
pub async fn get_hostname(&self) -> Option<String> { pub async fn get_hostname(&self) -> Option<String> {
let hostname = self.hostname.read().await; let hostname = self.hostname.read().await;
hostname.clone() 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 { impl Default for TrayManager {
@ -80,13 +201,11 @@ impl Default for TrayManager {
} }
} }
/// Service status monitor
pub struct ServiceMonitor { pub struct ServiceMonitor {
services: Vec<ServiceStatus>, services: Vec<ServiceStatus>,
} }
/// Status of a service #[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone)]
pub struct ServiceStatus { pub struct ServiceStatus {
pub name: String, pub name: String,
pub running: bool, pub running: bool,
@ -95,7 +214,6 @@ pub struct ServiceStatus {
} }
impl ServiceMonitor { impl ServiceMonitor {
/// Create a new service monitor with default services
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
services: vec![ 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> { pub async fn check_services(&mut self) -> Vec<ServiceStatus> {
for service in &mut self.services { for service in &mut self.services {
service.running = Self::check_service(&service.url).await; service.running = Self::check_service(&service.url).await;
@ -123,28 +249,41 @@ impl ServiceMonitor {
self.services.clone() self.services.clone()
} }
async fn check_service(url: &str) -> bool { pub async fn check_service(url: &str) -> bool {
if url.starts_with("http://") || url.starts_with("https://") { if !url.starts_with("http://") && !url.starts_with("https://") {
match reqwest::Client::builder() return false;
.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
} }
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; pub mod desktop;

View file

@ -1,29 +1,173 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![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 log::info;
use tauri::Manager;
mod desktop; 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() { fn main() {
env_logger::init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
info!("BotApp starting (Tauri)..."); .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() 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(service_monitor)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
// Drive commands
desktop::drive::list_files, desktop::drive::list_files,
desktop::drive::upload_file, desktop::drive::upload_file,
desktop::drive::create_folder, desktop::drive::create_folder,
desktop::drive::delete_path, desktop::drive::delete_path,
desktop::drive::get_home_dir, desktop::drive::get_home_dir,
// Sync commands (rclone-based)
desktop::sync::get_sync_status, desktop::sync::get_sync_status,
desktop::sync::start_sync, desktop::sync::start_sync,
desktop::sync::stop_sync, desktop::sync::stop_sync,
@ -32,11 +176,41 @@ fn main() {
desktop::sync::list_remotes, desktop::sync::list_remotes,
desktop::sync::get_sync_folder, desktop::sync::get_sync_folder,
desktop::sync::set_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| { .setup(|app| {
info!("BotApp setup complete"); 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(()) Ok(())
}) })
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("Failed to run BotApp");
} }