Initial botapp - Tauri wrapper for General Bots

This crate wraps botui (pure web UI) with Tauri for desktop/mobile:
- Native file system access via Tauri commands
- System tray integration (prepared)
- App-specific guides injected at runtime
- Desktop settings and configuration

Architecture:
- botui: Pure web UI (no Tauri deps)
- botapp: Tauri wrapper that loads botui's suite

Files:
- src/desktop/drive.rs: File system commands
- src/desktop/tray.rs: System tray (prepared)
- js/app-extensions.js: Injects app guides into suite
- ui/app-guides/: App-only HTML content
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-04 09:02:10 -03:00
commit 64e11506a2
20 changed files with 13327 additions and 0 deletions

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# Build artifacts
/target
/dist
# Tauri generated
/src-tauri/target
# Dependencies
/node_modules
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.local
.env.*.local
# Lock files (keep Cargo.lock for apps)
package-lock.json
yarn.lock
pnpm-lock.yaml
# Debug
debug/
*.pdb
# Test coverage
coverage/

6725
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

42
Cargo.toml Normal file
View file

@ -0,0 +1,42 @@
[package]
name = "botapp"
version = "6.1.0"
edition = "2021"
description = "General Bots App - Tauri wrapper for desktop/mobile"
license = "AGPL-3.0"
[dependencies]
# Core from botlib
botlib = { path = "../botlib", features = ["http-client"] }
# Tauri
tauri = { version = "2", features = ["unstable"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
# Common
anyhow = "1.0"
dirs = "6.0"
log = "0.4"
env_logger = "0.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.41", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
# Platform-specific tray
[target.'cfg(unix)'.dependencies]
ksni = { version = "0.2", optional = true }
[target.'cfg(windows)'.dependencies]
trayicon = { version = "0.2", optional = true }
image = "0.25"
thiserror = "2.0"
[features]
default = ["desktop"]
desktop = []
desktop-tray = ["dep:ksni", "dep:trayicon"]
[build-dependencies]
tauri-build = "2"

3
LICENSE Normal file
View file

@ -0,0 +1,3 @@
AGPL-3.0
See https://www.gnu.org/licenses/agpl-3.0.html

161
README.md Normal file
View file

@ -0,0 +1,161 @@
# BotApp - General Bots Desktop Application
BotApp is the Tauri-based desktop wrapper for General Bots, providing native desktop and mobile capabilities on top of the pure web UI from [botui](https://github.com/GeneralBots/botui).
## Architecture
```
botui (pure web) botapp (Tauri wrapper)
┌─────────────────┐ ┌─────────────────────────┐
│ suite/ │◄─────│ Loads botui's UI │
│ minimal/ │ │ + injects app-only │
│ shared/ │ │ features via JS │
│ │ │ │
│ No Tauri deps │ │ Tauri + native APIs │
└─────────────────┘ └─────────────────────────┘
```
### Why Two Projects?
- **botui**: Pure web UI with zero native dependencies. Works in any browser.
- **botapp**: Wraps botui with Tauri for desktop/mobile native features.
This separation allows:
- Same UI code for web, desktop, and mobile
- Clean dependency management (web users don't need Tauri)
- App-specific features only in the native app
## Features
BotApp adds these native capabilities to botui:
- **Local File Access**: Browse and manage files on your device
- **System Tray**: Minimize to tray, background operation
- **Native Dialogs**: File open/save dialogs
- **Desktop Notifications**: Native OS notifications
- **App Settings**: Desktop-specific configuration
## Project Structure
```
botapp/
├── Cargo.toml # Rust dependencies (includes Tauri)
├── build.rs # Tauri build script
├── tauri.conf.json # Tauri configuration
├── src/
│ ├── main.rs # Tauri entry point
│ ├── lib.rs # Library exports
│ └── desktop/
│ ├── mod.rs # Desktop module
│ ├── drive.rs # File system commands
│ └── tray.rs # System tray functionality
├── ui/
│ └── app-guides/ # App-only HTML content
│ ├── local-files.html
│ └── native-settings.html
└── js/
└── app-extensions.js # Injected into botui's suite
```
## Prerequisites
- Rust 1.70+
- Node.js 18+ (for Tauri CLI)
- Tauri CLI: `cargo install tauri-cli`
### Platform-specific
**Linux:**
```bash
sudo apt install libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
```
**macOS:**
```bash
xcode-select --install
```
**Windows:**
- Visual Studio Build Tools with C++ workload
## Development
1. Clone both repositories:
```bash
git clone https://github.com/GeneralBots/botui.git
git clone https://github.com/GeneralBots/botapp.git
```
2. Start botui's web server (required for dev):
```bash
cd botui
cargo run
```
3. Run botapp in development mode:
```bash
cd botapp
cargo tauri dev
```
## Building
### Debug Build
```bash
cargo tauri build --debug
```
### Release Build
```bash
cargo tauri build
```
Binaries will be in `target/release/bundle/`.
## How App Extensions Work
BotApp injects `js/app-extensions.js` into botui's suite at runtime. This script:
1. Detects Tauri environment (`window.__TAURI__`)
2. Injects app-only navigation items into the suite's `.app-grid`
3. Exposes `window.BotApp` API for native features
Example usage in suite:
```javascript
if (window.BotApp?.isApp) {
// Running in desktop app
const files = await BotApp.fs.listFiles('/home/user');
await BotApp.notify('Title', 'Native notification!');
}
```
## Tauri Commands
Available Tauri commands (invokable from JS):
| Command | Description |
|---------|-------------|
| `list_files` | List directory contents |
| `upload_file` | Copy file with progress |
| `create_folder` | Create new directory |
| `delete_path` | Delete file or folder |
| `get_home_dir` | Get user's home directory |
## Configuration
Edit `tauri.conf.json` to customize:
- `productName`: Application name
- `identifier`: Unique app identifier
- `build.devUrl`: URL for development (default: `http://localhost:3000`)
- `build.frontendDist`: Path to botui's UI (default: `../botui/ui/suite`)
## License
AGPL-3.0 - See [LICENSE](LICENSE) for details.
## Related Projects
- [botui](https://github.com/GeneralBots/botui) - Pure web UI
- [botserver](https://github.com/GeneralBots/botserver) - Backend server
- [botlib](https://github.com/GeneralBots/botlib) - Shared Rust library

3
build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

BIN
icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

223
js/app-extensions.js Normal file
View file

@ -0,0 +1,223 @@
// 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';
// 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">
<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">
<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'
}
];
// CSS for app-only elements
const APP_STYLES = `
.app-grid-separator {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0;
margin-top: 0.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.app-grid-separator span {
font-size: 0.75rem;
color: var(--text-secondary, #666);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.app-item.app-only {
position: relative;
}
.app-item.app-only::after {
content: '';
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
background: var(--accent-color, #4a90d9);
border-radius: 50%;
}
`;
/**
* Inject app-specific styles
*/
function injectStyles() {
const styleEl = document.createElement('style');
styleEl.id = 'botapp-styles';
styleEl.textContent = APP_STYLES;
document.head.appendChild(styleEl);
}
/**
* 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;
}
// Check if already injected
if (document.querySelector('.app-grid-separator')) {
return;
}
// 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 = `
<div class="app-icon" aria-hidden="true">${guide.icon}</div>
<span>${guide.label}</span>
`;
grid.appendChild(item);
});
// Re-process HTMX on new elements
if (window.htmx) {
htmx.process(grid);
}
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;
// 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');
}
/**
* Initialize BotApp extensions
*/
function init() {
console.log('[BotApp] Initializing app extensions...');
// Inject styles
injectStyles();
// Inject app guides
injectAppGuides();
// Setup Tauri events
setupTauriEvents();
console.log('[BotApp] App extensions initialized');
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Expose BotApp API globally
window.BotApp = {
isApp: true,
version: '6.1.0',
guides: APP_GUIDES,
// 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);
},
// 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'),
},
// 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);
}
};
})();

147
src/desktop/drive.rs Normal file
View file

@ -0,0 +1,147 @@
//! Drive/File System commands for Tauri
//!
//! Provides native file system access for the desktop app.
use serde::{Deserialize, Serialize};
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,
pub path: String,
pub is_dir: bool,
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);
let mut files = Vec::new();
if !base_path.exists() {
return Err("Path does not exist".into());
}
for entry in fs::read_dir(base_path).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let metadata = entry.metadata().ok();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let size = metadata.as_ref().map(|m| m.len());
let is_dir = metadata.map(|m| m.is_dir()).unwrap_or(false);
files.push(FileItem {
name,
path: path.to_str().unwrap_or("").to_string(),
is_dir,
size,
});
}
// Sort: directories first, then alphabetically
files.sort_by(|a, b| {
if a.is_dir && !b.is_dir {
std::cmp::Ordering::Less
} else if !a.is_dir && b.is_dir {
std::cmp::Ordering::Greater
} else {
a.name.to_lowercase().cmp(&b.name.to_lowercase())
}
});
Ok(files)
}
/// Upload a file with progress reporting
#[tauri::command]
pub async fn upload_file(
window: Window,
src_path: String,
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")?);
if !dest_dir.exists() {
fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?;
}
let mut source_file = File::open(&src).map_err(|e| e.to_string())?;
let mut dest_file = File::create(&dest).map_err(|e| e.to_string())?;
let file_size = source_file.metadata().map_err(|e| e.to_string())?.len();
let mut buffer = [0; 8192];
let mut total_read = 0u64;
loop {
let bytes_read = source_file.read(&mut buffer).map_err(|e| e.to_string())?;
if bytes_read == 0 {
break;
}
dest_file
.write_all(&buffer[..bytes_read])
.map_err(|e| e.to_string())?;
total_read += bytes_read as u64;
let progress = (total_read as f64 / file_size as f64) * 100.0;
window
.emit("upload_progress", progress)
.map_err(|e| e.to_string())?;
}
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);
if full_path.exists() {
return Err("Folder already exists".into());
}
fs::create_dir(&full_path).map_err(|e| e.to_string())?;
Ok(())
}
/// Delete a file or folder
#[tauri::command]
pub fn delete_path(path: String) -> Result<(), String> {
let target = Path::new(&path);
if !target.exists() {
return Err("Path does not exist".into());
}
if target.is_dir() {
fs::remove_dir_all(target).map_err(|e| e.to_string())?;
} else {
fs::remove_file(target).map_err(|e| e.to_string())?;
}
Ok(())
}
/// Get home directory
#[tauri::command]
pub fn get_home_dir() -> Result<String, String> {
dirs::home_dir()
.and_then(|p| p.to_str().map(String::from))
.ok_or_else(|| "Could not determine home directory".into())
}

8
src/desktop/mod.rs Normal file
View file

@ -0,0 +1,8 @@
//! Desktop-specific functionality for BotApp
//!
//! This module provides native desktop capabilities:
//! - Drive/file management via Tauri
//! - System tray integration
pub mod drive;
pub mod tray;

156
src/desktop/tray.rs Normal file
View file

@ -0,0 +1,156 @@
//! System Tray functionality for BotApp
//!
//! Provides system tray icon and menu for desktop platforms.
use anyhow::Result;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Tray manager handles system tray icon and interactions
pub struct TrayManager {
hostname: Arc<RwLock<Option<String>>>,
running_mode: RunningMode,
}
/// Running mode for the application
#[derive(Debug, Clone, PartialEq)]
pub enum RunningMode {
Server,
Desktop,
Client,
}
impl TrayManager {
/// Create a new TrayManager
pub fn new() -> Self {
Self {
hostname: Arc::new(RwLock::new(None)),
running_mode: RunningMode::Desktop,
}
}
/// Start the tray icon
pub async fn start(&self) -> Result<()> {
match self.running_mode {
RunningMode::Desktop => {
self.start_desktop_mode().await?;
}
RunningMode::Server => {
log::info!("Running in server mode - tray icon disabled");
}
RunningMode::Client => {
log::info!("Running in client mode - tray icon minimal");
}
}
Ok(())
}
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
Ok(())
}
/// Get mode as string
#[allow(dead_code)]
fn get_mode_string(&self) -> String {
match self.running_mode {
RunningMode::Desktop => "Desktop".to_string(),
RunningMode::Server => "Server".to_string(),
RunningMode::Client => "Client".to_string(),
}
}
/// Update tray status
pub async fn update_status(&self, status: &str) -> Result<()> {
log::info!("Tray status update: {}", status);
Ok(())
}
/// Get hostname
pub async fn get_hostname(&self) -> Option<String> {
let hostname = self.hostname.read().await;
hostname.clone()
}
}
impl Default for TrayManager {
fn default() -> Self {
Self::new()
}
}
/// Service status monitor
pub struct ServiceMonitor {
services: Vec<ServiceStatus>,
}
/// Status of a service
#[derive(Debug, Clone)]
pub struct ServiceStatus {
pub name: String,
pub running: bool,
pub port: u16,
pub url: String,
}
impl ServiceMonitor {
/// Create a new service monitor with default services
pub fn new() -> Self {
Self {
services: vec![
ServiceStatus {
name: "API".to_string(),
running: false,
port: 8080,
url: "http://localhost:8080".to_string(),
},
ServiceStatus {
name: "UI".to_string(),
running: false,
port: 3000,
url: "http://localhost:3000".to_string(),
},
],
}
}
/// Check all services
pub async fn check_services(&mut self) -> Vec<ServiceStatus> {
for service in &mut self.services {
service.running = Self::check_service(&service.url).await;
}
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
}
}
}
impl Default for ServiceMonitor {
fn default() -> Self {
Self::new()
}
}

9
src/lib.rs Normal file
View file

@ -0,0 +1,9 @@
//! 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;

32
src/main.rs Normal file
View file

@ -0,0 +1,32 @@
#![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;
mod desktop;
fn main() {
env_logger::init();
info!("BotApp starting (Tauri)...");
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
desktop::drive::list_files,
desktop::drive::upload_file,
desktop::drive::create_folder,
desktop::drive::delete_path,
desktop::drive::get_home_dir,
])
.setup(|_app| {
info!("BotApp setup complete");
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

31
tauri.conf.json Normal file
View file

@ -0,0 +1,31 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "General Bots",
"version": "6.1.0",
"identifier": "br.com.pragmatismo.botapp",
"build": {
"beforeDevCommand": "",
"beforeBuildCommand": "",
"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": []
}
}

View file

@ -0,0 +1,326 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Local Files - General Bots</title>
<style>
.local-files {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.guide-header {
margin-bottom: 2rem;
}
.guide-header h1 {
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--fg, #333);
}
.guide-header p {
color: var(--fg-muted, #666);
}
.path-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
}
.path-bar input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border, #ddd);
border-radius: 4px;
font-family: monospace;
background: var(--bg, #fff);
color: var(--fg, #333);
}
.path-bar button {
padding: 0.5rem 1rem;
background: var(--primary, #0066cc);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.path-bar button:hover {
background: var(--primary-hover, #0055aa);
}
.file-list {
border: 1px solid var(--border, #ddd);
border-radius: 8px;
min-height: 300px;
max-height: 500px;
overflow-y: auto;
background: var(--bg, #fff);
}
.file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-light, #eee);
cursor: pointer;
transition: background 0.15s;
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background: var(--bg-hover, #f8f8f8);
}
.file-item.folder {
font-weight: 500;
}
.file-item .icon {
font-size: 1.25rem;
width: 24px;
text-align: center;
}
.file-item .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-item .size {
color: var(--fg-muted, #888);
font-size: 0.85rem;
}
.file-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.file-actions button {
padding: 0.5rem 1rem;
background: var(--bg-secondary, #f0f0f0);
color: var(--fg, #333);
border: 1px solid var(--border, #ddd);
border-radius: 4px;
cursor: pointer;
}
.file-actions button:hover {
background: var(--bg-hover, #e8e8e8);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--fg-muted, #888);
}
.empty-state .icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--fg-muted, #888);
}
</style>
</head>
<body>
<div class="app-guide local-files">
<header class="guide-header">
<h1>📁 Local Files</h1>
<p>Access and manage files on your device using native file system access.</p>
</header>
<section class="file-browser">
<div class="path-bar">
<button onclick="goUp()" title="Go up one level">⬆️</button>
<input type="text" id="currentPath" placeholder="Enter path..." onkeydown="if(event.key==='Enter')loadFiles()">
<button onclick="loadFiles()">Browse</button>
<button onclick="goHome()">🏠 Home</button>
</div>
<div class="file-list" id="fileList">
<div class="empty-state">
<span class="icon">📂</span>
<p>Click "Home" or enter a path to browse files</p>
</div>
</div>
<div class="file-actions">
<button onclick="createFolder()">📁 New Folder</button>
<button onclick="refreshFiles()">🔄 Refresh</button>
</div>
</section>
</div>
<script>
// Check if running in Tauri
const isTauri = window.__TAURI__ !== undefined;
async function invoke(cmd, args) {
if (isTauri && window.__TAURI__.core) {
return await window.__TAURI__.core.invoke(cmd, args);
} else {
console.warn('Not running in Tauri - file operations unavailable');
throw new Error('Native file access requires the desktop app');
}
}
async function goHome() {
try {
const homePath = await invoke('get_home_dir', {});
document.getElementById('currentPath').value = homePath;
await loadFiles();
} catch (e) {
showError(e.message);
}
}
async function goUp() {
const pathInput = document.getElementById('currentPath');
const currentPath = pathInput.value;
if (!currentPath) return;
const parts = currentPath.split(/[/\\]/);
parts.pop();
if (parts.length > 0) {
pathInput.value = parts.join('/') || '/';
await loadFiles();
}
}
async function loadFiles() {
const pathInput = document.getElementById('currentPath');
const path = pathInput.value || '/';
const listEl = document.getElementById('fileList');
listEl.innerHTML = '<div class="loading">Loading...</div>';
try {
const files = await invoke('list_files', { path });
if (files.length === 0) {
listEl.innerHTML = `
<div class="empty-state">
<span class="icon">📭</span>
<p>This folder is empty</p>
</div>
`;
return;
}
listEl.innerHTML = files.map(f => `
<div class="file-item ${f.is_dir ? 'folder' : 'file'}"
onclick="${f.is_dir ? `navigateTo('${f.path.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')` : ''}">
<span class="icon">${f.is_dir ? '📁' : getFileIcon(f.name)}</span>
<span class="name">${escapeHtml(f.name)}</span>
<span class="size">${f.is_dir ? '' : formatSize(f.size)}</span>
</div>
`).join('');
} catch (e) {
listEl.innerHTML = `
<div class="empty-state">
<span class="icon">⚠️</span>
<p>${escapeHtml(e.message || 'Failed to load files')}</p>
</div>
`;
}
}
function navigateTo(path) {
document.getElementById('currentPath').value = path;
loadFiles();
}
async function createFolder() {
const path = document.getElementById('currentPath').value;
if (!path) {
showError('Please navigate to a directory first');
return;
}
const name = prompt('Enter folder name:');
if (name) {
try {
await invoke('create_folder', { path, name });
await loadFiles();
} catch (e) {
showError(e.message);
}
}
}
function refreshFiles() {
loadFiles();
}
function formatSize(bytes) {
if (bytes === null || bytes === undefined) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB';
}
function getFileIcon(filename) {
const ext = filename.split('.').pop().toLowerCase();
const icons = {
'pdf': '📕',
'doc': '📘', 'docx': '📘',
'xls': '📗', 'xlsx': '📗',
'ppt': '📙', 'pptx': '📙',
'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', 'svg': '🖼️',
'mp3': '🎵', 'wav': '🎵', 'flac': '🎵',
'mp4': '🎬', 'mkv': '🎬', 'avi': '🎬',
'zip': '📦', 'rar': '📦', 'tar': '📦', 'gz': '📦',
'js': '📜', 'ts': '📜', 'py': '📜', 'rs': '📜',
'html': '🌐', 'css': '🎨',
'json': '📋', 'xml': '📋', 'yaml': '📋', 'yml': '📋',
'txt': '📄', 'md': '📄',
};
return icons[ext] || '📄';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function showError(message) {
alert('Error: ' + message);
}
// Auto-load home directory on init if in Tauri
if (isTauri) {
goHome();
}
</script>
</body>
</html>

View file

@ -0,0 +1,332 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>App Settings - General Bots</title>
<style>
.app-guide {
padding: 2rem;
max-width: 800px;
margin: 0 auto;
}
.guide-header {
margin-bottom: 2rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
padding-bottom: 1rem;
}
.guide-header h1 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
font-weight: 600;
}
.guide-header p {
margin: 0;
color: var(--text-secondary, #666);
}
.settings-section {
margin-bottom: 2rem;
}
.settings-section h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
color: var(--text-primary, #333);
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
margin-bottom: 0.75rem;
}
.setting-info {
flex: 1;
}
.setting-label {
font-weight: 500;
margin-bottom: 0.25rem;
}
.setting-description {
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
.setting-control {
margin-left: 1rem;
}
.toggle-switch {
position: relative;
width: 48px;
height: 24px;
background: var(--bg-tertiary, #ccc);
border-radius: 12px;
cursor: pointer;
transition: background 0.2s;
}
.toggle-switch.active {
background: var(--accent-color, #4CAF50);
}
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle-switch.active::after {
transform: translateX(24px);
}
select, input[type="text"], input[type="number"] {
padding: 0.5rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
font-size: 0.875rem;
min-width: 150px;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.8;
}
.btn-primary {
background: var(--accent-color, #2196F3);
color: white;
}
.btn-secondary {
background: var(--bg-tertiary, #e0e0e0);
color: var(--text-primary, #333);
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.version-info {
margin-top: 2rem;
padding: 1rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
</style>
</head>
<body>
<div class="app-guide native-settings">
<header class="guide-header">
<h1>App Settings</h1>
<p>Configure native desktop application preferences</p>
</header>
<section class="settings-section">
<h2>General</h2>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Start on login</div>
<div class="setting-description">Automatically start General Bots when you log in</div>
</div>
<div class="setting-control">
<div class="toggle-switch" id="startOnLogin" onclick="toggleSetting(this)"></div>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Minimize to tray</div>
<div class="setting-description">Keep running in system tray when window is closed</div>
</div>
<div class="setting-control">
<div class="toggle-switch active" id="minimizeToTray" onclick="toggleSetting(this)"></div>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Default download location</div>
<div class="setting-description">Where to save downloaded files</div>
</div>
<div class="setting-control">
<button class="btn btn-secondary" onclick="selectDownloadPath()">Browse...</button>
</div>
</div>
</section>
<section class="settings-section">
<h2>Server Connection</h2>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Server URL</div>
<div class="setting-description">BotServer API endpoint</div>
</div>
<div class="setting-control">
<input type="text" id="serverUrl" value="http://localhost:8080" />
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Auto-reconnect</div>
<div class="setting-description">Automatically reconnect when connection is lost</div>
</div>
<div class="setting-control">
<div class="toggle-switch active" id="autoReconnect" onclick="toggleSetting(this)"></div>
</div>
</div>
</section>
<section class="settings-section">
<h2>Notifications</h2>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Desktop notifications</div>
<div class="setting-description">Show native desktop notifications</div>
</div>
<div class="setting-control">
<div class="toggle-switch active" id="desktopNotifications" onclick="toggleSetting(this)"></div>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">Sound alerts</div>
<div class="setting-description">Play sound when receiving messages</div>
</div>
<div class="setting-control">
<div class="toggle-switch" id="soundAlerts" onclick="toggleSetting(this)"></div>
</div>
</div>
</section>
<div class="actions">
<button class="btn btn-primary" onclick="saveSettings()">Save Settings</button>
<button class="btn btn-secondary" onclick="resetSettings()">Reset to Defaults</button>
</div>
<div class="version-info">
<strong>General Bots App</strong><br>
Version: <span id="appVersion">6.1.0</span><br>
Platform: <span id="appPlatform">-</span>
</div>
</div>
<script>
// Toggle switch functionality
function toggleSetting(element) {
element.classList.toggle('active');
}
// Select download path via Tauri dialog
async function selectDownloadPath() {
if (window.__TAURI__) {
try {
const { open } = window.__TAURI__.dialog;
const selected = await open({
directory: true,
multiple: false,
title: 'Select Download Location'
});
if (selected) {
console.log('Selected path:', selected);
}
} catch (e) {
console.error('Failed to open dialog:', e);
}
}
}
// Save settings
async function saveSettings() {
const settings = {
startOnLogin: document.getElementById('startOnLogin').classList.contains('active'),
minimizeToTray: document.getElementById('minimizeToTray').classList.contains('active'),
serverUrl: document.getElementById('serverUrl').value,
autoReconnect: document.getElementById('autoReconnect').classList.contains('active'),
desktopNotifications: document.getElementById('desktopNotifications').classList.contains('active'),
soundAlerts: document.getElementById('soundAlerts').classList.contains('active')
};
// Save to localStorage for now
localStorage.setItem('botapp_settings', JSON.stringify(settings));
// Show confirmation
if (window.BotServerApp) {
window.BotServerApp.showNotification('Settings saved successfully', 'success');
} else {
alert('Settings saved!');
}
}
// Reset settings to defaults
function resetSettings() {
document.getElementById('startOnLogin').classList.remove('active');
document.getElementById('minimizeToTray').classList.add('active');
document.getElementById('serverUrl').value = 'http://localhost:8080';
document.getElementById('autoReconnect').classList.add('active');
document.getElementById('desktopNotifications').classList.add('active');
document.getElementById('soundAlerts').classList.remove('active');
}
// Load settings on page load
function loadSettings() {
const saved = localStorage.getItem('botapp_settings');
if (saved) {
const settings = JSON.parse(saved);
if (settings.startOnLogin) document.getElementById('startOnLogin').classList.add('active');
if (!settings.minimizeToTray) document.getElementById('minimizeToTray').classList.remove('active');
if (settings.serverUrl) document.getElementById('serverUrl').value = settings.serverUrl;
if (!settings.autoReconnect) document.getElementById('autoReconnect').classList.remove('active');
if (!settings.desktopNotifications) document.getElementById('desktopNotifications').classList.remove('active');
if (settings.soundAlerts) document.getElementById('soundAlerts').classList.add('active');
}
// Set platform info
if (window.__TAURI__) {
document.getElementById('appPlatform').textContent = navigator.platform;
} else {
document.getElementById('appPlatform').textContent = 'Web (not in app)';
}
}
// Initialize
loadSettings();
</script>
</body>
</html>