feat: Desktop Sync Client (Tauri) - Grundgeruest
Tauri 2 Desktop-Client mit: Rust-Backend: - MiniCloudApi: Login, Token-Refresh, Upload, Download, Sync-Tree, Sync-Changes, File-Locking (Lock/Unlock/Heartbeat) - SyncEngine: Full-Sync (Server-Tree vs. lokales Dateisystem), Delta-Sync (nur Aenderungen seit letztem Sync), bidirektionaler Abgleich mit SHA-256 Checksummen, Ordner-Erstellung, Lock-Status-Pruefung vor Upload, Konflikt-Erkennung - FileWatcher: Filesystem-Watcher (notify crate) fuer Echtzeit- Erkennung lokaler Aenderungen, filtert temp/hidden files Vue-Frontend: - Login-Screen: Server-URL, Benutzername, Passwort - Main-Screen: Sync-Ordner setzen, Sync starten, Dateiliste mit Lock-Status, Sync-Protokoll - Dark-Mode Support Tauri-Kommandos: login, set_sync_dir, start_sync, delta_sync, get_status, get_file_tree Zum Bauen (Linux): sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev cd clients/desktop && npm install && npm run tauri build Windows/Mac: Tauri Voraussetzungen installieren, dann gleicher Befehl Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "minicloud-sync"
|
||||
version = "0.1.0"
|
||||
description = "Mini-Cloud Desktop Sync Client"
|
||||
authors = ["Mini-Cloud"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "minicloud_sync_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"], default-features = false }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
notify = "7"
|
||||
sha2 = "0.10"
|
||||
dirs = "6"
|
||||
rusqlite = { version = "0.34", features = ["bundled"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
keyring = "3"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,108 @@
|
||||
mod sync;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::State;
|
||||
|
||||
use sync::api::MiniCloudApi;
|
||||
use sync::engine::SyncEngine;
|
||||
|
||||
struct AppState {
|
||||
api: Mutex<Option<MiniCloudApi>>,
|
||||
sync_engine: Mutex<Option<SyncEngine>>,
|
||||
sync_dir: Mutex<Option<PathBuf>>,
|
||||
username: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn login(
|
||||
state: State<'_, AppState>,
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut api = MiniCloudApi::new(&server_url);
|
||||
let result = api.login(&username, &password).await?;
|
||||
|
||||
*state.api.lock().unwrap() = Some(api);
|
||||
*state.username.lock().unwrap() = Some(username);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"username": result.user.username,
|
||||
"role": result.user.role,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_sync_dir(state: State<'_, AppState>, path: String) -> Result<String, String> {
|
||||
let sync_path = PathBuf::from(&path);
|
||||
if !sync_path.exists() {
|
||||
std::fs::create_dir_all(&sync_path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
*state.sync_dir.lock().unwrap() = Some(sync_path);
|
||||
Ok(format!("Sync-Ordner gesetzt: {}", path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
||||
let api = state.api.lock().unwrap().clone()
|
||||
.ok_or("Nicht eingeloggt")?;
|
||||
let sync_dir = state.sync_dir.lock().unwrap().clone()
|
||||
.ok_or("Kein Sync-Ordner gesetzt")?;
|
||||
|
||||
let mut engine = SyncEngine::new(sync_dir, api);
|
||||
let log = engine.full_sync().await?;
|
||||
|
||||
*state.sync_engine.lock().unwrap() = Some(engine);
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delta_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> {
|
||||
let mut engine_guard = state.sync_engine.lock().unwrap();
|
||||
let engine = engine_guard.as_mut().ok_or("Sync nicht gestartet")?;
|
||||
engine.delta_sync().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_status(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
let logged_in = state.api.lock().unwrap().is_some();
|
||||
let sync_dir = state.sync_dir.lock().unwrap().clone();
|
||||
let username = state.username.lock().unwrap().clone();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"logged_in": logged_in,
|
||||
"username": username,
|
||||
"sync_dir": sync_dir.map(|p| p.to_string_lossy().to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_file_tree(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
let api = state.api.lock().unwrap().clone()
|
||||
.ok_or("Nicht eingeloggt")?;
|
||||
let tree = api.get_sync_tree().await?;
|
||||
Ok(serde_json::to_value(tree).map_err(|e| e.to_string())?)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(AppState {
|
||||
api: Mutex::new(None),
|
||||
sync_engine: Mutex::new(None),
|
||||
sync_dir: Mutex::new(None),
|
||||
username: Mutex::new(None),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
login,
|
||||
set_sync_dir,
|
||||
start_sync,
|
||||
delta_sync,
|
||||
get_status,
|
||||
get_file_tree,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tauri_app_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MiniCloudApi {
|
||||
client: Client,
|
||||
pub server_url: String,
|
||||
pub access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct LoginResponse {
|
||||
pub access_token: String,
|
||||
pub user: UserInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UserInfo {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FileEntry {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub is_folder: bool,
|
||||
pub size: Option<i64>,
|
||||
pub checksum: Option<String>,
|
||||
pub updated_at: Option<String>,
|
||||
pub children: Option<Vec<FileEntry>>,
|
||||
pub locked: Option<bool>,
|
||||
pub locked_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SyncTreeResponse {
|
||||
pub tree: Vec<FileEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SyncChangesResponse {
|
||||
pub changes: Vec<FileEntry>,
|
||||
pub server_time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct LockResponse {
|
||||
pub locked: Option<bool>,
|
||||
pub locked_by: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl MiniCloudApi {
|
||||
pub fn new(server_url: &str) -> Self {
|
||||
Self {
|
||||
client: Client::builder()
|
||||
.danger_accept_invalid_certs(false)
|
||||
.build()
|
||||
.unwrap(),
|
||||
server_url: server_url.trim_end_matches('/').to_string(),
|
||||
access_token: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_header(&self) -> String {
|
||||
format!("Bearer {}", self.access_token)
|
||||
}
|
||||
|
||||
pub async fn login(&mut self, username: &str, password: &str) -> Result<LoginResponse, String> {
|
||||
let url = format!("{}/api/auth/login", self.server_url);
|
||||
let body = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
});
|
||||
|
||||
let resp = self.client.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Verbindungsfehler: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Login fehlgeschlagen: {}", text));
|
||||
}
|
||||
|
||||
let data: LoginResponse = resp.json().await
|
||||
.map_err(|e| format!("Antwort-Fehler: {}", e))?;
|
||||
self.access_token = data.access_token.clone();
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn refresh_token(&mut self) -> Result<String, String> {
|
||||
let url = format!("{}/api/auth/refresh", self.server_url);
|
||||
let resp = self.client.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Refresh fehlgeschlagen: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err("Token-Refresh fehlgeschlagen".to_string());
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
if let Some(token) = data.get("access_token").and_then(|t| t.as_str()) {
|
||||
self.access_token = token.to_string();
|
||||
Ok(token.to_string())
|
||||
} else {
|
||||
Err("Kein Token in Antwort".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_sync_tree(&self) -> Result<Vec<FileEntry>, String> {
|
||||
let url = format!("{}/api/sync/tree", self.server_url);
|
||||
let resp = self.client.get(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Sync-Tree Fehler: {}", e))?;
|
||||
|
||||
let data: SyncTreeResponse = resp.json().await
|
||||
.map_err(|e| format!("Parse-Fehler: {}", e))?;
|
||||
Ok(data.tree)
|
||||
}
|
||||
|
||||
pub async fn get_changes(&self, since: &str) -> Result<SyncChangesResponse, String> {
|
||||
let url = format!("{}/api/sync/changes?since={}", self.server_url, since);
|
||||
let resp = self.client.get(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Changes Fehler: {}", e))?;
|
||||
|
||||
resp.json().await.map_err(|e| format!("Parse-Fehler: {}", e))
|
||||
}
|
||||
|
||||
pub async fn download_file(&self, file_id: i64, dest: &Path) -> Result<(), String> {
|
||||
let url = format!("{}/api/files/{}/download?token={}",
|
||||
self.server_url, file_id, self.access_token);
|
||||
let resp = self.client.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Download Fehler: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Download fehlgeschlagen: {}", resp.status()));
|
||||
}
|
||||
|
||||
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(dest, &bytes).map_err(|e| format!("Schreiben fehlgeschlagen: {}", e))
|
||||
}
|
||||
|
||||
pub async fn upload_file(&self, file_path: &Path, parent_id: Option<i64>) -> Result<FileEntry, String> {
|
||||
let url = format!("{}/api/files/upload", self.server_url);
|
||||
let file_name = file_path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("file")
|
||||
.to_string();
|
||||
|
||||
let file_bytes = std::fs::read(file_path)
|
||||
.map_err(|e| format!("Datei lesen fehlgeschlagen: {}", e))?;
|
||||
|
||||
let mut form = reqwest::multipart::Form::new()
|
||||
.part("file", reqwest::multipart::Part::bytes(file_bytes).file_name(file_name));
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
form = form.text("parent_id", pid.to_string());
|
||||
}
|
||||
|
||||
let resp = self.client.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Upload Fehler: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Upload fehlgeschlagen: {}", text));
|
||||
}
|
||||
|
||||
resp.json().await.map_err(|e| format!("Parse-Fehler: {}", e))
|
||||
}
|
||||
|
||||
pub async fn create_folder(&self, name: &str, parent_id: Option<i64>) -> Result<FileEntry, String> {
|
||||
let url = format!("{}/api/files/folder", self.server_url);
|
||||
let body = serde_json::json!({
|
||||
"name": name,
|
||||
"parent_id": parent_id,
|
||||
});
|
||||
|
||||
let resp = self.client.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
resp.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub async fn lock_file(&self, file_id: i64, client_info: &str) -> Result<(), String> {
|
||||
let url = format!("{}/api/files/{}/lock", self.server_url, file_id);
|
||||
let body = serde_json::json!({ "client_info": client_info });
|
||||
|
||||
let resp = self.client.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if resp.status().as_u16() == 423 {
|
||||
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
|
||||
let by = data.get("locked_by").and_then(|v| v.as_str()).unwrap_or("?");
|
||||
return Err(format!("Datei gesperrt von {}", by));
|
||||
}
|
||||
if !resp.status().is_success() {
|
||||
return Err("Lock fehlgeschlagen".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn unlock_file(&self, file_id: i64) -> Result<(), String> {
|
||||
let url = format!("{}/api/files/{}/unlock", self.server_url, file_id);
|
||||
self.client.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn heartbeat(&self, file_id: i64) -> Result<(), String> {
|
||||
let url = format!("{}/api/files/{}/heartbeat", self.server_url, file_id);
|
||||
self.client.post(&url)
|
||||
.header("Authorization", self.auth_header())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use crate::sync::api::{FileEntry, MiniCloudApi};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Compare local files with server tree and determine actions
|
||||
pub struct SyncEngine {
|
||||
pub sync_dir: PathBuf,
|
||||
pub api: MiniCloudApi,
|
||||
last_sync: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncAction {
|
||||
Download { id: i64, name: String, server_path: String },
|
||||
Upload { local_path: PathBuf, parent_id: Option<i64> },
|
||||
CreateFolder { name: String, parent_id: Option<i64> },
|
||||
Delete { local_path: PathBuf },
|
||||
Conflict { local_path: PathBuf, server_id: i64, locked_by: String },
|
||||
}
|
||||
|
||||
impl SyncEngine {
|
||||
pub fn new(sync_dir: PathBuf, api: MiniCloudApi) -> Self {
|
||||
Self { sync_dir, api, last_sync: None }
|
||||
}
|
||||
|
||||
/// Full sync: compare server tree with local filesystem
|
||||
pub async fn full_sync(&mut self) -> Result<Vec<String>, String> {
|
||||
let server_tree = self.api.get_sync_tree().await?;
|
||||
let mut log = Vec::new();
|
||||
|
||||
// Download missing/changed files from server
|
||||
self.sync_download_recursive(&server_tree, &self.sync_dir.clone(), &mut log).await;
|
||||
|
||||
// Upload local files not on server
|
||||
self.sync_upload_recursive(&server_tree, &self.sync_dir.clone(), None, &mut log).await;
|
||||
|
||||
// Update last sync time
|
||||
self.last_sync = Some(chrono::Utc::now().to_rfc3339());
|
||||
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
/// Delta sync: only changes since last sync
|
||||
pub async fn delta_sync(&mut self) -> Result<Vec<String>, String> {
|
||||
let since = self.last_sync.clone().unwrap_or_else(|| "2000-01-01T00:00:00Z".to_string());
|
||||
let changes = self.api.get_changes(&since).await?;
|
||||
let mut log = Vec::new();
|
||||
|
||||
for entry in &changes.changes {
|
||||
let rel_path = self.sync_dir.join(&entry.name);
|
||||
|
||||
if entry.is_folder {
|
||||
if !rel_path.exists() {
|
||||
std::fs::create_dir_all(&rel_path).ok();
|
||||
log.push(format!("Ordner erstellt: {}", entry.name));
|
||||
}
|
||||
} else {
|
||||
// Check if locked
|
||||
if entry.locked.unwrap_or(false) {
|
||||
log.push(format!("Uebersprungen (gesperrt von {}): {}",
|
||||
entry.locked_by.as_deref().unwrap_or("?"), entry.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Download if checksum differs
|
||||
let needs_download = if rel_path.exists() {
|
||||
let local_hash = compute_file_hash(&rel_path);
|
||||
local_hash != entry.checksum.as_deref().unwrap_or("")
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if needs_download {
|
||||
match self.api.download_file(entry.id, &rel_path).await {
|
||||
Ok(_) => log.push(format!("Heruntergeladen: {}", entry.name)),
|
||||
Err(e) => log.push(format!("Fehler {}: {}", entry.name, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.last_sync = Some(changes.server_time);
|
||||
Ok(log)
|
||||
}
|
||||
|
||||
async fn sync_download_recursive(&self, entries: &[FileEntry], local_dir: &Path, log: &mut Vec<String>) {
|
||||
for entry in entries {
|
||||
let local_path = local_dir.join(&entry.name);
|
||||
|
||||
if entry.is_folder {
|
||||
std::fs::create_dir_all(&local_path).ok();
|
||||
if let Some(children) = &entry.children {
|
||||
Box::pin(self.sync_download_recursive(children, &local_path, log)).await;
|
||||
}
|
||||
} else {
|
||||
// Skip locked files
|
||||
if entry.locked.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let needs_download = if local_path.exists() {
|
||||
let local_hash = compute_file_hash(&local_path);
|
||||
local_hash != entry.checksum.as_deref().unwrap_or("")
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if needs_download {
|
||||
match self.api.download_file(entry.id, &local_path).await {
|
||||
Ok(_) => log.push(format!("Heruntergeladen: {}", entry.name)),
|
||||
Err(e) => log.push(format!("Fehler {}: {}", entry.name, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_upload_recursive(&self, server_entries: &[FileEntry], local_dir: &Path,
|
||||
parent_id: Option<i64>, log: &mut Vec<String>) {
|
||||
let server_names: HashMap<String, &FileEntry> = server_entries.iter()
|
||||
.map(|e| (e.name.clone(), e))
|
||||
.collect();
|
||||
|
||||
let entries = match std::fs::read_dir(local_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let path = entry.path();
|
||||
|
||||
// Skip hidden files and sync metadata
|
||||
if name.starts_with('.') || name == ".minicloud_sync" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
if let Some(server_entry) = server_names.get(&name) {
|
||||
// Folder exists on server, recurse
|
||||
if let Some(children) = &server_entry.children {
|
||||
Box::pin(self.sync_upload_recursive(
|
||||
children, &path, Some(server_entry.id), log
|
||||
)).await;
|
||||
}
|
||||
} else {
|
||||
// Folder doesn't exist on server, create + upload contents
|
||||
match self.api.create_folder(&name, parent_id).await {
|
||||
Ok(folder) => {
|
||||
log.push(format!("Ordner erstellt: {}", name));
|
||||
Box::pin(self.sync_upload_recursive(
|
||||
&[], &path, Some(folder.id), log
|
||||
)).await;
|
||||
}
|
||||
Err(e) => log.push(format!("Ordner-Fehler {}: {}", name, e)),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// File
|
||||
let needs_upload = if let Some(server_entry) = server_names.get(&name) {
|
||||
// Check if local version is newer
|
||||
let local_hash = compute_file_hash(&path);
|
||||
local_hash != server_entry.checksum.as_deref().unwrap_or("")
|
||||
} else {
|
||||
true // New file
|
||||
};
|
||||
|
||||
if needs_upload {
|
||||
// Check if file is locked on server
|
||||
if let Some(server_entry) = server_names.get(&name) {
|
||||
if server_entry.locked.unwrap_or(false) {
|
||||
log.push(format!("Zurueckgehalten (gesperrt): {}", name));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match self.api.upload_file(&path, parent_id).await {
|
||||
Ok(_) => log.push(format!("Hochgeladen: {}", name)),
|
||||
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_file_hash(path: &Path) -> String {
|
||||
let data = match std::fs::read(path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&data);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod api;
|
||||
pub mod engine;
|
||||
pub mod watcher;
|
||||
@@ -0,0 +1,58 @@
|
||||
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub struct FileWatcher {
|
||||
_watcher: RecommendedWatcher,
|
||||
pub receiver: mpsc::Receiver<FileChange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileChange {
|
||||
pub path: PathBuf,
|
||||
pub kind: ChangeKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChangeKind {
|
||||
Created,
|
||||
Modified,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
impl FileWatcher {
|
||||
pub fn new(watch_dir: &PathBuf) -> Result<Self, String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |result: Result<Event, notify::Error>| {
|
||||
if let Ok(event) = result {
|
||||
let kind = match event.kind {
|
||||
EventKind::Create(_) => Some(ChangeKind::Created),
|
||||
EventKind::Modify(_) => Some(ChangeKind::Modified),
|
||||
EventKind::Remove(_) => Some(ChangeKind::Deleted),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kind) = kind {
|
||||
for path in event.paths {
|
||||
// Skip hidden files and temp files
|
||||
let name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
if name.starts_with('.') || name.starts_with('~') || name.ends_with(".tmp") {
|
||||
continue;
|
||||
}
|
||||
let _ = tx.send(FileChange { path, kind: kind.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Config::default(),
|
||||
).map_err(|e| format!("Watcher-Fehler: {}", e))?;
|
||||
|
||||
watcher.watch(watch_dir.as_ref(), RecursiveMode::Recursive)
|
||||
.map_err(|e| format!("Watch-Fehler: {}", e))?;
|
||||
|
||||
Ok(Self { _watcher: watcher, receiver: rx })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "MiniCloud Sync",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.minicloud.sync",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Mini-Cloud Sync",
|
||||
"width": 700,
|
||||
"height": 550,
|
||||
"resizable": true,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||