From bb3cd0120c1836ec723c80d8360a30c4fccbdc95 Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sun, 30 Mar 2025 14:30:33 -0300 Subject: [PATCH] add rclone sync functionality and enhance UI styles --- package.json | 5 +- src-tauri/Cargo.lock | 18 ++++ src-tauri/Cargo.toml | 2 + src-tauri/src/lib.rs | 162 +++++++++++++++++++++++++++++- src/App.css | 49 +++++++++ src/App.tsx | 230 +++++++++++++++++++++++++++++++++++-------- 6 files changed, 424 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index ef18ed1..a1b860a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "tauri": "tauri" }, "dependencies": { + "autoprefixer": "^10.4.17", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", "react": "^18.3.1", "react-dom": "^18.3.1", "@tauri-apps/api": "^2", @@ -23,4 +26,4 @@ "vite": "^6.0.3", "@tauri-apps/cli": "^2" } -} +} \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2a7d132..268429a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -493,8 +493,10 @@ checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -2048,11 +2050,13 @@ dependencies = [ name = "my-tauri-app" version = "0.1.0" dependencies = [ + "chrono", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-opener", + "tokio", ] [[package]] @@ -3879,11 +3883,25 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "tokio-util" version = "0.7.14" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5b809e6..3316be6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,8 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +tokio = { version = "1.0", features = ["full"] } +chrono = "0.4" [profile.release] lto = true # Enables Link-Time Optimization diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..4253e11 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,153 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + + +use serde::{Deserialize, Serialize}; +use tauri::{Manager, Window}; +use std::sync::Mutex; +use std::process::{Command, Stdio}; +use std::path::Path; +use std::fs::{File, OpenOptions, create_dir_all}; +use std::io::Write; +use std::env; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RcloneConfig { + name: String, + remote_path: String, + local_path: String, + access_key: String, + secret_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SyncStatus { + name: String, + status: String, + transferred: String, + bytes: String, + errors: usize, + last_updated: String, +} + +struct AppState { + sync_processes: Mutex>, + sync_active: Mutex, +} + +#[tauri::command] +fn save_config(config: RcloneConfig) -> Result<(), String> { + let home_dir = env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?; + let config_path = Path::new(&home_dir).join(".config/rclone/rclone.conf"); + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&config_path) + .map_err(|e| format!("Failed to open config file: {}", e))?; + + writeln!(file, "[{}]", config.name) + .and_then(|_| writeln!(file, "type = s3")) + .and_then(|_| writeln!(file, "provider = Other")) + .and_then(|_| writeln!(file, "access_key_id = {}", config.access_key)) + .and_then(|_| writeln!(file, "secret_access_key = {}", config.secret_key)) + .and_then(|_| writeln!(file, "endpoint = https://drive-api.pragmatismo.com.br")) + .and_then(|_| writeln!(file, "acl = private")) + .map_err(|e| format!("Failed to write config: {}", e)) +} + +#[tauri::command] +fn start_sync(config: RcloneConfig, state: tauri::State) -> Result<(), String> { + let local_path = Path::new(&config.local_path); + if !local_path.exists() { + create_dir_all(local_path).map_err(|e| format!("Failed to create local path: {}", e))?; + } + + let child = Command::new("rclone") + .arg("sync") + .arg(&config.remote_path) + .arg(&config.local_path) + .arg("--no-check-certificate") + .arg("--verbose") + .arg("--rc") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to start rclone: {}", e))?; + + state.sync_processes.lock().unwrap().push(child); + *state.sync_active.lock().unwrap() = true; + Ok(()) +} + +#[tauri::command] +fn stop_sync(state: tauri::State) -> Result<(), String> { + let mut processes = state.sync_processes.lock().unwrap(); + for child in processes.iter_mut() { + child.kill().map_err(|e| format!("Failed to kill process: {}", e))?; + } + processes.clear(); + *state.sync_active.lock().unwrap() = false; + Ok(()) +} + +#[tauri::command] +fn get_status(remote_name: String) -> Result { + let output = Command::new("rclone") + .arg("rc") + .arg("core/stats") + .arg("--json") + .output() + .map_err(|e| format!("Failed to execute rclone rc: {}", e))?; + + if !output.status.success() { + return Err(format!("rclone rc failed: {}", String::from_utf8_lossy(&output.stderr))); + } + + let json = String::from_utf8_lossy(&output.stdout); + let value: serde_json::Value = serde_json::from_str(&json) + .map_err(|e| format!("Failed to parse rclone status: {}", e))?; + + let transferred = value.get("bytes").and_then(|v| v.as_u64()).unwrap_or(0); + let errors = value.get("errors").and_then(|v| v.as_u64()).unwrap_or(0); + let speed = value.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0); + + let status = if errors > 0 { + "Error occurred".to_string() + } else if speed > 0.0 { + "Transferring".to_string() + } else if transferred > 0 { + "Completed".to_string() + } else { + "Initializing".to_string() + }; + + Ok(SyncStatus { + name: remote_name, + status, + transferred: format_bytes(transferred), + bytes: format!("{}/s", format_bytes(speed as u64)), + errors: errors as usize, + last_updated: chrono::Local::now().format("%H:%M:%S").to_string(), + }) +} + +fn format_bytes(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} + // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] fn greet(name: &str) -> String { @@ -7,8 +157,16 @@ fn greet(name: &str) -> String { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .manage(AppState { + sync_processes: Mutex::new(Vec::new()), + sync_active: Mutex::new(false), + }) + .plugin(tauri_plugin_opener::init()) + .invoke_handler(tauri::generate_handler![ + save_config, + start_sync, + stop_sync, + get_status]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/App.css b/src/App.css index 85f7a4a..c1e83c6 100644 --- a/src/App.css +++ b/src/App.css @@ -1,3 +1,51 @@ +.app { + padding: 20px; + max-width: 800px; + margin: 0 auto; +} + +.menu-bar { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +.main-screen, .status-screen { + display: flex; + flex-direction: column; + gap: 15px; + align-items: center; +} + +.dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + z-index: 100; + display: flex; + flex-direction: column; + gap: 10px; +} + +.status-list { + display: flex; + flex-direction: column; + gap: 15px; + width: 100%; +} + +.status-item { + background-color: #f0f0f0; + padding: 15px; + border-radius: 8px; +} + +/* Existing styles below */ .logo.vite:hover { filter: drop-shadow(0 0 2em #747bff); } @@ -5,6 +53,7 @@ .logo.react:hover { filter: drop-shadow(0 0 2em #61dafb); } + :root { font-family: Inter, Avenir, Helvetica, Arial, sans-serif; font-size: 16px; diff --git a/src/App.tsx b/src/App.tsx index 9855368..01d9f1f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,50 +1,202 @@ -import { useState } from "react"; -import reactLogo from "./assets/react.svg"; +import { useState, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; import "./App.css"; -function App() { - const [greetMsg, setGreetMsg] = useState(""); - const [name, setName] = useState(""); +interface RcloneConfig { + name: string; + remote_path: string; + local_path: string; + access_key: string; + secret_key: string; +} - async function greet() { - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ - setGreetMsg(await invoke("greet", { name })); - } +interface SyncStatus { + name: string; + status: string; + transferred: string; + bytes: string; + errors: number; + last_updated: string; +} + +type Screen = "Main" | "Status"; + +function App() { + const [state, setState] = useState({ + name: "", + access_key: "", + secret_key: "", + status_text: "Enter credentials to set up sync", + sync_statuses: [] as SyncStatus[], + show_config_dialog: false, + show_about_dialog: false, + current_screen: "Main" as Screen, + }); + + useEffect(() => { + if (state.current_screen === "Status") { + const interval = setInterval(() => { + // In a real app, you would fetch actual configs here + // This is just a mock implementation + invoke("get_status", { remoteName: "example" }) + .then((status) => { + setState(prev => ({ + ...prev, + sync_statuses: [status] + })); + }) + .catch(console.error); + }, 5000); + return () => clearInterval(interval); + } + }, [state.current_screen]); + + const saveConfig = async () => { + if (!state.name || !state.access_key || !state.secret_key) { + setState(prev => ({ ...prev, status_text: "All fields are required!" })); + return; + } + + const config: RcloneConfig = { + name: state.name, + remote_path: `s3://${state.name}`, + local_path: `${window.__TAURI__.path.homeDir}/General Bots/${state.name}`, + access_key: state.access_key, + secret_key: state.secret_key, + }; + + try { + await invoke("save_config", { config }); + setState(prev => ({ + ...prev, + status_text: "New sync saved!", + show_config_dialog: false + })); + } catch (e) { + setState(prev => ({ + ...prev, + status_text: `Failed to save config: ${e}` + })); + } + }; + + const startSync = async () => { + // In a real app, you would fetch actual configs here + const config: RcloneConfig = { + name: state.name || "example", + remote_path: `s3://${state.name || "example"}`, + local_path: `${window.__TAURI__.path.homeDir}/General Bots/${state.name || "example"}`, + access_key: state.access_key || "dummy", + secret_key: state.secret_key || "dummy", + }; + + try { + await invoke("start_sync", { config }); + setState(prev => ({ + ...prev, + status_text: "Sync started!" + })); + } catch (e) { + setState(prev => ({ + ...prev, + status_text: `Failed to start sync: ${e}` + })); + } + }; + + const stopSync = async () => { + try { + await invoke("stop_sync"); + setState(prev => ({ + ...prev, + status_text: "Sync stopped." + })); + } catch (e) { + setState(prev => ({ + ...prev, + status_text: `Failed to stop sync: ${e}` + })); + } + }; return ( -
-

Welcome to Tauri + React

- -
- - Vite logo - - - Tauri logo - - - React logo - +
+
+ +
-

Click on the Tauri, Vite, and React logos to learn more.

-
{ - e.preventDefault(); - greet(); - }} - > - setName(e.currentTarget.value)} - placeholder="Enter a name..." - /> - -
-

{greetMsg}

-
+ {state.current_screen === "Main" ? ( +
+

General Bots

+

{state.status_text}

+ + + +
+ ) : ( +
+

Sync Status

+
+ {state.sync_statuses.map((status, index) => ( +
+

{status.name}

+

Status: {status.status}

+

Transferred: {status.transferred}

+

Bytes: {status.bytes}

+

Errors: {status.errors}

+

Last Updated: {status.last_updated}

+
+ ))} +
+ +
+ )} + + {state.show_config_dialog && ( +
+

Add Sync Configuration

+ setState(prev => ({ ...prev, name: e.target.value }))} + placeholder="Enter sync name" + /> + setState(prev => ({ ...prev, access_key: e.target.value }))} + placeholder="Enter access key" + /> + setState(prev => ({ ...prev, secret_key: e.target.value }))} + placeholder="Enter secret key" + /> + + +
+ )} + + {state.show_about_dialog && ( +
+

About General Bots

+

Version: 1.0.0

+

A professional-grade sync tool for OneDrive/Dropbox-like functionality.

+ +
+ )} + ); }