add rclone sync functionality and enhance UI styles

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-03-30 14:30:33 -03:00
parent e9df98b0ff
commit bb3cd0120c
6 changed files with 424 additions and 42 deletions

View file

@ -10,6 +10,9 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
@ -23,4 +26,4 @@
"vite": "^6.0.3", "vite": "^6.0.3",
"@tauri-apps/cli": "^2" "@tauri-apps/cli": "^2"
} }
} }

18
src-tauri/Cargo.lock generated
View file

@ -493,8 +493,10 @@ checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c"
dependencies = [ dependencies = [
"android-tzdata", "android-tzdata",
"iana-time-zone", "iana-time-zone",
"js-sys",
"num-traits", "num-traits",
"serde", "serde",
"wasm-bindgen",
"windows-link", "windows-link",
] ]
@ -2048,11 +2050,13 @@ dependencies = [
name = "my-tauri-app" name = "my-tauri-app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-opener", "tauri-plugin-opener",
"tokio",
] ]
[[package]] [[package]]
@ -3879,11 +3883,25 @@ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio",
"parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tokio-macros",
"windows-sys 0.52.0", "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]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.14" version = "0.7.14"

View file

@ -22,6 +22,8 @@ tauri = { version = "2", features = [] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1.0", features = ["full"] }
chrono = "0.4"
[profile.release] [profile.release]
lto = true # Enables Link-Time Optimization lto = true # Enables Link-Time Optimization

View file

@ -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<Vec<std::process::Child>>,
sync_active: Mutex<bool>,
}
#[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<AppState>) -> 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<AppState>) -> 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<SyncStatus, String> {
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/ // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command] #[tauri::command]
fn greet(name: &str) -> String { fn greet(name: &str) -> String {
@ -7,8 +157,16 @@ fn greet(name: &str) -> String {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .manage(AppState {
.invoke_handler(tauri::generate_handler![greet]) 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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View file

@ -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 { .logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff); filter: drop-shadow(0 0 2em #747bff);
} }
@ -5,6 +53,7 @@
.logo.react:hover { .logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb); filter: drop-shadow(0 0 2em #61dafb);
} }
:root { :root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif; font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px; font-size: 16px;

View file

@ -1,50 +1,202 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import reactLogo from "./assets/react.svg";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import "./App.css"; import "./App.css";
function App() { interface RcloneConfig {
const [greetMsg, setGreetMsg] = useState(""); name: string;
const [name, setName] = useState(""); remote_path: string;
local_path: string;
access_key: string;
secret_key: string;
}
async function greet() { interface SyncStatus {
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ name: string;
setGreetMsg(await invoke("greet", { name })); 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<SyncStatus>("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 ( return (
<main className="container"> <div className="app">
<h1>Welcome to Tauri + React</h1> <div className="menu-bar">
<button onClick={() => setState(prev => ({ ...prev, show_config_dialog: true }))}>
<div className="row"> Add Sync Configuration
<a href="https://vitejs.dev" target="_blank"> </button>
<img src="/vite.svg" className="logo vite" alt="Vite logo" /> <button onClick={() => setState(prev => ({ ...prev, show_about_dialog: true }))}>
</a> About
<a href="https://tauri.app" target="_blank"> </button>
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://reactjs.org" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div> </div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<form {state.current_screen === "Main" ? (
className="row" <div className="main-screen">
onSubmit={(e) => { <h1>General Bots</h1>
e.preventDefault(); <p>{state.status_text}</p>
greet(); <button onClick={startSync}>Start Sync</button>
}} <button onClick={stopSync}>Stop Sync</button>
> <button onClick={() => setState(prev => ({ ...prev, current_screen: "Status" }))}>
<input Show Status
id="greet-input" </button>
onChange={(e) => setName(e.currentTarget.value)} </div>
placeholder="Enter a name..." ) : (
/> <div className="status-screen">
<button type="submit">Greet</button> <h1>Sync Status</h1>
</form> <div className="status-list">
<p>{greetMsg}</p> {state.sync_statuses.map((status, index) => (
</main> <div key={index} className="status-item">
<h2>{status.name}</h2>
<p>Status: {status.status}</p>
<p>Transferred: {status.transferred}</p>
<p>Bytes: {status.bytes}</p>
<p>Errors: {status.errors}</p>
<p>Last Updated: {status.last_updated}</p>
</div>
))}
</div>
<button onClick={() => setState(prev => ({ ...prev, current_screen: "Main" }))}>
Back
</button>
</div>
)}
{state.show_config_dialog && (
<div className="dialog">
<h2>Add Sync Configuration</h2>
<input
value={state.name}
onChange={(e) => setState(prev => ({ ...prev, name: e.target.value }))}
placeholder="Enter sync name"
/>
<input
value={state.access_key}
onChange={(e) => setState(prev => ({ ...prev, access_key: e.target.value }))}
placeholder="Enter access key"
/>
<input
value={state.secret_key}
onChange={(e) => setState(prev => ({ ...prev, secret_key: e.target.value }))}
placeholder="Enter secret key"
/>
<button onClick={saveConfig}>Save</button>
<button onClick={() => setState(prev => ({ ...prev, show_config_dialog: false }))}>
Cancel
</button>
</div>
)}
{state.show_about_dialog && (
<div className="dialog">
<h2>About General Bots</h2>
<p>Version: 1.0.0</p>
<p>A professional-grade sync tool for OneDrive/Dropbox-like functionality.</p>
<button onClick={() => setState(prev => ({ ...prev, show_about_dialog: false }))}>
Close
</button>
</div>
)}
</div>
); );
} }