444 lines
No EOL
15 KiB
Rust
444 lines
No EOL
15 KiB
Rust
use dioxus::prelude::*;
|
|
use dioxus_desktop::{use_window, LogicalSize};
|
|
use std::env;
|
|
use std::fs::{File, OpenOptions, create_dir_all};
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::path::Path;
|
|
use std::process::{Command as ProcCommand, Child, Stdio};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread;
|
|
use std::time::{Duration, Instant};
|
|
use notify_rust::Notification;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
// App state
|
|
#[derive(Debug, Clone)]
|
|
struct AppState {
|
|
name: String,
|
|
access_key: String,
|
|
secret_key: String,
|
|
status_text: String,
|
|
sync_processes: Arc<Mutex<Vec<Child>>>,
|
|
sync_active: Arc<Mutex<bool>>,
|
|
sync_statuses: Arc<Mutex<Vec<SyncStatus>>>,
|
|
show_config_dialog: bool,
|
|
show_about_dialog: bool,
|
|
current_screen: Screen,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Screen {
|
|
Main,
|
|
Status,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum Message {
|
|
NameChanged(String),
|
|
AccessKeyChanged(String),
|
|
SecretKeyChanged(String),
|
|
SaveConfig,
|
|
StartSync,
|
|
StopSync,
|
|
UpdateStatus(Vec<SyncStatus>),
|
|
ShowConfigDialog(bool),
|
|
ShowAboutDialog(bool),
|
|
ShowStatusScreen,
|
|
BackToMain,
|
|
None,
|
|
}
|
|
|
|
fn main() {
|
|
dioxus_desktop::launch(app);
|
|
}
|
|
|
|
fn app(cx: Scope) -> Element {
|
|
let window = use_window();
|
|
window.set_inner_size(LogicalSize::new(800, 600));
|
|
|
|
let state = use_ref(cx, || AppState {
|
|
name: String::new(),
|
|
access_key: String::new(),
|
|
secret_key: String::new(),
|
|
status_text: "Enter credentials to set up sync".to_string(),
|
|
sync_processes: Arc::new(Mutex::new(Vec::new())),
|
|
sync_active: Arc::new(Mutex::new(false)),
|
|
sync_statuses: Arc::new(Mutex::new(Vec::new())),
|
|
show_config_dialog: false,
|
|
show_about_dialog: false,
|
|
current_screen: Screen::Main,
|
|
});
|
|
|
|
// Monitor sync status
|
|
use_future( async move {
|
|
let state = state.clone();
|
|
async move {
|
|
let mut last_check = Instant::now();
|
|
let check_interval = Duration::from_secs(5);
|
|
|
|
loop {
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
|
|
if !*state.read().sync_active.lock().unwrap() {
|
|
continue;
|
|
}
|
|
|
|
if last_check.elapsed() < check_interval {
|
|
continue;
|
|
}
|
|
|
|
last_check = Instant::now();
|
|
|
|
match read_rclone_configs() {
|
|
Ok(configs) => {
|
|
let mut new_statuses = Vec::new();
|
|
for config in configs {
|
|
match get_rclone_status(&config.name) {
|
|
Ok(status) => new_statuses.push(status),
|
|
Err(e) => eprintln!("Failed to get status: {}", e),
|
|
}
|
|
}
|
|
*state.write().sync_statuses.lock().unwrap() = new_statuses.clone();
|
|
state.write().status_text = format!("Syncing {} repositories...", new_statuses.len());
|
|
}
|
|
Err(e) => eprintln!("Failed to read configs: {}", e),
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
cx.render(rsx! {
|
|
div {
|
|
class: "app",
|
|
// Main menu bar
|
|
div {
|
|
class: "menu-bar",
|
|
button {
|
|
onclick: move |_| state.write().show_config_dialog = true,
|
|
"Add Sync Configuration"
|
|
}
|
|
button {
|
|
onclick: move |_| state.write().show_about_dialog = true,
|
|
"About"
|
|
}
|
|
}
|
|
|
|
// Main content
|
|
{match state.read().current_screen {
|
|
Screen::Main => rsx! {
|
|
div {
|
|
class: "main-screen",
|
|
h1 { "General Bots" }
|
|
p { "{state.read().status_text}" }
|
|
button {
|
|
onclick: move |_| start_sync(&state),
|
|
"Start Sync"
|
|
}
|
|
button {
|
|
onclick: move |_| stop_sync(&state),
|
|
"Stop Sync"
|
|
}
|
|
button {
|
|
onclick: move |_| state.write().current_screen = Screen::Status,
|
|
"Show Status"
|
|
}
|
|
}
|
|
},
|
|
Screen::Status => rsx! {
|
|
div {
|
|
class: "status-screen",
|
|
h1 { "Sync Status" }
|
|
div {
|
|
class: "status-list",
|
|
for status in state.read().sync_statuses.lock().unwrap().iter() {
|
|
div {
|
|
class: "status-item",
|
|
h2 { "{status.name}" }
|
|
p { "Status: {status.status}" }
|
|
p { "Transferred: {status.transferred}" }
|
|
p { "Bytes: {status.bytes}" }
|
|
p { "Errors: {status.errors}" }
|
|
p { "Last Updated: {status.last_updated}" }
|
|
}
|
|
}
|
|
}
|
|
button {
|
|
onclick: move |_| state.write().current_screen = Screen::Main,
|
|
"Back"
|
|
}
|
|
}
|
|
}
|
|
}}
|
|
|
|
// Config dialog
|
|
if state.read().show_config_dialog {
|
|
div {
|
|
class: "dialog",
|
|
h2 { "Add Sync Configuration" }
|
|
input {
|
|
value: "{state.read().name}",
|
|
oninput: move |e| state.write().name = e.value.clone(),
|
|
placeholder: "Enter sync name",
|
|
}
|
|
input {
|
|
value: "{state.read().access_key}",
|
|
oninput: move |e| state.write().access_key = e.value.clone(),
|
|
placeholder: "Enter access key",
|
|
}
|
|
input {
|
|
value: "{state.read().secret_key}",
|
|
oninput: move |e| state.write().secret_key = e.value.clone(),
|
|
placeholder: "Enter secret key",
|
|
}
|
|
button {
|
|
onclick: move |_| {
|
|
save_config(&state);
|
|
state.write().show_config_dialog = false;
|
|
},
|
|
"Save"
|
|
}
|
|
button {
|
|
onclick: move |_| state.write().show_config_dialog = false,
|
|
"Cancel"
|
|
}
|
|
}
|
|
}
|
|
|
|
// About dialog
|
|
if state.read().show_about_dialog {
|
|
div {
|
|
class: "dialog",
|
|
h2 { "About General Bots" }
|
|
p { "Version: 1.0.0" }
|
|
p { "A professional-grade sync tool for OneDrive/Dropbox-like functionality." }
|
|
button {
|
|
onclick: move |_| state.write().show_about_dialog = false,
|
|
"Close"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Save sync configuration
|
|
fn save_config(state: &UseRef<AppState>) {
|
|
if state.read().name.is_empty() || state.read().access_key.is_empty() || state.read().secret_key.is_empty() {
|
|
state.write_with(|state| state.status_text = "All fields are required!".to_string());
|
|
return;
|
|
}
|
|
|
|
let new_config = RcloneConfig {
|
|
name: state.read().name.clone(),
|
|
remote_path: format!("s3://{}", state.read().name),
|
|
local_path: Path::new(&env::var("HOME").unwrap()).join("General Bots").join(&state.read().name).to_string_lossy().to_string(),
|
|
access_key: state.read().access_key.clone(),
|
|
secret_key: state.read().secret_key.clone(),
|
|
};
|
|
|
|
if let Err(e) = save_rclone_config(&new_config) {
|
|
state.write_with(|state| state.status_text = format!("Failed to save config: {}", e));
|
|
} else {
|
|
state.write_with(|state| state.status_text = "New sync saved!".to_string());
|
|
}
|
|
}
|
|
|
|
// Start sync process
|
|
fn start_sync(state: &UseRef<AppState>) {
|
|
let mut processes = state.write_with(|state| state.sync_processes.lock().unwrap());
|
|
processes.clear();
|
|
|
|
match read_rclone_configs() {
|
|
Ok(configs) => {
|
|
for config in configs {
|
|
match run_sync(&config) {
|
|
Ok(child) => processes.push(child),
|
|
Err(e) => eprintln!("Failed to start sync: {}", e),
|
|
}
|
|
}
|
|
state.write_with(|state| *state.sync_active.lock().unwrap() = true);
|
|
state.write_with(|state| state.status_text = format!("Syncing with {} configurations.", processes.len()));
|
|
}
|
|
Err(e) => state.write_with(|state| state.status_text = format!("Failed to read configurations: {}", e)),
|
|
}
|
|
}
|
|
|
|
// Stop sync process
|
|
fn stop_sync(state: &UseRef<AppState>) {
|
|
let mut processes = state.write_with(|state| state.sync_processes.lock().unwrap());
|
|
for child in processes.iter_mut() {
|
|
let _ = child.kill();
|
|
}
|
|
processes.clear();
|
|
state.write_with(|state| *state.sync_active.lock().unwrap() = false);
|
|
state.write_with(|state| state.status_text = "Sync stopped.".to_string());
|
|
}
|
|
|
|
// Utility functions (rclone, notifications, etc.)
|
|
fn save_rclone_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))
|
|
}
|
|
|
|
fn read_rclone_configs() -> Result<Vec<RcloneConfig>, 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");
|
|
|
|
if !config_path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let file = File::open(&config_path).map_err(|e| format!("Failed to open config file: {}", e))?;
|
|
let reader = BufReader::new(file);
|
|
let mut configs = Vec::new();
|
|
let mut current_config: Option<RcloneConfig> = None;
|
|
|
|
for line in reader.lines() {
|
|
let line = line.map_err(|e| format!("Failed to read line: {}", e))?;
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
|
|
if line.starts_with('[') && line.ends_with(']') {
|
|
if let Some(config) = current_config.take() {
|
|
configs.push(config);
|
|
}
|
|
let name = line[1..line.len()-1].to_string();
|
|
current_config = Some(RcloneConfig {
|
|
name: name.clone(),
|
|
remote_path: format!("s3://{}", name),
|
|
local_path: Path::new(&home_dir).join("General Bots").join(&name).to_string_lossy().to_string(),
|
|
access_key: String::new(),
|
|
secret_key: String::new(),
|
|
});
|
|
} else if let Some(ref mut config) = current_config {
|
|
if let Some(pos) = line.find('=') {
|
|
let key = line[..pos].trim().to_string();
|
|
let value = line[pos+1..].trim().to_string();
|
|
match key.as_str() {
|
|
"access_key_id" => config.access_key = value,
|
|
"secret_access_key" => config.secret_key = value,
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(config) = current_config {
|
|
configs.push(config);
|
|
}
|
|
|
|
Ok(configs)
|
|
}
|
|
|
|
fn run_sync(config: &RcloneConfig) -> Result<Child, std::io::Error> {
|
|
let local_path = Path::new(&config.local_path);
|
|
if !local_path.exists() {
|
|
create_dir_all(local_path)?;
|
|
}
|
|
|
|
ProcCommand::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()
|
|
}
|
|
|
|
fn get_rclone_status(remote_name: &str) -> Result<SyncStatus, String> {
|
|
let output = ProcCommand::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 parsed: Result<Value, _> = serde_json::from_str(&json);
|
|
match parsed {
|
|
Ok(value) => {
|
|
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.to_string(),
|
|
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(),
|
|
})
|
|
}
|
|
Err(e) => Err(format!("Failed to parse rclone status: {}", e)),
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
} |