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>
This commit is contained in:
Stefan Hacker
2026-04-11 23:26:57 +02:00
parent 748537b9f5
commit 06ad65dbb3
39 changed files with 8735 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
<script setup>
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
const screen = ref("login"); // login | main
const serverUrl = ref("https://");
const username = ref("");
const password = ref("");
const loginError = ref("");
const loginLoading = ref(false);
const syncDir = ref("");
const syncLog = ref([]);
const syncing = ref(false);
const userInfo = ref(null);
const fileTree = ref([]);
const statusMsg = ref("Nicht verbunden");
async function handleLogin() {
loginError.value = "";
loginLoading.value = true;
try {
const result = await invoke("login", {
serverUrl: serverUrl.value,
username: username.value,
password: password.value,
});
userInfo.value = result;
screen.value = "main";
statusMsg.value = `Verbunden als ${result.username}`;
// Set default sync dir
syncDir.value = `${await getDefaultSyncDir()}/MiniCloud`;
await loadFileTree();
} catch (err) {
loginError.value = String(err);
} finally {
loginLoading.value = false;
}
}
async function getDefaultSyncDir() {
// Use home directory
try {
const home = await invoke("get_status");
return home.sync_dir || (navigator.platform.includes("Win") ? "C:\\Users" : "/home");
} catch {
return "/home";
}
}
async function setSyncFolder() {
try {
await invoke("set_sync_dir", { path: syncDir.value });
statusMsg.value = `Sync-Ordner: ${syncDir.value}`;
} catch (err) {
statusMsg.value = `Fehler: ${err}`;
}
}
async function runSync() {
syncing.value = true;
statusMsg.value = "Synchronisiere...";
try {
await invoke("set_sync_dir", { path: syncDir.value });
const log = await invoke("start_sync");
syncLog.value = log;
statusMsg.value = `Sync fertig: ${log.length} Aktionen`;
await loadFileTree();
} catch (err) {
statusMsg.value = `Sync-Fehler: ${err}`;
} finally {
syncing.value = false;
}
}
async function loadFileTree() {
try {
fileTree.value = await invoke("get_file_tree");
} catch (err) {
console.error("Tree-Fehler:", err);
}
}
function formatSize(bytes) {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}
</script>
<template>
<!-- Login -->
<div v-if="screen === 'login'" class="login-screen">
<div class="login-card">
<div class="login-header">
<div class="logo-icon"></div>
<h1>Mini-Cloud</h1>
<p>Desktop Sync Client</p>
</div>
<form @submit.prevent="handleLogin">
<div class="field">
<label>Server-URL</label>
<input v-model="serverUrl" placeholder="https://cloud.example.com" />
</div>
<div class="field">
<label>Benutzername</label>
<input v-model="username" placeholder="Benutzername" autofocus />
</div>
<div class="field">
<label>Passwort</label>
<input v-model="password" type="password" placeholder="Passwort" />
</div>
<div v-if="loginError" class="error">{{ loginError }}</div>
<button type="submit" :disabled="loginLoading" class="btn-primary">
{{ loginLoading ? "Verbinde..." : "Anmelden" }}
</button>
</form>
</div>
</div>
<!-- Main -->
<div v-else class="main-screen">
<div class="toolbar">
<div class="toolbar-left">
<span class="logo-small"></span>
<strong>Mini-Cloud</strong>
<span class="status-badge">{{ statusMsg }}</span>
</div>
<div class="toolbar-right">
<span class="user-info">{{ userInfo?.username }}</span>
</div>
</div>
<div class="content">
<!-- Sync Settings -->
<div class="section">
<h3>Sync-Ordner</h3>
<div class="sync-row">
<input v-model="syncDir" class="sync-input" />
<button @click="runSync" :disabled="syncing" class="btn-primary">
{{ syncing ? "Synchronisiere..." : "Jetzt synchronisieren" }}
</button>
</div>
</div>
<!-- File Tree -->
<div class="section">
<h3>Server-Dateien</h3>
<div class="file-tree">
<div v-for="entry in fileTree" :key="entry.id" class="tree-item">
<span class="tree-icon">{{ entry.is_folder ? "📁" : "📄" }}</span>
<span class="tree-name">{{ entry.name }}</span>
<span v-if="entry.locked" class="tree-lock" :title="'Gesperrt von ' + entry.locked_by">
🔒 {{ entry.locked_by }}
</span>
<span v-if="!entry.is_folder" class="tree-size">{{ formatSize(entry.size) }}</span>
</div>
<div v-if="!fileTree.length" class="empty">Keine Dateien</div>
</div>
</div>
<!-- Sync Log -->
<div v-if="syncLog.length" class="section">
<h3>Sync-Protokoll</h3>
<div class="log-list">
<div v-for="(msg, i) in syncLog" :key="i" class="log-item">{{ msg }}</div>
</div>
</div>
</div>
</div>
</template>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px; color: #1a1a1a; background: #f0f2f5;
}
.login-screen {
height: 100vh; display: flex; align-items: center; justify-content: center;
}
.login-card {
background: white; border-radius: 12px; padding: 2rem; width: 360px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
}
.login-header { text-align: center; margin-bottom: 1.5rem; }
.logo-icon { font-size: 2.5rem; }
.login-header h1 { font-size: 1.3rem; margin: 0.5rem 0 0.25rem; }
.login-header p { color: #666; font-size: 0.85rem; }
.field { margin-bottom: 0.75rem; }
.field label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.85rem; }
.field input {
width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 6px;
font-size: 0.9rem; background: #fafafa;
}
.field input:focus { border-color: #4a90d9; outline: none; background: white; }
.error { color: #e53e3e; font-size: 0.85rem; margin-bottom: 0.75rem; }
.btn-primary {
width: 100%; padding: 0.6rem; background: #4a90d9; color: white;
border: none; border-radius: 6px; font-size: 0.9rem; cursor: pointer; font-weight: 500;
}
.btn-primary:hover { background: #3a7bc8; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.main-screen { height: 100vh; display: flex; flex-direction: column; }
.toolbar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.5rem 1rem; background: white; border-bottom: 1px solid #e0e0e0;
}
.toolbar-left { display: flex; align-items: center; gap: 0.5rem; }
.logo-small { font-size: 1.2rem; }
.status-badge { font-size: 0.8rem; color: #666; background: #f0f0f0; padding: 0.2rem 0.5rem; border-radius: 4px; }
.user-info { font-size: 0.85rem; color: #666; }
.content { flex: 1; overflow-y: auto; padding: 1rem; }
.section { background: white; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.section h3 { margin-bottom: 0.75rem; font-size: 0.95rem; }
.sync-row { display: flex; gap: 0.5rem; }
.sync-input { flex: 1; padding: 0.5rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.85rem; }
.file-tree { max-height: 300px; overflow-y: auto; }
.tree-item {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.375rem 0; border-bottom: 1px solid #f5f5f5; font-size: 0.85rem;
}
.tree-icon { flex-shrink: 0; }
.tree-name { flex: 1; }
.tree-lock { font-size: 0.75rem; color: #e67e22; }
.tree-size { font-size: 0.75rem; color: #999; }
.empty { text-align: center; color: #999; padding: 1rem; }
.log-list { max-height: 200px; overflow-y: auto; font-family: monospace; font-size: 0.8rem; }
.log-item { padding: 0.25rem 0; border-bottom: 1px solid #f5f5f5; }
@media (prefers-color-scheme: dark) {
body { color: #e0e0e0; background: #1a1a1a; }
.login-card, .section { background: #2a2a2a; }
.toolbar { background: #2a2a2a; border-color: #3a3a3a; }
.field input, .sync-input { background: #333; border-color: #444; color: #e0e0e0; }
.status-badge { background: #3a3a3a; color: #aaa; }
.tree-item { border-color: #333; }
}
</style>