add rclone sync functionality and enhance UI styles
This commit is contained in:
parent
e9df98b0ff
commit
bb3cd0120c
6 changed files with 424 additions and 42 deletions
|
@ -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",
|
||||
|
|
18
src-tauri/Cargo.lock
generated
18
src-tauri/Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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/
|
||||
#[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()
|
||||
.manage(AppState {
|
||||
sync_processes: Mutex::new(Vec::new()),
|
||||
sync_active: Mutex::new(false),
|
||||
})
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
save_config,
|
||||
start_sync,
|
||||
stop_sync,
|
||||
get_status])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
|
49
src/App.css
49
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;
|
||||
|
|
226
src/App.tsx
226
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("");
|
||||
|
||||
async function greet() {
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
setGreetMsg(await invoke("greet", { name }));
|
||||
interface RcloneConfig {
|
||||
name: string;
|
||||
remote_path: string;
|
||||
local_path: string;
|
||||
access_key: string;
|
||||
secret_key: string;
|
||||
}
|
||||
|
||||
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<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 (
|
||||
<main className="container">
|
||||
<h1>Welcome to Tauri + React</h1>
|
||||
|
||||
<div className="row">
|
||||
<a href="https://vitejs.dev" target="_blank">
|
||||
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://tauri.app" target="_blank">
|
||||
<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 className="app">
|
||||
<div className="menu-bar">
|
||||
<button onClick={() => setState(prev => ({ ...prev, show_config_dialog: true }))}>
|
||||
Add Sync Configuration
|
||||
</button>
|
||||
<button onClick={() => setState(prev => ({ ...prev, show_about_dialog: true }))}>
|
||||
About
|
||||
</button>
|
||||
</div>
|
||||
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
|
||||
|
||||
<form
|
||||
className="row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
greet();
|
||||
}}
|
||||
>
|
||||
{state.current_screen === "Main" ? (
|
||||
<div className="main-screen">
|
||||
<h1>General Bots</h1>
|
||||
<p>{state.status_text}</p>
|
||||
<button onClick={startSync}>Start Sync</button>
|
||||
<button onClick={stopSync}>Stop Sync</button>
|
||||
<button onClick={() => setState(prev => ({ ...prev, current_screen: "Status" }))}>
|
||||
Show Status
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="status-screen">
|
||||
<h1>Sync Status</h1>
|
||||
<div className="status-list">
|
||||
{state.sync_statuses.map((status, index) => (
|
||||
<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
|
||||
id="greet-input"
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
placeholder="Enter a name..."
|
||||
value={state.name}
|
||||
onChange={(e) => setState(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Enter sync name"
|
||||
/>
|
||||
<button type="submit">Greet</button>
|
||||
</form>
|
||||
<p>{greetMsg}</p>
|
||||
</main>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue