Initial release with Gitea updater
This commit is contained in:
commit
46c2474985
36 files changed
+3674
No files matched your search
@@ -0,0 +1,25 @@
|
||||
"""SyncVito – zuverlässige Ordner-Synchronisation für Windows."""
|
||||
|
||||
from syncvito.models import (
|
||||
AppConfig,
|
||||
AppSettings,
|
||||
ConflictMode,
|
||||
DeleteMode,
|
||||
SyncDirection,
|
||||
SyncTask,
|
||||
TaskRuntimeState,
|
||||
TriggerMode,
|
||||
)
|
||||
from syncvito.version import __version__
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"AppConfig",
|
||||
"AppSettings",
|
||||
"ConflictMode",
|
||||
"DeleteMode",
|
||||
"SyncDirection",
|
||||
"SyncTask",
|
||||
"TaskRuntimeState",
|
||||
"TriggerMode",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Ausführung mit ``python -m syncvito``."""
|
||||
|
||||
from syncvito.app import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Anwendungsstart und Prozesslebenszyklus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QLockFile, QStandardPaths
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
|
||||
|
||||
from syncvito.config_store import ConfigError, ConfigStore
|
||||
from syncvito.icons import app_icon
|
||||
from syncvito.main_window import MainWindow
|
||||
from syncvito.models import AppConfig
|
||||
from syncvito.theme import apply_theme
|
||||
|
||||
__all__ = ["main"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Startet SyncVito als einzelne Windows-Instanz."""
|
||||
application = QApplication(sys.argv)
|
||||
application.setApplicationName("SyncVito")
|
||||
application.setOrganizationName("SyncVito")
|
||||
application.setQuitOnLastWindowClosed(False)
|
||||
application.setWindowIcon(app_icon())
|
||||
|
||||
lock_path = Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.TempLocation)) / "syncvito.lock"
|
||||
lock = QLockFile(str(lock_path))
|
||||
lock.setStaleLockTime(0)
|
||||
if not lock.tryLock(100):
|
||||
QMessageBox.information(None, "SyncVito läuft bereits", "SyncVito ist bereits im Infobereich aktiv.")
|
||||
return 0
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
QMessageBox.critical(None, "Infobereich nicht verfügbar", "SyncVito benötigt den Windows-Infobereich.")
|
||||
return 1
|
||||
|
||||
store = ConfigStore()
|
||||
try:
|
||||
config = store.load()
|
||||
except ConfigError as exc:
|
||||
QMessageBox.warning(None, "Konfiguration zurückgesetzt", str(exc))
|
||||
config = AppConfig()
|
||||
apply_theme(application, config.settings)
|
||||
window = MainWindow(config, store)
|
||||
if "--minimized" not in sys.argv:
|
||||
window.show()
|
||||
exit_code = application.exec()
|
||||
window.coordinator.stop()
|
||||
lock.unlock()
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Verwaltung des benutzerspezifischen Windows-Autostarts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["set_autostart"]
|
||||
|
||||
|
||||
def set_autostart(enabled: bool) -> None:
|
||||
"""Setzt oder entfernt SyncVito unter HKCU\\...\\Run."""
|
||||
if os.name != "nt":
|
||||
return
|
||||
import winreg
|
||||
|
||||
key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as key:
|
||||
if enabled:
|
||||
executable = Path(sys.executable)
|
||||
if getattr(sys, "frozen", False):
|
||||
command = f'"{executable}" --minimized'
|
||||
else:
|
||||
entry = Path(__file__).resolve().parents[2] / "main.py"
|
||||
command = f'"{executable}" "{entry}" --minimized'
|
||||
winreg.SetValueEx(key, "SyncVito", 0, winreg.REG_SZ, command)
|
||||
else:
|
||||
with suppress(FileNotFoundError):
|
||||
winreg.DeleteValue(key, "SyncVito")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Atomare Speicherung der Anwendungskonfiguration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from syncvito.models import AppConfig
|
||||
|
||||
__all__ = ["ConfigError", "ConfigStore", "default_data_directory"]
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Fehler beim Laden oder Speichern der Konfiguration."""
|
||||
|
||||
|
||||
def default_data_directory() -> Path:
|
||||
"""Liefert das lokale, benutzerspezifische Datenverzeichnis."""
|
||||
root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
||||
return root / "SyncVito"
|
||||
|
||||
|
||||
class ConfigStore:
|
||||
"""Lädt und speichert JSON-Daten mit atomarem Dateiaustausch."""
|
||||
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = path or default_data_directory() / "config.json"
|
||||
|
||||
def load(self) -> AppConfig:
|
||||
"""Lädt die Konfiguration; beschädigte Dateien werden gesichert."""
|
||||
if not self.path.exists():
|
||||
return AppConfig()
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("Das JSON-Wurzelelement ist kein Objekt.")
|
||||
return AppConfig.from_dict(raw)
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
recovery_path = self.path.with_name(f"config.defekt-{timestamp}.json")
|
||||
try:
|
||||
self.path.replace(recovery_path)
|
||||
except OSError:
|
||||
recovery_path = self.path
|
||||
raise ConfigError(f"Die Konfiguration war nicht lesbar. Sicherung: {recovery_path}") from exc
|
||||
|
||||
def save(self, config: AppConfig) -> None:
|
||||
"""Speichert die Konfiguration atomar und UTF-8-kodiert."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.path.with_suffix(".json.tmp")
|
||||
try:
|
||||
temporary.write_text(
|
||||
json.dumps(config.to_dict(), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, self.path)
|
||||
except OSError as exc:
|
||||
with suppress(OSError):
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise ConfigError(f"Konfiguration konnte nicht gespeichert werden: {exc}") from exc
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Serielle Hintergrundausführung, Zeitplanung und Retry-Steuerung."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal
|
||||
|
||||
from syncvito.config_store import ConfigStore
|
||||
from syncvito.file_locks import is_file_locked
|
||||
from syncvito.models import AppConfig, SyncTask, TaskRuntimeState, TriggerMode
|
||||
from syncvito.realtime import RealtimeMonitor
|
||||
from syncvito.sync_engine import SyncEngine, SyncResult, UnavailablePathError
|
||||
|
||||
__all__ = ["SyncCoordinator", "derive_overall_status"]
|
||||
|
||||
|
||||
def derive_overall_status(
|
||||
states: list[TaskRuntimeState], globally_paused: bool, running: bool = False
|
||||
) -> tuple[str, str]:
|
||||
"""Leitet den Tray-Zustand deterministisch aus allen Aufgaben ab."""
|
||||
if TaskRuntimeState.ERROR in states:
|
||||
return "error", "Fehler"
|
||||
if TaskRuntimeState.WAITING_FOR_RELEASE in states:
|
||||
return "error", "Wartet auf Dateifreigabe"
|
||||
if globally_paused:
|
||||
return "paused", "Pausiert"
|
||||
if TaskRuntimeState.ACTIVE not in states:
|
||||
return "paused", "Inaktiv"
|
||||
return "active", "Synchronisiert" if running else "Bereit"
|
||||
|
||||
|
||||
class _WorkerSignals(QObject):
|
||||
progress = pyqtSignal(int, str)
|
||||
finished = pyqtSignal(str, object)
|
||||
failed = pyqtSignal(str, str, bool)
|
||||
|
||||
|
||||
class _SyncWorker(QRunnable):
|
||||
def __init__(self, task: SyncTask, engine: SyncEngine) -> None:
|
||||
super().__init__()
|
||||
self.task = task
|
||||
self.engine = engine
|
||||
self.signals = _WorkerSignals()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
result = self.engine.synchronize(self.task, self.signals.progress.emit)
|
||||
except UnavailablePathError as exc:
|
||||
self.signals.failed.emit(self.task.id, str(exc), True)
|
||||
except Exception as exc: # noqa: BLE001 - Worker-Grenze muss UI-Absturz verhindern.
|
||||
detail = f"{exc}\n{traceback.format_exc(limit=4)}"
|
||||
self.signals.failed.emit(self.task.id, detail, False)
|
||||
else:
|
||||
self.signals.finished.emit(self.task.id, result)
|
||||
|
||||
|
||||
class _RealtimeBridge(QObject):
|
||||
changed = pyqtSignal(str)
|
||||
|
||||
|
||||
class SyncCoordinator(QObject):
|
||||
"""Koordiniert genau einen sichtbaren Synchronisationslauf zur Zeit."""
|
||||
|
||||
progress_changed = pyqtSignal(int, str)
|
||||
log_created = pyqtSignal(str, str, str)
|
||||
overall_status_changed = pyqtSignal(str, str)
|
||||
task_state_changed = pyqtSignal(str, object)
|
||||
lock_changed = pyqtSignal(str)
|
||||
idle_changed = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, config: AppConfig, config_store: ConfigStore, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
self.config_store = config_store
|
||||
self.engine = SyncEngine()
|
||||
self.pool = QThreadPool.globalInstance()
|
||||
self.pool.setMaxThreadCount(max(2, self.pool.maxThreadCount()))
|
||||
self._queue: deque[str] = deque()
|
||||
self._running_task_id: str | None = None
|
||||
self._retry_tasks: dict[str, list[Path]] = {}
|
||||
self._lock_messages: dict[str, str] = {}
|
||||
self._task_states: dict[str, TaskRuntimeState] = {
|
||||
task.id: TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED for task in config.tasks
|
||||
}
|
||||
self._last_realtime_event: dict[str, datetime] = {}
|
||||
self._bridge = _RealtimeBridge()
|
||||
self._bridge.changed.connect(self._on_realtime_event)
|
||||
self.monitor = RealtimeMonitor(self._bridge.changed.emit)
|
||||
|
||||
self._schedule_timer = QTimer(self)
|
||||
self._schedule_timer.setInterval(30_000)
|
||||
self._schedule_timer.timeout.connect(self._check_intervals)
|
||||
self._schedule_timer.start()
|
||||
self._retry_timer = QTimer(self)
|
||||
self._retry_timer.setInterval(10_000)
|
||||
self._retry_timer.timeout.connect(self._check_retries)
|
||||
self._retry_timer.start()
|
||||
self.rebuild_monitors()
|
||||
|
||||
def task_state(self, task_id: str) -> TaskRuntimeState:
|
||||
"""Liefert den aktuellen Engine-Zustand einer Aufgabe."""
|
||||
return self._task_states.get(task_id, TaskRuntimeState.PAUSED)
|
||||
|
||||
def current_overall_status(self) -> tuple[str, str]:
|
||||
"""Liefert den aktuell abgeleiteten Tray-Zustand."""
|
||||
return derive_overall_status(list(self._task_states.values()), self.config.settings.paused, self.is_running)
|
||||
|
||||
def refresh_task_states(self) -> None:
|
||||
"""Gleicht Laufzeitzustände nach Änderungen an der Aufgabenliste ab."""
|
||||
known_ids = {task.id for task in self.config.tasks}
|
||||
self._task_states = {task_id: state for task_id, state in self._task_states.items() if task_id in known_ids}
|
||||
self._retry_tasks = {task_id: paths for task_id, paths in self._retry_tasks.items() if task_id in known_ids}
|
||||
self._lock_messages = {
|
||||
task_id: message for task_id, message in self._lock_messages.items() if task_id in known_ids
|
||||
}
|
||||
for task in self.config.tasks:
|
||||
desired = TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED
|
||||
current = self._task_states.get(task.id)
|
||||
if current is None or current is TaskRuntimeState.PAUSED or not task.enabled:
|
||||
self._set_task_state(task.id, desired)
|
||||
if not task.enabled:
|
||||
self._retry_tasks.pop(task.id, None)
|
||||
self._lock_messages.pop(task.id, None)
|
||||
enabled_ids = {task.id for task in self.config.tasks if task.enabled}
|
||||
self._queue = deque(task_id for task_id in self._queue if task_id in enabled_ids)
|
||||
self._emit_overall_status()
|
||||
self._emit_lock_summary()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Zeigt an, ob momentan ein Worker aktiv ist."""
|
||||
return self._running_task_id is not None
|
||||
|
||||
def enqueue(self, task_id: str) -> None:
|
||||
"""Stellt eine Aufgabe dedupliziert in die serielle Warteschlange."""
|
||||
if self.config.settings.paused:
|
||||
self.log_created.emit("Synchronisation", "Hinweis", "SyncVito ist pausiert.")
|
||||
return
|
||||
task = self._task(task_id)
|
||||
if task is None or not task.enabled:
|
||||
return
|
||||
if task_id != self._running_task_id and task_id not in self._queue:
|
||||
self._queue.append(task_id)
|
||||
self._start_next()
|
||||
|
||||
def enqueue_all(self) -> None:
|
||||
"""Stellt alle aktiven Aufgaben zur manuellen Ausführung an."""
|
||||
for task in self.config.tasks:
|
||||
if task.enabled:
|
||||
self.enqueue(task.id)
|
||||
|
||||
def set_paused(self, paused: bool) -> None:
|
||||
"""Ändert den globalen Pausenzustand."""
|
||||
self.config.settings.paused = paused
|
||||
self.config_store.save(self.config)
|
||||
if paused:
|
||||
self.log_created.emit("Status", "Hinweis", "Alle automatischen Auslöser sind pausiert.")
|
||||
else:
|
||||
self.log_created.emit("Status", "Erfolg", "Synchronisation fortgesetzt.")
|
||||
self._start_next()
|
||||
self._emit_overall_status()
|
||||
|
||||
def rebuild_monitors(self) -> None:
|
||||
"""Aktualisiert alle Dateisystemüberwachungen."""
|
||||
errors = self.monitor.rebuild(self.config.tasks)
|
||||
for task_id, message in errors:
|
||||
self._retry_tasks[task_id] = []
|
||||
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||
self.log_created.emit("Echtzeit", "Fehler", message)
|
||||
self._emit_overall_status()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stoppt die Dateisystemüberwachung beim Beenden."""
|
||||
self.monitor.stop()
|
||||
|
||||
def _start_next(self) -> None:
|
||||
if self._running_task_id is not None or self.config.settings.paused or not self._queue:
|
||||
return
|
||||
task_id = self._queue.popleft()
|
||||
task = self._task(task_id)
|
||||
if task is None or not task.enabled:
|
||||
self._start_next()
|
||||
return
|
||||
self._running_task_id = task_id
|
||||
self.idle_changed.emit(False)
|
||||
self._emit_overall_status()
|
||||
self.log_created.emit(task.name, "Start", "Synchronisation gestartet.")
|
||||
worker = _SyncWorker(task, self.engine)
|
||||
worker.signals.progress.connect(self.progress_changed)
|
||||
worker.signals.finished.connect(self._on_finished)
|
||||
worker.signals.failed.connect(self._on_failed)
|
||||
self.pool.start(worker)
|
||||
|
||||
def _on_finished(self, task_id: str, result: SyncResult) -> None:
|
||||
task = self._task(task_id)
|
||||
if task is None:
|
||||
self._complete_current()
|
||||
return
|
||||
task.last_run = datetime.now().isoformat(timespec="seconds")
|
||||
self.config_store.save(self.config)
|
||||
if result.blocked:
|
||||
paths = [blocked.path for blocked in result.blocked]
|
||||
self._retry_tasks[task_id] = paths
|
||||
descriptions: list[str] = []
|
||||
for blocked in result.blocked:
|
||||
names = ", ".join(f"{process.name} (PID {process.pid})" for process in blocked.processes)
|
||||
descriptions.append(f"{blocked.path.name}: {names or 'sperrender Prozess nicht ermittelbar'}")
|
||||
message = "Zurückgestellt – " + "; ".join(descriptions)
|
||||
self._lock_messages[task_id] = message
|
||||
self._emit_lock_summary()
|
||||
self.log_created.emit(task.name, "Blockiert", message)
|
||||
self._set_task_state(task_id, TaskRuntimeState.WAITING_FOR_RELEASE)
|
||||
elif result.errors:
|
||||
self._retry_tasks[task_id] = []
|
||||
self._lock_messages.pop(task_id, None)
|
||||
self._emit_lock_summary()
|
||||
self.log_created.emit(task.name, "Fehler", " | ".join(result.errors[:5]))
|
||||
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||
else:
|
||||
self._retry_tasks.pop(task_id, None)
|
||||
self._lock_messages.pop(task_id, None)
|
||||
self._emit_lock_summary()
|
||||
summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien"
|
||||
self.log_created.emit(task.name, "Erfolg", summary)
|
||||
self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
|
||||
self._complete_current()
|
||||
|
||||
def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None:
|
||||
task = self._task(task_id)
|
||||
name = task.name if task else "Aufgabe"
|
||||
self._retry_tasks[task_id] = []
|
||||
self._lock_messages.pop(task_id, None)
|
||||
self._emit_lock_summary()
|
||||
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||
self.log_created.emit(name, "Fehler", detail)
|
||||
self._complete_current()
|
||||
|
||||
def _complete_current(self) -> None:
|
||||
self._running_task_id = None
|
||||
self.progress_changed.emit(100, "Abgeschlossen")
|
||||
if not self._queue:
|
||||
self.idle_changed.emit(True)
|
||||
self._emit_overall_status()
|
||||
QTimer.singleShot(200, self._start_next)
|
||||
|
||||
def _check_intervals(self) -> None:
|
||||
if self.config.settings.paused:
|
||||
return
|
||||
now = datetime.now()
|
||||
for task in self.config.tasks:
|
||||
if not task.enabled or task.trigger is not TriggerMode.INTERVAL:
|
||||
continue
|
||||
try:
|
||||
last_run = datetime.fromisoformat(task.last_run) if task.last_run else datetime.min
|
||||
except ValueError:
|
||||
last_run = datetime.min
|
||||
if now - last_run >= timedelta(minutes=task.interval_minutes):
|
||||
self.enqueue(task.id)
|
||||
|
||||
def _on_realtime_event(self, task_id: str) -> None:
|
||||
now = datetime.now()
|
||||
last = self._last_realtime_event.get(task_id)
|
||||
self._last_realtime_event[task_id] = now
|
||||
if last and now - last < timedelta(seconds=2):
|
||||
return
|
||||
QTimer.singleShot(1200, lambda: self.enqueue(task_id))
|
||||
|
||||
def _check_retries(self) -> None:
|
||||
if self.config.settings.paused or self.is_running:
|
||||
return
|
||||
for task_id, paths in list(self._retry_tasks.items()):
|
||||
if paths and any(is_file_locked(path) for path in paths):
|
||||
continue
|
||||
self.log_created.emit(
|
||||
"Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft."
|
||||
)
|
||||
self.enqueue(task_id)
|
||||
break
|
||||
|
||||
def _task(self, task_id: str) -> SyncTask | None:
|
||||
return next((task for task in self.config.tasks if task.id == task_id), None)
|
||||
|
||||
def _set_task_state(self, task_id: str, state: TaskRuntimeState) -> None:
|
||||
if self._task_states.get(task_id) == state:
|
||||
return
|
||||
self._task_states[task_id] = state
|
||||
self.task_state_changed.emit(task_id, state)
|
||||
self._emit_overall_status()
|
||||
|
||||
def _emit_overall_status(self) -> None:
|
||||
status, text = self.current_overall_status()
|
||||
self.overall_status_changed.emit(status, text)
|
||||
|
||||
def _emit_lock_summary(self) -> None:
|
||||
self.lock_changed.emit("\n".join(self._lock_messages.values()))
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Dialoge zur Aufgabenbearbeitung und Unterauswahl."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QDir
|
||||
from PyQt6.QtGui import QFileSystemModel
|
||||
from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QTabWidget,
|
||||
QTreeView,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask, TriggerMode
|
||||
|
||||
__all__ = ["TaskDialog"]
|
||||
|
||||
|
||||
class _PathField(QWidget):
|
||||
def __init__(self, title: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.edit = QLineEdit()
|
||||
self.edit.setPlaceholderText(title)
|
||||
self.button = QPushButton("Auswählen …")
|
||||
self.button.clicked.connect(self._browse)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
layout.addWidget(self.edit, 1)
|
||||
layout.addWidget(self.button)
|
||||
|
||||
def _browse(self) -> None:
|
||||
selected = QFileDialog.getExistingDirectory(self, "Ordner auswählen", self.edit.text())
|
||||
if selected:
|
||||
self.edit.setText(QDir.toNativeSeparators(selected))
|
||||
|
||||
|
||||
class _SelectionDialog(QDialog):
|
||||
def __init__(self, root: Path, selected: list[str], parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Dateien und Ordner auswählen")
|
||||
self.resize(820, 560)
|
||||
self.root = root
|
||||
self.model = QFileSystemModel(self)
|
||||
self.model.setRootPath(str(root))
|
||||
self.model.setFilter(QDir.Filter.AllEntries | QDir.Filter.NoDotAndDotDot)
|
||||
self.tree = QTreeView()
|
||||
self.tree.setModel(self.model)
|
||||
self.tree.setRootIndex(self.model.index(str(root)))
|
||||
self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.tree.setAlternatingRowColors(True)
|
||||
self.tree.setSortingEnabled(True)
|
||||
for column in (1, 2, 3):
|
||||
self.tree.hideColumn(column)
|
||||
|
||||
self.chosen = QListWidget()
|
||||
self.chosen.addItems(selected)
|
||||
add_button = QPushButton("Markierte übernehmen")
|
||||
remove_button = QPushButton("Aus Liste entfernen")
|
||||
add_button.clicked.connect(self._add_marked)
|
||||
remove_button.clicked.connect(self._remove_chosen)
|
||||
|
||||
hint = QLabel(
|
||||
"Ohne Unterauswahl wird der gesamte Quellordner synchronisiert. "
|
||||
"Ordnerauswahlen schließen deren gesamten Inhalt ein."
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
hint.setProperty("muted", True)
|
||||
|
||||
columns = QHBoxLayout()
|
||||
tree_column = QVBoxLayout()
|
||||
tree_column.addWidget(QLabel("Quellordner"))
|
||||
tree_column.addWidget(self.tree, 1)
|
||||
tree_column.addWidget(add_button)
|
||||
chosen_column = QVBoxLayout()
|
||||
chosen_column.addWidget(QLabel("Aktive Unterauswahl"))
|
||||
chosen_column.addWidget(self.chosen, 1)
|
||||
chosen_column.addWidget(remove_button)
|
||||
columns.addLayout(tree_column, 3)
|
||||
columns.addLayout(chosen_column, 2)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.setSpacing(12)
|
||||
layout.addWidget(hint)
|
||||
layout.addLayout(columns, 1)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def selected_paths(self) -> list[str]:
|
||||
"""Liefert die gespeicherten relativen Pfade."""
|
||||
return sorted({self.chosen.item(index).text() for index in range(self.chosen.count())})
|
||||
|
||||
def _add_marked(self) -> None:
|
||||
existing = set(self.selected_paths())
|
||||
for index in self.tree.selectionModel().selectedRows(0):
|
||||
path = Path(self.model.filePath(index))
|
||||
try:
|
||||
relative = str(path.relative_to(self.root))
|
||||
except ValueError:
|
||||
continue
|
||||
if relative not in existing and relative != ".":
|
||||
self.chosen.addItem(relative)
|
||||
existing.add(relative)
|
||||
|
||||
def _remove_chosen(self) -> None:
|
||||
for item in self.chosen.selectedItems():
|
||||
self.chosen.takeItem(self.chosen.row(item))
|
||||
|
||||
|
||||
class TaskDialog(QDialog):
|
||||
"""Vollständiger Editor für eine Synchronisationsaufgabe."""
|
||||
|
||||
def __init__(self, task: SyncTask | None = None, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._original = task
|
||||
self._selected_paths = list(task.selected_paths) if task else []
|
||||
self.setWindowTitle("Aufgabe bearbeiten" if task else "Neue Aufgabe")
|
||||
self.setMinimumWidth(690)
|
||||
self.resize(720, 610)
|
||||
|
||||
self.name_edit = QLineEdit(task.name if task else "")
|
||||
self.name_edit.setPlaceholderText("z. B. Projektarchiv")
|
||||
self.source_field = _PathField("Lokalen Quellordner wählen")
|
||||
self.target_field = _PathField("NAS- oder Zielordner wählen")
|
||||
if task:
|
||||
self.source_field.edit.setText(task.source)
|
||||
self.target_field.edit.setText(task.target)
|
||||
|
||||
self.direction_combo = QComboBox()
|
||||
self.direction_combo.addItem("Einweg – Quelle → Ziel", SyncDirection.ONE_WAY)
|
||||
self.direction_combo.addItem("Bidirektional – beide Seiten", SyncDirection.BIDIRECTIONAL)
|
||||
self.trigger_combo = QComboBox()
|
||||
self.trigger_combo.addItem("Manuell", TriggerMode.MANUAL)
|
||||
self.trigger_combo.addItem("Echtzeit überwachen", TriggerMode.REALTIME)
|
||||
self.trigger_combo.addItem("Zeitgesteuert", TriggerMode.INTERVAL)
|
||||
self.interval_spin = QSpinBox()
|
||||
self.interval_spin.setRange(1, 10080)
|
||||
self.interval_spin.setSuffix(" Minuten")
|
||||
self.interval_spin.setValue(task.interval_minutes if task else 30)
|
||||
self.delete_combo = QComboBox()
|
||||
self.delete_combo.addItem("Nie automatisch löschen", DeleteMode.KEEP)
|
||||
self.delete_combo.addItem("Löschungen spiegeln", DeleteMode.MIRROR)
|
||||
self.conflict_combo = QComboBox()
|
||||
self.conflict_combo.addItem("Neuerer Zeitstempel gewinnt", ConflictMode.NEWER_WINS)
|
||||
self.conflict_combo.addItem("Unterlegene Version als _conflict-Kopie behalten", ConflictMode.CONFLICT_COPY)
|
||||
self.enabled_check = QCheckBox("Aufgabe ist aktiv")
|
||||
self.enabled_check.setChecked(task.enabled if task else True)
|
||||
|
||||
if task:
|
||||
self._set_combo(self.direction_combo, task.direction)
|
||||
self._set_combo(self.trigger_combo, task.trigger)
|
||||
self._set_combo(self.delete_combo, task.delete_mode)
|
||||
self._set_combo(self.conflict_combo, task.conflict_mode)
|
||||
|
||||
self.selection_label = QLabel()
|
||||
self.selection_label.setProperty("muted", True)
|
||||
selection_button = QPushButton("Unterauswahl festlegen …")
|
||||
selection_button.clicked.connect(self._choose_selection)
|
||||
selection_row = QHBoxLayout()
|
||||
selection_row.addWidget(self.selection_label, 1)
|
||||
selection_row.addWidget(selection_button)
|
||||
|
||||
base_page = QWidget()
|
||||
base_form = QFormLayout(base_page)
|
||||
base_form.setContentsMargins(18, 18, 18, 18)
|
||||
base_form.setHorizontalSpacing(18)
|
||||
base_form.setVerticalSpacing(14)
|
||||
base_form.addRow("Name", self.name_edit)
|
||||
base_form.addRow("Quellordner", self.source_field)
|
||||
base_form.addRow("Zielordner", self.target_field)
|
||||
base_form.addRow("Richtung", self.direction_combo)
|
||||
base_form.addRow("Auslösung", self.trigger_combo)
|
||||
base_form.addRow("Intervall", self.interval_spin)
|
||||
base_form.addRow("Umfang", selection_row)
|
||||
base_form.addRow("", self.enabled_check)
|
||||
|
||||
behavior_page = QWidget()
|
||||
behavior_layout = QFormLayout(behavior_page)
|
||||
behavior_layout.setContentsMargins(18, 18, 18, 18)
|
||||
behavior_layout.setHorizontalSpacing(18)
|
||||
behavior_layout.setVerticalSpacing(14)
|
||||
behavior_layout.addRow("Löschverhalten", self.delete_combo)
|
||||
behavior_layout.addRow("Dateikonflikte", self.conflict_combo)
|
||||
warning = QLabel(
|
||||
"Beim Spiegeln von Löschungen wird der letzte erfolgreiche Abgleich als Referenz verwendet. "
|
||||
"So werden neu hinzugefügte Dateien nicht versehentlich als Löschung interpretiert."
|
||||
)
|
||||
warning.setWordWrap(True)
|
||||
warning.setProperty("muted", True)
|
||||
behavior_layout.addRow("", warning)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(base_page, "Ordner und Zeitplan")
|
||||
tabs.addTab(behavior_page, "Konflikte und Löschungen")
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.button(QDialogButtonBox.StandardButton.Save).setText("Speichern")
|
||||
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("Abbrechen")
|
||||
buttons.accepted.connect(self._validate_and_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.addWidget(tabs, 1)
|
||||
layout.addWidget(buttons)
|
||||
self.trigger_combo.currentIndexChanged.connect(self._update_visibility)
|
||||
self.direction_combo.currentIndexChanged.connect(self._update_visibility)
|
||||
self.source_field.edit.textChanged.connect(self._update_selection_label)
|
||||
self._update_visibility()
|
||||
self._update_selection_label()
|
||||
|
||||
def task(self) -> SyncTask:
|
||||
"""Erzeugt das validierte Modell aus den Formulardaten."""
|
||||
task = SyncTask(
|
||||
id=self._original.id if self._original else SyncTask("", "", "").id,
|
||||
name=self.name_edit.text(),
|
||||
source=self.source_field.edit.text(),
|
||||
target=self.target_field.edit.text(),
|
||||
direction=self.direction_combo.currentData(),
|
||||
trigger=self.trigger_combo.currentData(),
|
||||
delete_mode=self.delete_combo.currentData(),
|
||||
conflict_mode=self.conflict_combo.currentData(),
|
||||
interval_minutes=self.interval_spin.value(),
|
||||
selected_paths=self._selected_paths,
|
||||
enabled=self.enabled_check.isChecked(),
|
||||
last_run=self._original.last_run if self._original else None,
|
||||
)
|
||||
task.validate()
|
||||
return task
|
||||
|
||||
def _choose_selection(self) -> None:
|
||||
root = Path(self.source_field.edit.text())
|
||||
if not root.is_dir():
|
||||
QMessageBox.warning(self, "Quellordner fehlt", "Bitte zuerst einen erreichbaren Quellordner auswählen.")
|
||||
return
|
||||
dialog = _SelectionDialog(root, self._selected_paths, self)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self._selected_paths = dialog.selected_paths()
|
||||
self._update_selection_label()
|
||||
|
||||
def _validate_and_accept(self) -> None:
|
||||
try:
|
||||
self.task()
|
||||
except ValueError as exc:
|
||||
QMessageBox.warning(self, "Eingaben prüfen", str(exc))
|
||||
return
|
||||
self.accept()
|
||||
|
||||
def _update_visibility(self) -> None:
|
||||
self.interval_spin.setEnabled(self.trigger_combo.currentData() is TriggerMode.INTERVAL)
|
||||
self.conflict_combo.setEnabled(self.direction_combo.currentData() is SyncDirection.BIDIRECTIONAL)
|
||||
|
||||
def _update_selection_label(self) -> None:
|
||||
if self._selected_paths:
|
||||
self.selection_label.setText(f"{len(self._selected_paths)} Einträge ausgewählt")
|
||||
else:
|
||||
self.selection_label.setText("Gesamter Quellordner")
|
||||
|
||||
@staticmethod
|
||||
def _set_combo(combo: QComboBox, value: object) -> None:
|
||||
index = combo.findData(value)
|
||||
if index >= 0:
|
||||
combo.setCurrentIndex(index)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Ermittlung von Prozessen, die Dateien unter Windows sperren."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
from ctypes import wintypes
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["LockingProcess", "find_locking_processes", "is_file_locked"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LockingProcess:
|
||||
"""Ein vom Windows Restart Manager gemeldeter Prozess."""
|
||||
|
||||
pid: int
|
||||
name: str
|
||||
|
||||
|
||||
def is_file_locked(path: Path) -> bool:
|
||||
"""Prüft eine vorhandene Datei durch exklusives Öffnen ohne Schreibzugriff."""
|
||||
if not path.exists() or os.name != "nt":
|
||||
return False
|
||||
handle = ctypes.windll.kernel32.CreateFileW(
|
||||
str(path),
|
||||
0x80000000,
|
||||
0,
|
||||
None,
|
||||
3,
|
||||
0x80,
|
||||
None,
|
||||
)
|
||||
invalid_handle = ctypes.c_void_p(-1).value
|
||||
if handle == invalid_handle:
|
||||
return ctypes.get_last_error() in {5, 32, 33}
|
||||
ctypes.windll.kernel32.CloseHandle(handle)
|
||||
return False
|
||||
|
||||
|
||||
def find_locking_processes(path: Path) -> list[LockingProcess]:
|
||||
"""Liefert sperrende Prozesse über die Windows-Restart-Manager-API."""
|
||||
if os.name != "nt" or not path.exists():
|
||||
return []
|
||||
try:
|
||||
return _restart_manager_processes(path)
|
||||
except (OSError, AttributeError, ctypes.ArgumentError):
|
||||
return []
|
||||
|
||||
|
||||
def _restart_manager_processes(path: Path) -> list[LockingProcess]:
|
||||
rstrtmgr = ctypes.WinDLL("Rstrtmgr")
|
||||
session_handle = wintypes.DWORD()
|
||||
session_key = ctypes.create_unicode_buffer(33)
|
||||
if rstrtmgr.RmStartSession(ctypes.byref(session_handle), 0, session_key) != 0:
|
||||
return []
|
||||
try:
|
||||
resources = (wintypes.LPCWSTR * 1)(str(path))
|
||||
if rstrtmgr.RmRegisterResources(session_handle, 1, resources, 0, None, 0, None) != 0:
|
||||
return []
|
||||
needed = wintypes.UINT(0)
|
||||
count = wintypes.UINT(0)
|
||||
reboot_reasons = wintypes.DWORD(0)
|
||||
result = rstrtmgr.RmGetList(
|
||||
session_handle,
|
||||
ctypes.byref(needed),
|
||||
ctypes.byref(count),
|
||||
None,
|
||||
ctypes.byref(reboot_reasons),
|
||||
)
|
||||
if result not in {0, 234} or needed.value == 0:
|
||||
return []
|
||||
process_info_type = _rm_process_info_type()
|
||||
process_array = (process_info_type * needed.value)()
|
||||
count.value = needed.value
|
||||
result = rstrtmgr.RmGetList(
|
||||
session_handle,
|
||||
ctypes.byref(needed),
|
||||
ctypes.byref(count),
|
||||
process_array,
|
||||
ctypes.byref(reboot_reasons),
|
||||
)
|
||||
if result != 0:
|
||||
return []
|
||||
return [
|
||||
LockingProcess(
|
||||
pid=int(item.Process.dwProcessId), name=item.strAppName or f"Prozess {item.Process.dwProcessId}"
|
||||
)
|
||||
for item in process_array[: count.value]
|
||||
]
|
||||
finally:
|
||||
rstrtmgr.RmEndSession(session_handle)
|
||||
|
||||
|
||||
def _rm_process_info_type() -> type[ctypes.Structure]:
|
||||
class FileTime(ctypes.Structure):
|
||||
_fields_ = [("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD)]
|
||||
|
||||
class UniqueProcess(ctypes.Structure):
|
||||
_fields_ = [("dwProcessId", wintypes.DWORD), ("ProcessStartTime", FileTime)]
|
||||
|
||||
class ProcessInfo(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Process", UniqueProcess),
|
||||
("strAppName", wintypes.WCHAR * 256),
|
||||
("strServiceShortName", wintypes.WCHAR * 64),
|
||||
("ApplicationType", wintypes.UINT),
|
||||
("AppStatus", wintypes.ULONG),
|
||||
("TSSessionId", wintypes.DWORD),
|
||||
("bRestartable", wintypes.BOOL),
|
||||
]
|
||||
|
||||
return ProcessInfo
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Skalierbare, programmatisch gezeichnete Status- und App-Symbole."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QPointF, QRectF, Qt
|
||||
from PyQt6.QtGui import QColor, QFont, QIcon, QPainter, QPainterPath, QPen, QPixmap
|
||||
|
||||
__all__ = ["app_icon", "status_icon"]
|
||||
|
||||
|
||||
def app_icon(size: int = 64) -> QIcon:
|
||||
"""Erzeugt das SyncVito-Symbol ohne plattformabhängige Emoji-Glyphen."""
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#2878FF"))
|
||||
painter.drawRoundedRect(QRectF(2, 2, size - 4, size - 4), size * 0.22, size * 0.22)
|
||||
pen = QPen(QColor("#FFFFFF"), max(2, size * 0.07), Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
path = QPainterPath(QPointF(size * 0.25, size * 0.38))
|
||||
path.cubicTo(size * 0.38, size * 0.18, size * 0.68, size * 0.20, size * 0.74, size * 0.39)
|
||||
path.lineTo(size * 0.67, size * 0.34)
|
||||
painter.drawPath(path)
|
||||
path = QPainterPath(QPointF(size * 0.75, size * 0.62))
|
||||
path.cubicTo(size * 0.62, size * 0.82, size * 0.32, size * 0.80, size * 0.26, size * 0.61)
|
||||
path.lineTo(size * 0.33, size * 0.66)
|
||||
painter.drawPath(path)
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def status_icon(status: str, size: int = 64) -> QIcon:
|
||||
"""Erzeugt ein farbiges Tray-Symbol für aktiv, pausiert oder Fehler."""
|
||||
colors = {"active": "#35C77A", "paused": "#E0525D", "error": "#F0B84B"}
|
||||
pixmap = app_icon(size).pixmap(size, size)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
diameter = size * 0.34
|
||||
badge = QRectF(size - diameter - 1, size - diameter - 1, diameter, diameter)
|
||||
painter.setPen(QPen(QColor("#0B0F14"), max(2, size * 0.04)))
|
||||
painter.setBrush(QColor(colors.get(status, colors["active"])))
|
||||
painter.drawEllipse(badge)
|
||||
if status == "error":
|
||||
painter.setPen(QColor("#111820"))
|
||||
font = QFont("Segoe UI", max(8, round(size * 0.20)), QFont.Weight.Bold)
|
||||
painter.setFont(font)
|
||||
painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, "!")
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
@@ -0,0 +1,653 @@
|
||||
"""Hauptfenster mit Aufgaben-, Protokoll- und Einstellungsansicht."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QEvent, Qt, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QAction, QCloseEvent, QFontDatabase
|
||||
from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QStackedWidget,
|
||||
QSystemTrayIcon,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from syncvito.autostart import set_autostart
|
||||
from syncvito.config_store import ConfigError, ConfigStore
|
||||
from syncvito.coordinator import SyncCoordinator
|
||||
from syncvito.dialogs import TaskDialog
|
||||
from syncvito.icons import app_icon, status_icon
|
||||
from syncvito.models import AppConfig, SyncDirection, SyncTask, TaskRuntimeState, TriggerMode
|
||||
from syncvito.state_store import StateStore
|
||||
from syncvito.theme import apply_theme
|
||||
from syncvito.update_controller import UpdateController
|
||||
from syncvito.update_dialog import UpdateDialog
|
||||
from syncvito.updater import UpdateInfo
|
||||
from syncvito.version import __version__
|
||||
|
||||
__all__ = ["MainWindow"]
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Zentrale SyncVito-Benutzeroberfläche."""
|
||||
|
||||
quit_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, config: AppConfig, config_store: ConfigStore) -> None:
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.config_store = config_store
|
||||
self._allow_close = False
|
||||
self.setWindowTitle("SyncVito")
|
||||
self.setWindowIcon(app_icon())
|
||||
self.setMinimumSize(980, 680)
|
||||
self.resize(1220, 800)
|
||||
|
||||
self.coordinator = SyncCoordinator(config, config_store, self)
|
||||
self.updater = UpdateController(__version__, self)
|
||||
self._build_ui()
|
||||
self._build_tray()
|
||||
self._connect_coordinator()
|
||||
self._refresh_tasks()
|
||||
self._set_global_status(*self.coordinator.current_overall_status())
|
||||
QTimer.singleShot(3000, self._automatic_update_check)
|
||||
|
||||
def show_from_tray(self) -> None:
|
||||
"""Stellt das Fenster aus dem Tray wieder her."""
|
||||
self.showNormal()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
|
||||
def request_quit(self) -> None:
|
||||
"""Beendet Observer und Anwendung explizit über das Tray-Menü."""
|
||||
self._allow_close = True
|
||||
self.coordinator.stop()
|
||||
self.tray.hide()
|
||||
QApplication.instance().quit()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt API
|
||||
if self._allow_close:
|
||||
event.accept()
|
||||
return
|
||||
event.ignore()
|
||||
self.hide()
|
||||
self.tray.showMessage(
|
||||
"SyncVito läuft weiter",
|
||||
"Das Fenster wurde in den Infobereich verschoben.",
|
||||
QSystemTrayIcon.MessageIcon.Information,
|
||||
2500,
|
||||
)
|
||||
|
||||
def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||||
super().changeEvent(event)
|
||||
if event.type() == QEvent.Type.WindowStateChange and self.isMinimized():
|
||||
QApplication.processEvents()
|
||||
self.hide()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
root = QWidget()
|
||||
self.setCentralWidget(root)
|
||||
root_layout = QHBoxLayout(root)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.setSpacing(0)
|
||||
root_layout.addWidget(self._build_sidebar())
|
||||
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout(right)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.setSpacing(0)
|
||||
right_layout.addWidget(self._build_header())
|
||||
self.stack = QStackedWidget()
|
||||
self.tasks_page = self._build_tasks_page()
|
||||
self.log_page = self._build_log_page()
|
||||
self.settings_page = self._build_settings_page()
|
||||
self.stack.addWidget(self.tasks_page)
|
||||
self.stack.addWidget(self.log_page)
|
||||
self.stack.addWidget(self.settings_page)
|
||||
right_layout.addWidget(self.stack, 1)
|
||||
right_layout.addWidget(self._build_progress_dock())
|
||||
root_layout.addWidget(right, 1)
|
||||
|
||||
def _build_sidebar(self) -> QWidget:
|
||||
sidebar = QWidget()
|
||||
sidebar.setObjectName("Sidebar")
|
||||
sidebar.setFixedWidth(226)
|
||||
layout = QVBoxLayout(sidebar)
|
||||
layout.setContentsMargins(18, 22, 18, 18)
|
||||
layout.setSpacing(8)
|
||||
|
||||
eyebrow = QLabel("DATEISYNCHRONISATION")
|
||||
eyebrow.setObjectName("Eyebrow")
|
||||
brand = QLabel("SyncVito")
|
||||
brand.setObjectName("Brand")
|
||||
layout.addWidget(eyebrow)
|
||||
layout.addWidget(brand)
|
||||
layout.addSpacing(28)
|
||||
|
||||
self.nav_buttons: list[QPushButton] = []
|
||||
for title, index in (("Aufgaben", 0), ("Protokoll", 1), ("Einstellungen", 2)):
|
||||
button = QPushButton(title)
|
||||
button.setCheckable(True)
|
||||
button.setProperty("nav", True)
|
||||
button.setMinimumHeight(42)
|
||||
button.clicked.connect(lambda checked=False, page=index: self._select_page(page))
|
||||
layout.addWidget(button)
|
||||
self.nav_buttons.append(button)
|
||||
self.nav_buttons[0].setChecked(True)
|
||||
layout.addStretch(1)
|
||||
|
||||
version = QLabel("Version 1.0.0\nLokale Konfiguration")
|
||||
version.setProperty("muted", True)
|
||||
layout.addWidget(version)
|
||||
return sidebar
|
||||
|
||||
def _build_header(self) -> QWidget:
|
||||
header = QWidget()
|
||||
header.setObjectName("Header")
|
||||
header.setFixedHeight(82)
|
||||
layout = QHBoxLayout(header)
|
||||
layout.setContentsMargins(28, 0, 28, 0)
|
||||
title_area = QVBoxLayout()
|
||||
title_area.setSpacing(2)
|
||||
self.page_title = QLabel("Synchronisationsaufgaben")
|
||||
self.page_title.setObjectName("PageTitle")
|
||||
self.page_subtitle = QLabel("Lokale Ordner und NAS-Ziele zuverlässig im gleichen Stand halten")
|
||||
self.page_subtitle.setObjectName("PageSubtitle")
|
||||
title_area.addWidget(self.page_title)
|
||||
title_area.addWidget(self.page_subtitle)
|
||||
layout.addLayout(title_area)
|
||||
layout.addStretch(1)
|
||||
self.status_label = QLabel("Bereit")
|
||||
self.status_label.setObjectName("StatusPill")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
layout.addWidget(self.status_label, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
self.sync_all_button = QPushButton("Alle jetzt synchronisieren")
|
||||
self.sync_all_button.setProperty("primary", True)
|
||||
self.sync_all_button.clicked.connect(self.coordinator.enqueue_all)
|
||||
layout.addWidget(self.sync_all_button, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
return header
|
||||
|
||||
def _build_tasks_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(28, 24, 28, 24)
|
||||
layout.setSpacing(14)
|
||||
toolbar = QHBoxLayout()
|
||||
add_button = QPushButton("Neue Aufgabe")
|
||||
add_button.setProperty("primary", True)
|
||||
edit_button = QPushButton("Bearbeiten")
|
||||
delete_button = QPushButton("Löschen")
|
||||
delete_button.setProperty("danger", True)
|
||||
self.sync_selected_button = QPushButton("Auswahl synchronisieren")
|
||||
add_button.clicked.connect(self._add_task)
|
||||
edit_button.clicked.connect(self._edit_task)
|
||||
delete_button.clicked.connect(self._delete_task)
|
||||
self.sync_selected_button.clicked.connect(self._sync_selected)
|
||||
toolbar.addWidget(add_button)
|
||||
toolbar.addWidget(edit_button)
|
||||
toolbar.addWidget(delete_button)
|
||||
toolbar.addStretch(1)
|
||||
toolbar.addWidget(self.sync_selected_button)
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
self.task_table = QTableWidget(0, 5)
|
||||
self.task_table.setHorizontalHeaderLabels(["Aufgabe", "Ordnerpaar", "Richtung", "Auslösung", "Status"])
|
||||
self.task_table.setAlternatingRowColors(True)
|
||||
self.task_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.task_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.task_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.task_table.verticalHeader().setVisible(False)
|
||||
self.task_table.verticalHeader().setDefaultSectionSize(58)
|
||||
header = self.task_table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.task_table.doubleClicked.connect(self._edit_task)
|
||||
layout.addWidget(self.task_table, 1)
|
||||
|
||||
self.lock_banner = QLabel()
|
||||
self.lock_banner.setObjectName("LockBanner")
|
||||
self.lock_banner.setWordWrap(True)
|
||||
self.lock_banner.hide()
|
||||
layout.addWidget(self.lock_banner)
|
||||
return page
|
||||
|
||||
def _build_log_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(28, 24, 28, 24)
|
||||
layout.setSpacing(12)
|
||||
self.log_table = QTableWidget(0, 4)
|
||||
self.log_table.setHorizontalHeaderLabels(["Zeitpunkt", "Aktion", "Ergebnis", "Details"])
|
||||
self.log_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.log_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.log_table.setAlternatingRowColors(True)
|
||||
self.log_table.verticalHeader().setVisible(False)
|
||||
header = self.log_table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
||||
clear_button = QPushButton("Ansicht leeren")
|
||||
clear_button.clicked.connect(lambda: self.log_table.setRowCount(0))
|
||||
button_row = QHBoxLayout()
|
||||
button_row.addStretch(1)
|
||||
button_row.addWidget(clear_button)
|
||||
layout.addLayout(button_row)
|
||||
layout.addWidget(self.log_table, 1)
|
||||
return page
|
||||
|
||||
def _build_settings_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
outer = QVBoxLayout(page)
|
||||
outer.setContentsMargins(28, 24, 28, 24)
|
||||
outer.setSpacing(16)
|
||||
card = QFrame()
|
||||
card.setProperty("card", True)
|
||||
layout = QVBoxLayout(card)
|
||||
layout.setContentsMargins(22, 22, 22, 22)
|
||||
layout.setSpacing(16)
|
||||
heading = QLabel("Darstellung und Systemstart")
|
||||
heading.setObjectName("PageTitle")
|
||||
layout.addWidget(heading)
|
||||
|
||||
self.autostart_check = QCheckBox("SyncVito mit Windows starten")
|
||||
self.autostart_check.setChecked(self.config.settings.autostart)
|
||||
layout.addWidget(self.autostart_check)
|
||||
layout.addSpacing(6)
|
||||
|
||||
font_row = QHBoxLayout()
|
||||
font_row.addWidget(QLabel("Schriftart"))
|
||||
self.font_combo = QComboBox()
|
||||
self.font_combo.addItems(QFontDatabase.families())
|
||||
font_index = self.font_combo.findText(self.config.settings.font_family)
|
||||
self.font_combo.setCurrentIndex(max(0, font_index))
|
||||
font_row.addWidget(self.font_combo, 1)
|
||||
font_row.addWidget(QLabel("Größe"))
|
||||
self.font_size_spin = QSpinBox()
|
||||
self.font_size_spin.setRange(8, 18)
|
||||
self.font_size_spin.setValue(self.config.settings.font_size)
|
||||
font_row.addWidget(self.font_size_spin)
|
||||
font_row.addWidget(QLabel("Schnitt"))
|
||||
self.font_weight_combo = QComboBox()
|
||||
self.font_weight_combo.addItem("Regulär", "regular")
|
||||
self.font_weight_combo.addItem("Fett", "bold")
|
||||
self.font_weight_combo.setCurrentIndex(1 if self.config.settings.font_weight == "bold" else 0)
|
||||
font_row.addWidget(self.font_weight_combo)
|
||||
layout.addLayout(font_row)
|
||||
|
||||
scale_row = QHBoxLayout()
|
||||
scale_row.addWidget(QLabel("UI-Skalierung"))
|
||||
self.scale_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.scale_slider.setRange(80, 150)
|
||||
self.scale_slider.setTickInterval(10)
|
||||
self.scale_slider.setValue(self.config.settings.ui_scale)
|
||||
self.scale_value = QLabel(f"{self.config.settings.ui_scale} %")
|
||||
self.scale_value.setMinimumWidth(52)
|
||||
self.scale_slider.valueChanged.connect(lambda value: self.scale_value.setText(f"{value} %"))
|
||||
scale_row.addWidget(self.scale_slider, 1)
|
||||
scale_row.addWidget(self.scale_value)
|
||||
layout.addLayout(scale_row)
|
||||
|
||||
hint = QLabel("Änderungen an Schrift und Skalierung werden sofort angewendet und lokal gespeichert.")
|
||||
hint.setProperty("muted", True)
|
||||
layout.addWidget(hint)
|
||||
save_button = QPushButton("Einstellungen übernehmen")
|
||||
save_button.setProperty("primary", True)
|
||||
save_button.clicked.connect(self._save_settings)
|
||||
action_row = QHBoxLayout()
|
||||
action_row.addStretch(1)
|
||||
action_row.addWidget(save_button)
|
||||
layout.addLayout(action_row)
|
||||
outer.addWidget(card)
|
||||
|
||||
update_card = QFrame()
|
||||
update_card.setProperty("card", True)
|
||||
update_layout = QVBoxLayout(update_card)
|
||||
update_layout.setContentsMargins(22, 22, 22, 22)
|
||||
update_layout.setSpacing(12)
|
||||
update_heading = QLabel("Programmupdates")
|
||||
update_heading.setObjectName("PageTitle")
|
||||
update_layout.addWidget(update_heading)
|
||||
version_label = QLabel(f"Installierte Version: {__version__}")
|
||||
version_label.setProperty("muted", True)
|
||||
update_layout.addWidget(version_label)
|
||||
self.update_check_box = QCheckBox("Beim Start automatisch nach Updates suchen")
|
||||
self.update_check_box.setChecked(self.config.settings.check_updates_on_start)
|
||||
update_layout.addWidget(self.update_check_box)
|
||||
update_hint = QLabel(
|
||||
"Updates werden ausschließlich aus den öffentlichen Releases des SyncVito-Gitea-Repositories geladen "
|
||||
"und vor der Installation per SHA-256 geprüft."
|
||||
)
|
||||
update_hint.setWordWrap(True)
|
||||
update_hint.setProperty("muted", True)
|
||||
update_layout.addWidget(update_hint)
|
||||
self.update_button = QPushButton("Nach Updates suchen")
|
||||
self.update_button.clicked.connect(lambda: self._check_updates(manual=True))
|
||||
update_actions = QHBoxLayout()
|
||||
update_actions.addStretch(1)
|
||||
update_actions.addWidget(self.update_button)
|
||||
update_layout.addLayout(update_actions)
|
||||
outer.addWidget(update_card)
|
||||
outer.addStretch(1)
|
||||
return page
|
||||
|
||||
def _build_progress_dock(self) -> QWidget:
|
||||
dock = QWidget()
|
||||
dock.setObjectName("ProgressDock")
|
||||
dock.setFixedHeight(66)
|
||||
layout = QHBoxLayout(dock)
|
||||
layout.setContentsMargins(28, 12, 28, 12)
|
||||
self.progress_text = QLabel("Bereit")
|
||||
self.progress_text.setMinimumWidth(250)
|
||||
self.progress_text.setProperty("muted", True)
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_bar.setFormat("%p %")
|
||||
layout.addWidget(self.progress_text)
|
||||
layout.addWidget(self.progress_bar, 1)
|
||||
return dock
|
||||
|
||||
def _build_tray(self) -> None:
|
||||
self.tray = QSystemTrayIcon(status_icon("active"), self)
|
||||
self.tray.setToolTip("SyncVito – Bereit")
|
||||
menu = QMenu()
|
||||
open_action = QAction("Öffnen", self)
|
||||
self.tray_sync_action = QAction("Sync jetzt", self)
|
||||
self.tray_update_action = QAction("Nach Updates suchen", self)
|
||||
self.pause_action = QAction("Pausieren", self)
|
||||
quit_action = QAction("Beenden", self)
|
||||
open_action.triggered.connect(self.show_from_tray)
|
||||
self.tray_sync_action.triggered.connect(self.coordinator.enqueue_all)
|
||||
self.tray_update_action.triggered.connect(lambda: self._check_updates(manual=True))
|
||||
self.pause_action.triggered.connect(self._toggle_pause)
|
||||
quit_action.triggered.connect(self.request_quit)
|
||||
menu.addAction(open_action)
|
||||
menu.addAction(self.tray_sync_action)
|
||||
menu.addAction(self.pause_action)
|
||||
menu.addAction(self.tray_update_action)
|
||||
menu.addSeparator()
|
||||
menu.addAction(quit_action)
|
||||
self.tray.setContextMenu(menu)
|
||||
self.tray.activated.connect(
|
||||
lambda reason: self.show_from_tray() if reason == QSystemTrayIcon.ActivationReason.DoubleClick else None
|
||||
)
|
||||
self.tray.show()
|
||||
self._update_pause_action()
|
||||
|
||||
def _connect_coordinator(self) -> None:
|
||||
self.coordinator.progress_changed.connect(self._update_progress)
|
||||
self.coordinator.log_created.connect(self._append_log)
|
||||
self.coordinator.overall_status_changed.connect(self._set_global_status)
|
||||
self.coordinator.task_state_changed.connect(self._set_task_state)
|
||||
self.coordinator.lock_changed.connect(self._set_lock_message)
|
||||
self.coordinator.idle_changed.connect(lambda _idle: self._refresh_action_availability())
|
||||
self.updater.update_available.connect(self._on_update_available)
|
||||
self.updater.no_update.connect(self._on_no_update)
|
||||
self.updater.error.connect(self._on_update_error)
|
||||
self.updater.progress.connect(self._update_progress)
|
||||
self.updater.install_ready.connect(self._install_update)
|
||||
self.updater.busy_changed.connect(lambda _busy: self._refresh_action_availability())
|
||||
|
||||
def _select_page(self, index: int) -> None:
|
||||
titles = [
|
||||
("Synchronisationsaufgaben", "Lokale Ordner und NAS-Ziele zuverlässig im gleichen Stand halten"),
|
||||
("Aktivitätsprotokoll", "Erfolge, Konflikte und Fehler transparent nachvollziehen"),
|
||||
("Einstellungen", "Darstellung und Windows-Integration anpassen"),
|
||||
]
|
||||
self.stack.setCurrentIndex(index)
|
||||
self.page_title.setText(titles[index][0])
|
||||
self.page_subtitle.setText(titles[index][1])
|
||||
for button_index, button in enumerate(self.nav_buttons):
|
||||
button.setChecked(button_index == index)
|
||||
|
||||
def _refresh_tasks(self) -> None:
|
||||
self.task_table.setRowCount(len(self.config.tasks))
|
||||
for row, task in enumerate(self.config.tasks):
|
||||
name = QTableWidgetItem(task.name)
|
||||
name.setData(Qt.ItemDataRole.UserRole, task.id)
|
||||
pair = QTableWidgetItem(f"{task.source}\n→ {task.target}")
|
||||
direction = QTableWidgetItem("Einweg" if task.direction is SyncDirection.ONE_WAY else "Bidirektional")
|
||||
trigger_text = {
|
||||
TriggerMode.MANUAL: "Manuell",
|
||||
TriggerMode.REALTIME: "Echtzeit",
|
||||
TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.",
|
||||
}[task.trigger]
|
||||
trigger = QTableWidgetItem(trigger_text)
|
||||
status = QTableWidgetItem(self._runtime_state_text(self.coordinator.task_state(task.id)))
|
||||
for column, item in enumerate((name, pair, direction, trigger, status)):
|
||||
item.setToolTip(item.text())
|
||||
self.task_table.setItem(row, column, item)
|
||||
|
||||
def _selected_task(self) -> SyncTask | None:
|
||||
row = self.task_table.currentRow()
|
||||
if row < 0:
|
||||
return None
|
||||
item = self.task_table.item(row, 0)
|
||||
task_id = item.data(Qt.ItemDataRole.UserRole)
|
||||
return next((task for task in self.config.tasks if task.id == task_id), None)
|
||||
|
||||
def _add_task(self) -> None:
|
||||
dialog = TaskDialog(parent=self)
|
||||
if dialog.exec() == TaskDialog.DialogCode.Accepted:
|
||||
self.config.tasks.append(dialog.task())
|
||||
self._persist_tasks("Aufgabe angelegt")
|
||||
|
||||
def _edit_task(self) -> None:
|
||||
task = self._selected_task()
|
||||
if task is None:
|
||||
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||
return
|
||||
dialog = TaskDialog(task, self)
|
||||
if dialog.exec() == TaskDialog.DialogCode.Accepted:
|
||||
updated = dialog.task()
|
||||
index = self.config.tasks.index(task)
|
||||
self.config.tasks[index] = updated
|
||||
self._persist_tasks("Aufgabe aktualisiert")
|
||||
|
||||
def _delete_task(self) -> None:
|
||||
task = self._selected_task()
|
||||
if task is None:
|
||||
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||
return
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"Aufgabe löschen",
|
||||
f"Soll „{task.name}“ wirklich gelöscht werden? Dateien werden dabei nicht entfernt.",
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.config.tasks.remove(task)
|
||||
StateStore().remove(task.id)
|
||||
self._persist_tasks("Aufgabe gelöscht")
|
||||
|
||||
def _sync_selected(self) -> None:
|
||||
task = self._selected_task()
|
||||
if task is None:
|
||||
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||
return
|
||||
self.coordinator.enqueue(task.id)
|
||||
|
||||
def _persist_tasks(self, message: str) -> None:
|
||||
try:
|
||||
self.config_store.save(self.config)
|
||||
self.coordinator.refresh_task_states()
|
||||
self.coordinator.rebuild_monitors()
|
||||
except ConfigError as exc:
|
||||
QMessageBox.critical(self, "Speicherfehler", str(exc))
|
||||
return
|
||||
self._refresh_tasks()
|
||||
self._append_log("Aufgaben", "Erfolg", message)
|
||||
|
||||
def _save_settings(self) -> None:
|
||||
settings = self.config.settings
|
||||
old_autostart = settings.autostart
|
||||
settings.autostart = self.autostart_check.isChecked()
|
||||
settings.font_family = self.font_combo.currentText()
|
||||
settings.font_size = self.font_size_spin.value()
|
||||
settings.font_weight = self.font_weight_combo.currentData()
|
||||
settings.ui_scale = self.scale_slider.value()
|
||||
settings.check_updates_on_start = self.update_check_box.isChecked()
|
||||
settings.validate()
|
||||
try:
|
||||
if old_autostart != settings.autostart:
|
||||
set_autostart(settings.autostart)
|
||||
self.config_store.save(self.config)
|
||||
except (ConfigError, OSError) as exc:
|
||||
QMessageBox.critical(self, "Einstellungen konnten nicht gespeichert werden", str(exc))
|
||||
settings.autostart = old_autostart
|
||||
return
|
||||
apply_theme(QApplication.instance(), settings)
|
||||
self._append_log("Einstellungen", "Erfolg", "Darstellung und Systemstart wurden übernommen.")
|
||||
|
||||
def _automatic_update_check(self) -> None:
|
||||
settings = self.config.settings
|
||||
if not settings.check_updates_on_start or self.coordinator.is_running:
|
||||
return
|
||||
if settings.last_update_check:
|
||||
try:
|
||||
last_check = datetime.fromisoformat(settings.last_update_check)
|
||||
except ValueError:
|
||||
last_check = datetime.min
|
||||
if datetime.now() - last_check < timedelta(hours=24):
|
||||
return
|
||||
self._check_updates(manual=False)
|
||||
|
||||
def _check_updates(self, manual: bool) -> None:
|
||||
if self.coordinator.is_running:
|
||||
if manual:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Synchronisation läuft",
|
||||
"Bitte die laufende Synchronisation abschließen lassen und die Updateprüfung erneut starten.",
|
||||
)
|
||||
return
|
||||
self._update_progress(0, "Update wird geprüft …")
|
||||
self.updater.check(manual)
|
||||
|
||||
def _on_update_available(self, info: UpdateInfo, manual: bool) -> None:
|
||||
self._mark_update_checked()
|
||||
self._update_progress(100, f"Version {info.version} ist verfügbar")
|
||||
self._append_log("Update", "Hinweis", f"Version {info.version} ist verfügbar.")
|
||||
dialog = UpdateDialog(__version__, info, getattr(sys, "frozen", False), self)
|
||||
if dialog.exec() == UpdateDialog.DialogCode.Accepted:
|
||||
self.updater.download(info)
|
||||
|
||||
def _on_no_update(self, manual: bool) -> None:
|
||||
self._mark_update_checked()
|
||||
self._update_progress(100, "SyncVito ist aktuell")
|
||||
self._append_log("Update", "Erfolg", "Die installierte Version ist aktuell.")
|
||||
if manual:
|
||||
QMessageBox.information(self, "Keine Updates", "Du verwendest bereits die aktuelle SyncVito-Version.")
|
||||
|
||||
def _on_update_error(self, message: str, manual: bool) -> None:
|
||||
self._update_progress(0, "Updateprüfung fehlgeschlagen")
|
||||
self._append_log("Update", "Fehler", message)
|
||||
if manual:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Update nicht möglich",
|
||||
f"{message}\n\nBitte die Netzwerkverbindung prüfen und anschließend erneut versuchen.",
|
||||
)
|
||||
|
||||
def _install_update(self, path: Path, info: UpdateInfo) -> None:
|
||||
try:
|
||||
self.updater.service.launch_installer(path)
|
||||
except Exception as exc: # noqa: BLE001 - UI-Grenze verhindert einen Anwendungsabbruch.
|
||||
self._on_update_error(str(exc), True)
|
||||
return
|
||||
self._append_log("Update", "Erfolg", f"Version {info.version} wird installiert.")
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Update wird installiert",
|
||||
"SyncVito wird jetzt geschlossen, aktualisiert und automatisch neu gestartet.",
|
||||
)
|
||||
self.request_quit()
|
||||
|
||||
def _mark_update_checked(self) -> None:
|
||||
self.config.settings.last_update_check = datetime.now().isoformat(timespec="seconds")
|
||||
try:
|
||||
self.config_store.save(self.config)
|
||||
except ConfigError as exc:
|
||||
self._append_log("Update", "Fehler", str(exc))
|
||||
|
||||
def _refresh_action_availability(self) -> None:
|
||||
update_idle = not self.updater.busy
|
||||
sync_idle = not self.coordinator.is_running
|
||||
self.sync_all_button.setEnabled(update_idle and sync_idle)
|
||||
self.sync_selected_button.setEnabled(update_idle and sync_idle)
|
||||
self.tray_sync_action.setEnabled(update_idle and sync_idle)
|
||||
self.update_button.setEnabled(update_idle and sync_idle)
|
||||
self.tray_update_action.setEnabled(update_idle and sync_idle)
|
||||
|
||||
def _toggle_pause(self) -> None:
|
||||
self.coordinator.set_paused(not self.config.settings.paused)
|
||||
self._update_pause_action()
|
||||
|
||||
def _update_pause_action(self) -> None:
|
||||
self.pause_action.setText("Fortsetzen" if self.config.settings.paused else "Pausieren")
|
||||
|
||||
def _update_progress(self, value: int, detail: str) -> None:
|
||||
self.progress_bar.setValue(value)
|
||||
self.progress_text.setText(detail)
|
||||
|
||||
def _append_log(self, action: str, level: str, detail: str) -> None:
|
||||
row = self.log_table.rowCount()
|
||||
self.log_table.insertRow(row)
|
||||
values = (datetime.now().strftime("%d.%m.%Y %H:%M:%S"), action, level, detail)
|
||||
for column, value in enumerate(values):
|
||||
item = QTableWidgetItem(value)
|
||||
item.setToolTip(value)
|
||||
self.log_table.setItem(row, column, item)
|
||||
if self.log_table.rowCount() > 1000:
|
||||
self.log_table.removeRow(0)
|
||||
self.log_table.scrollToBottom()
|
||||
|
||||
def _set_global_status(self, status: str, text: str) -> None:
|
||||
colors = {"active": "#35C77A", "paused": "#E0525D", "error": "#F0B84B"}
|
||||
self.status_label.setText(text)
|
||||
self.status_label.setStyleSheet(f"color: {colors.get(status, '#E8EDF4')};")
|
||||
self.tray.setIcon(status_icon(status))
|
||||
self.tray.setToolTip(f"SyncVito – {text}")
|
||||
self._update_pause_action()
|
||||
|
||||
def _set_task_state(self, task_id: str, state: TaskRuntimeState) -> None:
|
||||
self._refresh_tasks()
|
||||
|
||||
def _set_lock_message(self, message: str) -> None:
|
||||
self.lock_banner.setText(message)
|
||||
self.lock_banner.setVisible(bool(message))
|
||||
|
||||
@staticmethod
|
||||
def _runtime_state_text(state: TaskRuntimeState) -> str:
|
||||
return {
|
||||
TaskRuntimeState.ACTIVE: "Aktiv",
|
||||
TaskRuntimeState.PAUSED: "Pausiert",
|
||||
TaskRuntimeState.WAITING_FOR_RELEASE: "Wartet auf Freigabe",
|
||||
TaskRuntimeState.ERROR: "Fehler",
|
||||
}[state]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Domänenmodelle und validierte Konfigurationswerte."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
__all__ = [
|
||||
"AppConfig",
|
||||
"AppSettings",
|
||||
"ConflictMode",
|
||||
"DeleteMode",
|
||||
"SyncDirection",
|
||||
"SyncTask",
|
||||
"TaskRuntimeState",
|
||||
"TriggerMode",
|
||||
]
|
||||
|
||||
|
||||
class SyncDirection(StrEnum):
|
||||
"""Unterstützte Synchronisationsrichtungen."""
|
||||
|
||||
ONE_WAY = "one_way"
|
||||
BIDIRECTIONAL = "bidirectional"
|
||||
|
||||
|
||||
class TriggerMode(StrEnum):
|
||||
"""Unterstützte Auslöser für eine Aufgabe."""
|
||||
|
||||
REALTIME = "realtime"
|
||||
INTERVAL = "interval"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class DeleteMode(StrEnum):
|
||||
"""Verhalten bei erkannten Löschungen."""
|
||||
|
||||
MIRROR = "mirror"
|
||||
KEEP = "keep"
|
||||
|
||||
|
||||
class ConflictMode(StrEnum):
|
||||
"""Verhalten bei beidseitig geänderten Dateien."""
|
||||
|
||||
NEWER_WINS = "newer_wins"
|
||||
CONFLICT_COPY = "conflict_copy"
|
||||
|
||||
|
||||
class TaskRuntimeState(StrEnum):
|
||||
"""Nicht persistierter, tatsächlicher Laufzeitzustand einer Aufgabe."""
|
||||
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
WAITING_FOR_RELEASE = "waiting_for_release"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SyncTask:
|
||||
"""Persistierbare Konfiguration eines Ordnerpaars."""
|
||||
|
||||
name: str
|
||||
source: str
|
||||
target: str
|
||||
id: str = field(default_factory=lambda: str(uuid4()))
|
||||
direction: SyncDirection = SyncDirection.ONE_WAY
|
||||
trigger: TriggerMode = TriggerMode.MANUAL
|
||||
delete_mode: DeleteMode = DeleteMode.KEEP
|
||||
conflict_mode: ConflictMode = ConflictMode.NEWER_WINS
|
||||
interval_minutes: int = 30
|
||||
selected_paths: list[str] = field(default_factory=list)
|
||||
enabled: bool = True
|
||||
last_run: str | None = None
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validiert sicherheitsrelevante Aufgabeneigenschaften frühzeitig."""
|
||||
if not self.name.strip():
|
||||
raise ValueError("Bitte einen Namen für die Aufgabe eingeben.")
|
||||
if not self.source.strip() or not self.target.strip():
|
||||
raise ValueError("Quell- und Zielordner müssen angegeben werden.")
|
||||
source = Path(self.source).expanduser().resolve(strict=False)
|
||||
target = Path(self.target).expanduser().resolve(strict=False)
|
||||
source_norm = os.path.normcase(str(source))
|
||||
target_norm = os.path.normcase(str(target))
|
||||
if source_norm == target_norm:
|
||||
raise ValueError("Quell- und Zielordner dürfen nicht identisch sein.")
|
||||
if _is_relative_to(source, target) or _is_relative_to(target, source):
|
||||
raise ValueError("Quell- und Zielordner dürfen nicht ineinander liegen.")
|
||||
if not 1 <= self.interval_minutes <= 10080:
|
||||
raise ValueError("Das Intervall muss zwischen 1 Minute und 7 Tagen liegen.")
|
||||
self.name = self.name.strip()
|
||||
self.source = str(source)
|
||||
self.target = str(target)
|
||||
self.selected_paths = sorted({_normalize_relative_path(item) for item in self.selected_paths if item.strip()})
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialisiert die Aufgabe in ein JSON-kompatibles Dictionary."""
|
||||
data = asdict(self)
|
||||
data["direction"] = self.direction.value
|
||||
data["trigger"] = self.trigger.value
|
||||
data["delete_mode"] = self.delete_mode.value
|
||||
data["conflict_mode"] = self.conflict_mode.value
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> SyncTask:
|
||||
"""Erzeugt eine Aufgabe aus persistierten Daten."""
|
||||
task = cls(
|
||||
id=str(data.get("id") or uuid4()),
|
||||
name=str(data.get("name", "Unbenannte Aufgabe")),
|
||||
source=str(data.get("source", "")),
|
||||
target=str(data.get("target", "")),
|
||||
direction=SyncDirection(data.get("direction", SyncDirection.ONE_WAY.value)),
|
||||
trigger=TriggerMode(data.get("trigger", TriggerMode.MANUAL.value)),
|
||||
delete_mode=DeleteMode(data.get("delete_mode", DeleteMode.KEEP.value)),
|
||||
conflict_mode=ConflictMode(data.get("conflict_mode", ConflictMode.NEWER_WINS.value)),
|
||||
interval_minutes=int(data.get("interval_minutes", 30)),
|
||||
selected_paths=[str(item) for item in data.get("selected_paths", [])],
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
last_run=data.get("last_run"),
|
||||
)
|
||||
task.validate()
|
||||
return task
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppSettings:
|
||||
"""Globale, persistierbare Anwendungseinstellungen."""
|
||||
|
||||
autostart: bool = False
|
||||
font_family: str = "Segoe UI Variable"
|
||||
font_size: int = 10
|
||||
font_weight: str = "regular"
|
||||
ui_scale: int = 100
|
||||
paused: bool = False
|
||||
check_updates_on_start: bool = True
|
||||
last_update_check: str | None = None
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Begrenzt Darstellungswerte auf brauchbare Bereiche."""
|
||||
self.font_size = max(8, min(18, int(self.font_size)))
|
||||
self.ui_scale = max(80, min(150, int(self.ui_scale)))
|
||||
if self.font_weight not in {"regular", "bold"}:
|
||||
self.font_weight = "regular"
|
||||
if not self.font_family.strip():
|
||||
self.font_family = "Segoe UI Variable"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialisiert die Einstellungen."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> AppSettings:
|
||||
"""Erzeugt Einstellungen aus einem Dictionary."""
|
||||
settings = cls(
|
||||
autostart=bool(data.get("autostart", False)),
|
||||
font_family=str(data.get("font_family", "Segoe UI Variable")),
|
||||
font_size=int(data.get("font_size", 10)),
|
||||
font_weight=str(data.get("font_weight", "regular")),
|
||||
ui_scale=int(data.get("ui_scale", 100)),
|
||||
paused=bool(data.get("paused", False)),
|
||||
check_updates_on_start=bool(data.get("check_updates_on_start", True)),
|
||||
last_update_check=data.get("last_update_check"),
|
||||
)
|
||||
settings.validate()
|
||||
return settings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppConfig:
|
||||
"""Wurzelobjekt der lokalen JSON-Konfiguration."""
|
||||
|
||||
tasks: list[SyncTask] = field(default_factory=list)
|
||||
settings: AppSettings = field(default_factory=AppSettings)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialisiert die vollständige Konfiguration."""
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"tasks": [task.to_dict() for task in self.tasks],
|
||||
"settings": self.settings.to_dict(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> AppConfig:
|
||||
"""Erzeugt eine vollständige Konfiguration aus JSON-Daten."""
|
||||
return cls(
|
||||
tasks=[SyncTask.from_dict(item) for item in data.get("tasks", [])],
|
||||
settings=AppSettings.from_dict(data.get("settings", {})),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_relative_path(value: str) -> str:
|
||||
normalized = value.replace("/", os.sep).replace("\\", os.sep).strip(os.sep)
|
||||
path = Path(normalized)
|
||||
if path.is_absolute() or ".." in path.parts:
|
||||
raise ValueError(f"Ungültige Unterauswahl: {value}")
|
||||
return str(path)
|
||||
|
||||
|
||||
def _is_relative_to(path: Path, base: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(base)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Watchdog-Adapter für Echtzeit-Aufgaben."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from syncvito.models import SyncDirection, SyncTask, TriggerMode
|
||||
|
||||
__all__ = ["RealtimeMonitor"]
|
||||
|
||||
|
||||
class _TaskEventHandler(FileSystemEventHandler):
|
||||
def __init__(self, task_id: str, callback: Callable[[str], None]) -> None:
|
||||
self.task_id = task_id
|
||||
self.callback = callback
|
||||
|
||||
def on_any_event(self, event: FileSystemEvent) -> None:
|
||||
if not event.is_directory or event.event_type in {"created", "deleted", "moved", "modified"}:
|
||||
self.callback(self.task_id)
|
||||
|
||||
|
||||
class RealtimeMonitor:
|
||||
"""Verwaltet einen Watchdog-Observer für alle Echtzeit-Aufgaben."""
|
||||
|
||||
def __init__(self, callback: Callable[[str], None]) -> None:
|
||||
self.callback = callback
|
||||
self._observer: Observer | None = None
|
||||
|
||||
def rebuild(self, tasks: list[SyncTask]) -> list[tuple[str, str]]:
|
||||
"""Startet die Überwachung passend zur aktuellen Aufgabenliste neu."""
|
||||
self.stop()
|
||||
observer = Observer()
|
||||
errors: list[tuple[str, str]] = []
|
||||
scheduled = False
|
||||
for task in tasks:
|
||||
if not task.enabled or task.trigger is not TriggerMode.REALTIME:
|
||||
continue
|
||||
handler = _TaskEventHandler(task.id, self.callback)
|
||||
paths = [Path(task.source)]
|
||||
if task.direction is SyncDirection.BIDIRECTIONAL:
|
||||
paths.append(Path(task.target))
|
||||
for path in paths:
|
||||
if not path.is_dir():
|
||||
errors.append((task.id, f"Echtzeitüberwachung nicht möglich: {path}"))
|
||||
continue
|
||||
try:
|
||||
observer.schedule(handler, str(path), recursive=True)
|
||||
scheduled = True
|
||||
except OSError as exc:
|
||||
errors.append((task.id, f"Überwachung fehlgeschlagen ({path}): {exc}"))
|
||||
if scheduled:
|
||||
observer.start()
|
||||
self._observer = observer
|
||||
return errors
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Beendet den laufenden Observer sauber."""
|
||||
if self._observer is not None:
|
||||
self._observer.stop()
|
||||
self._observer.join(timeout=3)
|
||||
self._observer = None
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Persistenter Abgleichzustand für sichere bidirektionale Löschungen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from syncvito.config_store import default_data_directory
|
||||
|
||||
__all__ = ["StateStore"]
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Speichert Dateisignaturen getrennt von der Benutzerkonfiguration."""
|
||||
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = path or default_data_directory() / "sync-state.json"
|
||||
self._data: dict[str, dict[str, dict[str, str | None]]] = {}
|
||||
self._load()
|
||||
|
||||
def get(self, task_id: str) -> dict[str, dict[str, str | None]]:
|
||||
"""Liefert den zuletzt erfolgreichen Zustand einer Aufgabe."""
|
||||
return dict(self._data.get(task_id, {}))
|
||||
|
||||
def set(self, task_id: str, state: dict[str, dict[str, str | None]]) -> None:
|
||||
"""Aktualisiert und persistiert den Zustand einer Aufgabe."""
|
||||
self._data[task_id] = state
|
||||
self._save()
|
||||
|
||||
def remove(self, task_id: str) -> None:
|
||||
"""Entfernt den Zustand einer gelöschten Aufgabe."""
|
||||
self._data.pop(task_id, None)
|
||||
self._save()
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.path.exists():
|
||||
return
|
||||
try:
|
||||
raw: Any = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
self._data = raw
|
||||
except (OSError, json.JSONDecodeError):
|
||||
self._data = {}
|
||||
|
||||
def _save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.path.with_suffix(".json.tmp")
|
||||
temporary.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(temporary, self.path)
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Dateisystem-Synchronisation ohne Abhängigkeit von der Benutzeroberfläche."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from syncvito.file_locks import LockingProcess, find_locking_processes
|
||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
||||
from syncvito.state_store import StateStore
|
||||
|
||||
__all__ = [
|
||||
"BlockedFile",
|
||||
"ProgressCallback",
|
||||
"SyncEngine",
|
||||
"SyncResult",
|
||||
"UnavailablePathError",
|
||||
]
|
||||
|
||||
ProgressCallback = Callable[[int, str], None]
|
||||
|
||||
|
||||
class UnavailablePathError(RuntimeError):
|
||||
"""Ein für die Synchronisation benötigter Pfad ist nicht erreichbar."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FileRecord:
|
||||
"""Kompakte Dateisystemmetadaten für Vergleiche."""
|
||||
|
||||
path: Path
|
||||
size: int
|
||||
mtime_ns: int
|
||||
|
||||
@property
|
||||
def signature(self) -> str:
|
||||
"""Liefert eine persistierbare Signatur."""
|
||||
return f"{self.size}:{self.mtime_ns}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BlockedFile:
|
||||
"""Eine zurückgestellte Datei einschließlich bekannter Sperrprozesse."""
|
||||
|
||||
path: Path
|
||||
processes: tuple[LockingProcess, ...] = ()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SyncResult:
|
||||
"""Zusammenfassung eines vollständigen Aufgabenlaufs."""
|
||||
|
||||
copied: int = 0
|
||||
deleted: int = 0
|
||||
conflicts: int = 0
|
||||
skipped: int = 0
|
||||
blocked: list[BlockedFile] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
inaccessible: bool = False
|
||||
|
||||
@property
|
||||
def successful(self) -> bool:
|
||||
"""Zeigt an, ob der Lauf ohne Fehler und Sperren abgeschlossen wurde."""
|
||||
return not self.errors and not self.blocked and not self.inaccessible
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Operation:
|
||||
kind: str
|
||||
source: Path | None
|
||||
target: Path
|
||||
relative: str
|
||||
conflict_copy: bool = False
|
||||
|
||||
|
||||
class SyncEngine:
|
||||
"""Plant und verarbeitet Einweg- sowie bidirektionale Aufgaben."""
|
||||
|
||||
def __init__(self, state_store: StateStore | None = None) -> None:
|
||||
self.state_store = state_store or StateStore()
|
||||
|
||||
def synchronize(self, task: SyncTask, progress: ProgressCallback | None = None) -> SyncResult:
|
||||
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
|
||||
task.validate()
|
||||
source_root = Path(task.source)
|
||||
target_root = Path(task.target)
|
||||
self._validate_roots(source_root, target_root)
|
||||
source_files, source_dirs = self._inventory(source_root, task.selected_paths)
|
||||
target_files, target_dirs = self._inventory(target_root, task.selected_paths)
|
||||
previous = self.state_store.get(task.id)
|
||||
|
||||
if task.direction is SyncDirection.ONE_WAY:
|
||||
operations = self._plan_one_way(
|
||||
task, source_root, target_root, source_files, target_files, source_dirs, target_dirs
|
||||
)
|
||||
else:
|
||||
operations = self._plan_bidirectional(
|
||||
task,
|
||||
source_root,
|
||||
target_root,
|
||||
source_files,
|
||||
target_files,
|
||||
source_dirs,
|
||||
target_dirs,
|
||||
previous,
|
||||
)
|
||||
|
||||
result = SyncResult()
|
||||
total = max(1, len(operations))
|
||||
if progress:
|
||||
progress(0, "Änderungen werden vorbereitet …")
|
||||
for index, operation in enumerate(operations, start=1):
|
||||
try:
|
||||
self._execute(operation, result)
|
||||
except PermissionError as exc:
|
||||
processes = tuple(find_locking_processes(operation.target))
|
||||
result.blocked.append(BlockedFile(operation.target, processes))
|
||||
result.errors.append(f"Zugriff verweigert: {operation.target} ({exc})")
|
||||
except (OSError, shutil.Error) as exc:
|
||||
result.errors.append(f"{operation.target}: {exc}")
|
||||
if progress:
|
||||
detail = f"{operation.kind}: {operation.relative}"
|
||||
progress(round(index / total * 100), detail)
|
||||
|
||||
if not operations and progress:
|
||||
progress(100, "Keine Änderungen gefunden")
|
||||
|
||||
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
|
||||
refreshed_source, _ = self._inventory(source_root, task.selected_paths)
|
||||
refreshed_target, _ = self._inventory(target_root, task.selected_paths)
|
||||
self.state_store.set(task.id, self._state_from_inventories(refreshed_source, refreshed_target))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _validate_roots(source_root: Path, target_root: Path) -> None:
|
||||
if not source_root.is_dir():
|
||||
raise UnavailablePathError(f"Quellordner nicht erreichbar: {source_root}")
|
||||
if not target_root.exists():
|
||||
try:
|
||||
target_root.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise UnavailablePathError(f"Zielordner nicht erreichbar: {target_root}") from exc
|
||||
if not target_root.is_dir():
|
||||
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
|
||||
|
||||
@staticmethod
|
||||
def _inventory(root: Path, selected_paths: list[str]) -> tuple[dict[str, FileRecord], set[str]]:
|
||||
files: dict[str, FileRecord] = {}
|
||||
directories: set[str] = set()
|
||||
selections = tuple(Path(item).parts for item in selected_paths)
|
||||
try:
|
||||
for current, dir_names, file_names in os.walk(root):
|
||||
current_path = Path(current)
|
||||
relative_dir = current_path.relative_to(root)
|
||||
dir_names[:] = [name for name in dir_names if not (current_path / name).is_symlink()]
|
||||
for directory_name in dir_names:
|
||||
relative = relative_dir / directory_name
|
||||
if _is_selected(relative.parts, selections):
|
||||
directories.add(str(relative))
|
||||
for file_name in file_names:
|
||||
path = current_path / file_name
|
||||
if path.is_symlink():
|
||||
continue
|
||||
relative = path.relative_to(root)
|
||||
if not _is_selected(relative.parts, selections):
|
||||
continue
|
||||
try:
|
||||
file_stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
files[str(relative)] = FileRecord(path, file_stat.st_size, file_stat.st_mtime_ns)
|
||||
except OSError as exc:
|
||||
raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc
|
||||
return files, directories
|
||||
|
||||
@staticmethod
|
||||
def _plan_one_way(
|
||||
task: SyncTask,
|
||||
source_root: Path,
|
||||
target_root: Path,
|
||||
source_files: dict[str, FileRecord],
|
||||
target_files: dict[str, FileRecord],
|
||||
source_dirs: set[str],
|
||||
target_dirs: set[str],
|
||||
) -> list[_Operation]:
|
||||
operations: list[_Operation] = []
|
||||
for relative in sorted(source_dirs - target_dirs):
|
||||
operations.append(_Operation("Ordner anlegen", None, target_root / relative, relative))
|
||||
for relative, source in sorted(source_files.items()):
|
||||
target = target_files.get(relative)
|
||||
if target is None or target.signature != source.signature:
|
||||
operations.append(_Operation("Kopieren", source.path, target_root / relative, relative))
|
||||
if task.delete_mode is DeleteMode.MIRROR:
|
||||
for relative in sorted(target_files.keys() - source_files.keys()):
|
||||
operations.append(_Operation("Löschen", None, target_root / relative, relative))
|
||||
for relative in sorted(target_dirs - source_dirs, key=lambda item: len(Path(item).parts), reverse=True):
|
||||
operations.append(_Operation("Ordner löschen", None, target_root / relative, relative))
|
||||
return operations
|
||||
|
||||
@staticmethod
|
||||
def _plan_bidirectional(
|
||||
task: SyncTask,
|
||||
source_root: Path,
|
||||
target_root: Path,
|
||||
source_files: dict[str, FileRecord],
|
||||
target_files: dict[str, FileRecord],
|
||||
source_dirs: set[str],
|
||||
target_dirs: set[str],
|
||||
previous: dict[str, dict[str, str | None]],
|
||||
) -> list[_Operation]:
|
||||
operations = [
|
||||
_Operation("Ordner anlegen", None, target_root / relative, relative)
|
||||
for relative in sorted(source_dirs - target_dirs)
|
||||
]
|
||||
operations.extend(
|
||||
_Operation("Ordner anlegen", None, source_root / relative, relative)
|
||||
for relative in sorted(target_dirs - source_dirs)
|
||||
)
|
||||
all_paths = sorted(source_files.keys() | target_files.keys() | previous.keys())
|
||||
for relative in all_paths:
|
||||
source = source_files.get(relative)
|
||||
target = target_files.get(relative)
|
||||
old = previous.get(relative, {})
|
||||
old_source = old.get("source")
|
||||
old_target = old.get("target")
|
||||
if source and target:
|
||||
if source.signature == target.signature:
|
||||
continue
|
||||
winner, destination = _newer_pair(source, target)
|
||||
conflict = task.conflict_mode is ConflictMode.CONFLICT_COPY
|
||||
operations.append(
|
||||
_Operation(
|
||||
"Konflikt lösen" if conflict else "Aktualisieren",
|
||||
winner.path,
|
||||
destination.path,
|
||||
relative,
|
||||
conflict,
|
||||
)
|
||||
)
|
||||
elif source:
|
||||
target_was_deleted = old_target is not None and source.signature == old_source
|
||||
if task.delete_mode is DeleteMode.MIRROR and target_was_deleted:
|
||||
operations.append(_Operation("Löschung spiegeln", None, source.path, relative))
|
||||
else:
|
||||
operations.append(_Operation("Kopieren", source.path, target_root / relative, relative))
|
||||
elif target:
|
||||
source_was_deleted = old_source is not None and target.signature == old_target
|
||||
if task.delete_mode is DeleteMode.MIRROR and source_was_deleted:
|
||||
operations.append(_Operation("Löschung spiegeln", None, target.path, relative))
|
||||
else:
|
||||
operations.append(_Operation("Kopieren", target.path, source_root / relative, relative))
|
||||
return operations
|
||||
|
||||
@staticmethod
|
||||
def _execute(operation: _Operation, result: SyncResult) -> None:
|
||||
if operation.kind == "Ordner anlegen":
|
||||
operation.target.mkdir(parents=True, exist_ok=True)
|
||||
return
|
||||
if operation.kind in {"Löschen", "Löschung spiegeln"}:
|
||||
operation.target.unlink(missing_ok=True)
|
||||
result.deleted += 1
|
||||
return
|
||||
if operation.kind == "Ordner löschen":
|
||||
try:
|
||||
operation.target.rmdir()
|
||||
result.deleted += 1
|
||||
except OSError:
|
||||
result.skipped += 1
|
||||
return
|
||||
if operation.source is None:
|
||||
return
|
||||
operation.target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if operation.conflict_copy and operation.target.exists():
|
||||
conflict_path = _conflict_path(operation.target)
|
||||
shutil.copy2(operation.target, conflict_path)
|
||||
result.conflicts += 1
|
||||
_atomic_copy(operation.source, operation.target)
|
||||
result.copied += 1
|
||||
|
||||
@staticmethod
|
||||
def _state_from_inventories(
|
||||
source_files: dict[str, FileRecord], target_files: dict[str, FileRecord]
|
||||
) -> dict[str, dict[str, str | None]]:
|
||||
return {
|
||||
relative: {
|
||||
"source": source_files[relative].signature if relative in source_files else None,
|
||||
"target": target_files[relative].signature if relative in target_files else None,
|
||||
}
|
||||
for relative in source_files.keys() | target_files.keys()
|
||||
}
|
||||
|
||||
|
||||
def _atomic_copy(source: Path, target: Path) -> None:
|
||||
temporary = target.with_name(f".{target.name}.syncvito.tmp")
|
||||
try:
|
||||
shutil.copy2(source, temporary)
|
||||
os.replace(temporary, target)
|
||||
except BaseException:
|
||||
with suppress(OSError):
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _conflict_path(path: Path) -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
candidate = path.with_name(f"{path.stem}_conflict_{timestamp}{path.suffix}")
|
||||
counter = 2
|
||||
while candidate.exists():
|
||||
candidate = path.with_name(f"{path.stem}_conflict_{timestamp}_{counter}{path.suffix}")
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _newer_pair(left: FileRecord, right: FileRecord) -> tuple[FileRecord, FileRecord]:
|
||||
if left.mtime_ns >= right.mtime_ns:
|
||||
return left, right
|
||||
return right, left
|
||||
|
||||
|
||||
def _is_selected(parts: tuple[str, ...], selections: tuple[tuple[str, ...], ...]) -> bool:
|
||||
if not selections:
|
||||
return True
|
||||
return any(parts[: len(selection)] == selection or selection[: len(parts)] == parts for selection in selections)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Zentrales dunkles Designsystem der Qt-Oberfläche."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtGui import QFont
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from syncvito.models import AppSettings
|
||||
|
||||
__all__ = ["apply_theme"]
|
||||
|
||||
|
||||
def apply_theme(application: QApplication, settings: AppSettings) -> None:
|
||||
"""Wendet Typografie und semantische Farbtoken global an."""
|
||||
scale = settings.ui_scale / 100
|
||||
font = QFont(settings.font_family, max(8, round(settings.font_size * scale)))
|
||||
font.setWeight(QFont.Weight.Bold if settings.font_weight == "bold" else QFont.Weight.Normal)
|
||||
application.setFont(font)
|
||||
application.setStyleSheet(_stylesheet(scale))
|
||||
|
||||
|
||||
def _stylesheet(scale: float) -> str:
|
||||
radius = round(8 * scale)
|
||||
padding = round(8 * scale)
|
||||
return f"""
|
||||
* {{ outline: none; }}
|
||||
QMainWindow, QDialog {{ background: #0B0F14; color: #E8EDF4; }}
|
||||
QWidget {{ color: #E8EDF4; selection-background-color: #2878FF; selection-color: #FFFFFF; }}
|
||||
QWidget#Sidebar {{ background: #10161F; border-right: 1px solid #263241; }}
|
||||
QWidget#Header {{ background: #0B0F14; border-bottom: 1px solid #202B38; }}
|
||||
QWidget#ProgressDock {{ background: #10161F; border-top: 1px solid #263241; }}
|
||||
QFrame[card="true"] {{ background: #121923; border: 1px solid #263241; border-radius: {radius}px; }}
|
||||
QLabel#Brand {{ color: #FFFFFF; font-size: {round(18 * scale)}px; font-weight: 700; }}
|
||||
QLabel#Eyebrow {{ color: #6F8299; font-size: {round(10 * scale)}px; font-weight: 600; }}
|
||||
QLabel#PageTitle {{ color: #F4F7FB; font-size: {round(23 * scale)}px; font-weight: 700; }}
|
||||
QLabel#PageSubtitle, QLabel[muted="true"] {{ color: #9CACBF; }}
|
||||
QLabel#StatusPill {{ background: #162131; border: 1px solid #304158; border-radius: {round(13 * scale)}px;
|
||||
min-height: {round(18 * scale)}px; max-height: {round(18 * scale)}px;
|
||||
padding: {round(3 * scale)}px {round(10 * scale)}px; font-weight: 600; }}
|
||||
QLabel#LockBanner {{ background: #261C10; color: #FFD18A; border: 1px solid #6B4C21;
|
||||
border-radius: {radius}px; padding: {padding}px {round(12 * scale)}px; }}
|
||||
QPushButton {{ background: #192332; border: 1px solid #34455B; border-radius: {radius}px;
|
||||
padding: {padding}px {round(14 * scale)}px; min-height: {round(20 * scale)}px; font-weight: 600; }}
|
||||
QPushButton:hover {{ background: #223044; border-color: #506781; }}
|
||||
QPushButton:pressed {{ background: #131B27; }}
|
||||
QPushButton:focus {{ border: 2px solid #5B9CFF; padding: {padding - 1}px {round(13 * scale)}px; }}
|
||||
QPushButton:disabled {{ color: #627188; background: #121922; border-color: #283342; }}
|
||||
QPushButton[primary="true"] {{ background: #2878FF; border-color: #448AFF; color: #FFFFFF; }}
|
||||
QPushButton[primary="true"]:hover {{ background: #3C86FF; }}
|
||||
QPushButton[danger="true"] {{ color: #FFB6B6; border-color: #653A42; background: #24171B; }}
|
||||
QPushButton[nav="true"] {{ text-align: left; border-color: transparent; background: transparent;
|
||||
padding: {round(10 * scale)}px {round(12 * scale)}px; color: #9CACBF; }}
|
||||
QPushButton[nav="true"]:hover {{ background: #17212E; color: #E8EDF4; }}
|
||||
QPushButton[nav="true"]:checked {{ background: #1B2A3D; color: #FFFFFF; border-color: #2A568B; }}
|
||||
QLineEdit, QComboBox, QSpinBox, QListWidget, QTreeView, QTableWidget {{
|
||||
background: #0E141D; border: 1px solid #304052; border-radius: {radius}px;
|
||||
padding: {round(7 * scale)}px; color: #E8EDF4;
|
||||
}}
|
||||
QTextBrowser {{ background: #0E141D; border: 1px solid #304052; border-radius: {radius}px;
|
||||
padding: {round(8 * scale)}px; color: #DDE5EF; }}
|
||||
QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QListWidget:focus, QTreeView:focus, QTableWidget:focus {{
|
||||
border: 2px solid #5B9CFF;
|
||||
}}
|
||||
QComboBox::drop-down {{ border: none; width: {round(26 * scale)}px; }}
|
||||
QComboBox QAbstractItemView {{ background: #151E2A; border: 1px solid #34455B; selection-background-color: #2878FF; }}
|
||||
QHeaderView::section {{ background: #131B26; color: #91A2B7; border: none; border-bottom: 1px solid #2A3747;
|
||||
padding: {round(9 * scale)}px; font-weight: 600; }}
|
||||
QTableWidget {{ gridline-color: #202B38; alternate-background-color: #101721; }}
|
||||
QTableWidget::item {{ padding: {round(7 * scale)}px; border-bottom: 1px solid #202B38; }}
|
||||
QTableWidget::item:selected {{ background: #1E4E86; }}
|
||||
QProgressBar {{ background: #182230; border: 1px solid #304052; border-radius: {round(7 * scale)}px;
|
||||
text-align: center; color: #FFFFFF; min-height: {round(18 * scale)}px; font-weight: 700; }}
|
||||
QProgressBar::chunk {{ background: #2878FF; border-radius: {round(6 * scale)}px; }}
|
||||
QTabWidget::pane {{ border: 1px solid #263241; border-radius: {radius}px; top: -1px; }}
|
||||
QTabBar::tab {{ background: #111923; padding: {padding}px {round(14 * scale)}px; color: #96A7BA; }}
|
||||
QTabBar::tab:selected {{ background: #1A2635; color: #FFFFFF; }}
|
||||
QCheckBox {{ spacing: {round(8 * scale)}px; }}
|
||||
QCheckBox::indicator {{ width: {round(16 * scale)}px; height: {round(16 * scale)}px;
|
||||
border: 1px solid #50647C; border-radius: {round(4 * scale)}px; background: #0D131B; }}
|
||||
QCheckBox::indicator:checked {{ background: #2878FF; border-color: #72A8FF; }}
|
||||
QScrollBar:vertical {{ background: #0E141D; width: {round(11 * scale)}px; margin: 0; }}
|
||||
QScrollBar::handle:vertical {{ background: #35465A; border-radius: {round(5 * scale)}px; min-height: 24px; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
QToolTip {{ background: #1B2635; color: #FFFFFF; border: 1px solid #4B6078; padding: 5px; }}
|
||||
"""
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Qt-Worker für nicht blockierende Updateprüfungen und Downloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal
|
||||
|
||||
from syncvito.updater import GiteaUpdateService, UpdateError, UpdateInfo
|
||||
|
||||
__all__ = ["UpdateController"]
|
||||
|
||||
|
||||
class _CheckSignals(QObject):
|
||||
finished = pyqtSignal(object, bool)
|
||||
failed = pyqtSignal(str, bool)
|
||||
|
||||
|
||||
class _CheckWorker(QRunnable):
|
||||
def __init__(self, service: GiteaUpdateService, manual: bool) -> None:
|
||||
super().__init__()
|
||||
self.service = service
|
||||
self.manual = manual
|
||||
self.signals = _CheckSignals()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
info = self.service.check()
|
||||
except UpdateError as exc:
|
||||
self.signals.failed.emit(str(exc), self.manual)
|
||||
else:
|
||||
self.signals.finished.emit(info, self.manual)
|
||||
|
||||
|
||||
class _DownloadSignals(QObject):
|
||||
progress = pyqtSignal(int, str)
|
||||
finished = pyqtSignal(object, object)
|
||||
failed = pyqtSignal(str, bool)
|
||||
|
||||
|
||||
class _DownloadWorker(QRunnable):
|
||||
def __init__(self, service: GiteaUpdateService, info: UpdateInfo) -> None:
|
||||
super().__init__()
|
||||
self.service = service
|
||||
self.info = info
|
||||
self.signals = _DownloadSignals()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
path = self.service.download(self.info, self.signals.progress.emit)
|
||||
except UpdateError as exc:
|
||||
self.signals.failed.emit(str(exc), True)
|
||||
else:
|
||||
self.signals.finished.emit(path, self.info)
|
||||
|
||||
|
||||
class UpdateController(QObject):
|
||||
"""Serialisiert Updateaktionen und überträgt Ergebnisse in den UI-Thread."""
|
||||
|
||||
update_available = pyqtSignal(object, bool)
|
||||
no_update = pyqtSignal(bool)
|
||||
error = pyqtSignal(str, bool)
|
||||
progress = pyqtSignal(int, str)
|
||||
install_ready = pyqtSignal(object, object)
|
||||
busy_changed = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, current_version: str, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.service = GiteaUpdateService(current_version)
|
||||
self.pool = QThreadPool.globalInstance()
|
||||
self._busy = False
|
||||
|
||||
@property
|
||||
def busy(self) -> bool:
|
||||
"""Zeigt an, ob eine Updateaktion läuft."""
|
||||
return self._busy
|
||||
|
||||
def check(self, manual: bool = False) -> None:
|
||||
"""Startet eine Hintergrundprüfung, sofern keine Aktion läuft."""
|
||||
if self._busy:
|
||||
return
|
||||
self._set_busy(True)
|
||||
worker = _CheckWorker(self.service, manual)
|
||||
worker.signals.finished.connect(self._check_finished)
|
||||
worker.signals.failed.connect(self._failed)
|
||||
self.pool.start(worker)
|
||||
|
||||
def download(self, info: UpdateInfo) -> None:
|
||||
"""Lädt ein zuvor validiertes Release im Hintergrund."""
|
||||
if self._busy:
|
||||
return
|
||||
self._set_busy(True)
|
||||
worker = _DownloadWorker(self.service, info)
|
||||
worker.signals.progress.connect(self.progress)
|
||||
worker.signals.finished.connect(self._download_finished)
|
||||
worker.signals.failed.connect(self._failed)
|
||||
self.pool.start(worker)
|
||||
|
||||
def _check_finished(self, info: UpdateInfo | None, manual: bool) -> None:
|
||||
self._set_busy(False)
|
||||
if info is None:
|
||||
self.no_update.emit(manual)
|
||||
else:
|
||||
self.update_available.emit(info, manual)
|
||||
|
||||
def _download_finished(self, path: object, info: object) -> None:
|
||||
self._set_busy(False)
|
||||
self.install_ready.emit(path, info)
|
||||
|
||||
def _failed(self, message: str, manual: bool) -> None:
|
||||
self._set_busy(False)
|
||||
self.error.emit(message, manual)
|
||||
|
||||
def _set_busy(self, busy: bool) -> None:
|
||||
self._busy = busy
|
||||
self.busy_changed.emit(busy)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Kompakter Dialog für verfügbare Programmupdates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from syncvito.updater import UpdateInfo
|
||||
|
||||
__all__ = ["UpdateDialog"]
|
||||
|
||||
|
||||
class UpdateDialog(QDialog):
|
||||
"""Zeigt Versionsdaten und fordert eine explizite Installation an."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_version: str,
|
||||
update: UpdateInfo,
|
||||
install_supported: bool,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("SyncVito-Update")
|
||||
self.setMinimumWidth(590)
|
||||
self.resize(640, 470)
|
||||
|
||||
eyebrow = QLabel("NEUE VERSION VERFÜGBAR")
|
||||
eyebrow.setObjectName("Eyebrow")
|
||||
title = QLabel(update.title)
|
||||
title.setObjectName("PageTitle")
|
||||
version_text = QLabel(f"Installiert: {current_version} → Verfügbar: {update.version}")
|
||||
version_text.setProperty("muted", True)
|
||||
|
||||
notes_frame = QFrame()
|
||||
notes_frame.setProperty("card", True)
|
||||
notes_layout = QVBoxLayout(notes_frame)
|
||||
notes_layout.setContentsMargins(16, 16, 16, 16)
|
||||
notes_layout.addWidget(QLabel("Versionshinweise"))
|
||||
notes = QTextBrowser()
|
||||
notes.setPlainText(update.notes)
|
||||
notes.setOpenExternalLinks(False)
|
||||
notes_layout.addWidget(notes, 1)
|
||||
|
||||
notice = QLabel(
|
||||
"SyncVito lädt die neue EXE herunter, prüft ihre SHA-256-Summe und startet anschließend neu. "
|
||||
"Aufgaben und Einstellungen bleiben erhalten."
|
||||
if install_supported
|
||||
else "Die Updateinstallation ist nur in der gebauten Windows-EXE verfügbar."
|
||||
)
|
||||
notice.setWordWrap(True)
|
||||
notice.setProperty("muted", True)
|
||||
|
||||
buttons = QDialogButtonBox()
|
||||
later_button = buttons.addButton("Später", QDialogButtonBox.ButtonRole.RejectRole)
|
||||
install_text = "Herunterladen und installieren" if install_supported else "Schließen"
|
||||
install_button = buttons.addButton(install_text, QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
install_button.setProperty("primary", True)
|
||||
later_button.clicked.connect(self.reject)
|
||||
install_button.clicked.connect(self.accept if install_supported else self.reject)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(22, 22, 22, 22)
|
||||
layout.setSpacing(12)
|
||||
header = QHBoxLayout()
|
||||
header_text = QVBoxLayout()
|
||||
header_text.addWidget(eyebrow)
|
||||
header_text.addWidget(title)
|
||||
header_text.addWidget(version_text)
|
||||
header.addLayout(header_text)
|
||||
header.addStretch(1)
|
||||
layout.addLayout(header)
|
||||
layout.addWidget(notes_frame, 1)
|
||||
layout.addWidget(notice)
|
||||
layout.addWidget(buttons)
|
||||
@@ -0,0 +1,286 @@
|
||||
"""Gitea-Release-Abfrage, geprüfter Download und sicherer EXE-Austausch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from syncvito.config_store import default_data_directory
|
||||
|
||||
__all__ = [
|
||||
"GiteaUpdateService",
|
||||
"UpdateError",
|
||||
"UpdateFormatError",
|
||||
"UpdateInfo",
|
||||
"UpdateInstallError",
|
||||
"UpdateIntegrityError",
|
||||
"UpdateNetworkError",
|
||||
"parse_version",
|
||||
]
|
||||
|
||||
GITEA_BASE_URL = "https://gitea.davito.synology.me"
|
||||
GITEA_OWNER = "Davito"
|
||||
GITEA_REPOSITORY = "SyncVito"
|
||||
APPLICATION_ASSET = "SyncVito.exe"
|
||||
CHECKSUM_ASSET = "SyncVito.exe.sha256"
|
||||
MAX_DOWNLOAD_BYTES = 250 * 1024 * 1024
|
||||
USER_AGENT = "SyncVito-Updater/1.0"
|
||||
ProgressCallback = Callable[[int, str], None]
|
||||
|
||||
|
||||
class UpdateError(RuntimeError):
|
||||
"""Basisklasse für erwartete Updatefehler."""
|
||||
|
||||
|
||||
class UpdateNetworkError(UpdateError):
|
||||
"""Gitea oder ein Release-Artefakt war nicht erreichbar."""
|
||||
|
||||
|
||||
class UpdateFormatError(UpdateError):
|
||||
"""Release-Metadaten waren unvollständig oder ungültig."""
|
||||
|
||||
|
||||
class UpdateIntegrityError(UpdateError):
|
||||
"""Die heruntergeladene Datei bestand die Integritätsprüfung nicht."""
|
||||
|
||||
|
||||
class UpdateInstallError(UpdateError):
|
||||
"""Der externe Austauschprozess konnte nicht gestartet werden."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UpdateInfo:
|
||||
"""Validierte Metadaten eines installierbaren Gitea-Releases."""
|
||||
|
||||
version: str
|
||||
title: str
|
||||
notes: str
|
||||
release_url: str
|
||||
download_url: str
|
||||
checksum_url: str
|
||||
size: int
|
||||
|
||||
|
||||
def parse_version(value: str) -> tuple[int, int, int]:
|
||||
"""Konvertiert stabile SemVer-Tags wie ``v1.2.3`` in Vergleichswerte."""
|
||||
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", value.strip())
|
||||
if match is None:
|
||||
raise ValueError(f"Ungültige stabile Versionsnummer: {value}")
|
||||
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
|
||||
|
||||
|
||||
class GiteaUpdateService:
|
||||
"""Kapselt das öffentliche Updateprotokoll für SyncVito."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_version: str,
|
||||
base_url: str = GITEA_BASE_URL,
|
||||
owner: str = GITEA_OWNER,
|
||||
repository: str = GITEA_REPOSITORY,
|
||||
update_directory: Path | None = None,
|
||||
) -> None:
|
||||
self.current_version = current_version
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.owner = owner
|
||||
self.repository = repository
|
||||
self.update_directory = update_directory or default_data_directory() / "updates"
|
||||
if not self.base_url.startswith("https://"):
|
||||
raise ValueError("Die Updatequelle muss HTTPS verwenden.")
|
||||
parse_version(current_version)
|
||||
|
||||
def check(self) -> UpdateInfo | None:
|
||||
"""Liefert das neueste geeignete Release, sofern es neuer ist."""
|
||||
endpoint = f"{self.base_url}/api/v1/repos/{self.owner}/{self.repository}/releases?limit=20"
|
||||
payload = self._read_json(endpoint)
|
||||
if not isinstance(payload, list):
|
||||
raise UpdateFormatError("Die Gitea-Antwort enthält keine Release-Liste.")
|
||||
candidates: list[tuple[tuple[int, int, int], dict[str, Any]]] = []
|
||||
for release in payload:
|
||||
if not isinstance(release, dict) or release.get("draft") or release.get("prerelease"):
|
||||
continue
|
||||
try:
|
||||
version = parse_version(str(release.get("tag_name", "")))
|
||||
except ValueError:
|
||||
continue
|
||||
candidates.append((version, release))
|
||||
if not candidates:
|
||||
return None
|
||||
version, release = max(candidates, key=lambda item: item[0])
|
||||
if version <= parse_version(self.current_version):
|
||||
return None
|
||||
return self._parse_release(release, ".".join(str(part) for part in version))
|
||||
|
||||
def download(self, info: UpdateInfo, progress: ProgressCallback | None = None) -> Path:
|
||||
"""Lädt die neue EXE und verifiziert sie gegen die Release-Prüfsumme."""
|
||||
self.update_directory.mkdir(parents=True, exist_ok=True)
|
||||
expected_hash = self._read_checksum(info.checksum_url)
|
||||
destination = self.update_directory / f"SyncVito-{info.version}.exe"
|
||||
temporary = destination.with_suffix(".exe.part")
|
||||
request = urllib.request.Request(info.download_url, headers={"User-Agent": USER_AGENT})
|
||||
digest = hashlib.sha256()
|
||||
received = 0
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response, temporary.open("wb") as output:
|
||||
announced = int(response.headers.get("Content-Length", info.size or 0))
|
||||
if announced > MAX_DOWNLOAD_BYTES:
|
||||
raise UpdateFormatError("Das Update ist unerwartet groß und wurde abgebrochen.")
|
||||
while chunk := response.read(1024 * 256):
|
||||
received += len(chunk)
|
||||
if received > MAX_DOWNLOAD_BYTES:
|
||||
raise UpdateFormatError("Das Update überschreitet die zulässige Größe.")
|
||||
output.write(chunk)
|
||||
digest.update(chunk)
|
||||
if progress and announced > 0:
|
||||
progress(min(99, round(received / announced * 100)), "Update wird heruntergeladen …")
|
||||
if digest.hexdigest().lower() != expected_hash:
|
||||
raise UpdateIntegrityError("Die SHA-256-Prüfsumme des Updates stimmt nicht überein.")
|
||||
os.replace(temporary, destination)
|
||||
except UpdateError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
except (OSError, TimeoutError, urllib.error.URLError) as exc:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise UpdateNetworkError(f"Das Update konnte nicht heruntergeladen werden: {exc}") from exc
|
||||
if progress:
|
||||
progress(100, "Update geprüft und bereit")
|
||||
self._remove_old_downloads(destination)
|
||||
return destination
|
||||
|
||||
def launch_installer(self, downloaded_executable: Path) -> None:
|
||||
"""Startet einen versteckten Helfer, der die beendete EXE atomar ersetzt."""
|
||||
if os.name != "nt" or not getattr(sys, "frozen", False):
|
||||
raise UpdateInstallError("Updates können nur aus der installierten Windows-EXE heraus angewendet werden.")
|
||||
current_executable = Path(sys.executable).resolve()
|
||||
downloaded = downloaded_executable.resolve()
|
||||
if not downloaded.is_file() or downloaded.parent != self.update_directory.resolve():
|
||||
raise UpdateInstallError("Die geprüfte Update-Datei wurde nicht gefunden.")
|
||||
script = self.update_directory / "install-update.ps1"
|
||||
log_path = self.update_directory / "update-install.log"
|
||||
script.write_text(_installer_script(), encoding="utf-8-sig")
|
||||
command = [
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-File",
|
||||
str(script),
|
||||
"-ProcessId",
|
||||
str(os.getpid()),
|
||||
"-CurrentExecutable",
|
||||
str(current_executable),
|
||||
"-DownloadedExecutable",
|
||||
str(downloaded),
|
||||
"-LogPath",
|
||||
str(log_path),
|
||||
]
|
||||
try:
|
||||
subprocess.Popen(
|
||||
command,
|
||||
close_fds=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW | subprocess.DETACHED_PROCESS,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise UpdateInstallError(f"Der Update-Helfer konnte nicht gestartet werden: {exc}") from exc
|
||||
|
||||
def _parse_release(self, release: dict[str, Any], version: str) -> UpdateInfo:
|
||||
assets = release.get("assets")
|
||||
if not isinstance(assets, list):
|
||||
raise UpdateFormatError(f"Release {version} enthält keine Dateianhänge.")
|
||||
by_name = {str(asset.get("name")): asset for asset in assets if isinstance(asset, dict)}
|
||||
executable = by_name.get(APPLICATION_ASSET)
|
||||
checksum = by_name.get(CHECKSUM_ASSET)
|
||||
if executable is None or checksum is None:
|
||||
raise UpdateFormatError(f"Release {version} benötigt {APPLICATION_ASSET} und {CHECKSUM_ASSET}.")
|
||||
download_url = str(executable.get("browser_download_url", ""))
|
||||
checksum_url = str(checksum.get("browser_download_url", ""))
|
||||
if not download_url.startswith("https://") or not checksum_url.startswith("https://"):
|
||||
raise UpdateFormatError("Die Release-Dateien besitzen keine sicheren Downloadadressen.")
|
||||
return UpdateInfo(
|
||||
version=version,
|
||||
title=str(release.get("name") or f"SyncVito {version}"),
|
||||
notes=str(release.get("body") or "Keine Versionshinweise vorhanden."),
|
||||
release_url=str(release.get("html_url") or self.base_url),
|
||||
download_url=download_url,
|
||||
checksum_url=checksum_url,
|
||||
size=int(executable.get("size") or 0),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _read_json(url: str) -> Any:
|
||||
request = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=12) as response:
|
||||
return json.load(response)
|
||||
except (TimeoutError, urllib.error.URLError, OSError) as exc:
|
||||
raise UpdateNetworkError(f"Der Gitea-Server ist nicht erreichbar: {exc}") from exc
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise UpdateFormatError("Gitea hat ungültige Release-Daten geliefert.") from exc
|
||||
|
||||
@staticmethod
|
||||
def _read_checksum(url: str) -> str:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=12) as response:
|
||||
content = response.read(4096).decode("ascii", errors="strict")
|
||||
except (TimeoutError, urllib.error.URLError, OSError, UnicodeDecodeError) as exc:
|
||||
raise UpdateNetworkError(f"Die Update-Prüfsumme konnte nicht geladen werden: {exc}") from exc
|
||||
match = re.search(r"\b([0-9a-fA-F]{64})\b", content)
|
||||
if match is None:
|
||||
raise UpdateFormatError("Die Release-Prüfsumme ist ungültig.")
|
||||
return match.group(1).lower()
|
||||
|
||||
def _remove_old_downloads(self, keep: Path) -> None:
|
||||
for candidate in self.update_directory.glob("SyncVito-*.exe"):
|
||||
if candidate != keep:
|
||||
try:
|
||||
candidate.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def _installer_script() -> str:
|
||||
return r"""param(
|
||||
[Parameter(Mandatory=$true)][int]$ProcessId,
|
||||
[Parameter(Mandatory=$true)][string]$CurrentExecutable,
|
||||
[Parameter(Mandatory=$true)][string]$DownloadedExecutable,
|
||||
[Parameter(Mandatory=$true)][string]$LogPath
|
||||
)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$BackupExecutable = "$CurrentExecutable.old"
|
||||
try {
|
||||
Wait-Process -Id $ProcessId -Timeout 90 -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Milliseconds 800
|
||||
if (Test-Path -LiteralPath $BackupExecutable) {
|
||||
Remove-Item -LiteralPath $BackupExecutable -Force
|
||||
}
|
||||
Move-Item -LiteralPath $CurrentExecutable -Destination $BackupExecutable -Force
|
||||
Move-Item -LiteralPath $DownloadedExecutable -Destination $CurrentExecutable -Force
|
||||
Start-Process -FilePath $CurrentExecutable
|
||||
Start-Sleep -Seconds 3
|
||||
Remove-Item -LiteralPath $BackupExecutable -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
$_ | Out-File -LiteralPath $LogPath -Encoding utf8
|
||||
if (-not (Test-Path -LiteralPath $CurrentExecutable) -and (Test-Path -LiteralPath $BackupExecutable)) {
|
||||
Move-Item -LiteralPath $BackupExecutable -Destination $CurrentExecutable -Force
|
||||
}
|
||||
if (Test-Path -LiteralPath $CurrentExecutable) {
|
||||
Start-Process -FilePath $CurrentExecutable
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Zentrale, maschinenlesbare Anwendungsversion."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "1.0.0"
|
||||
Reference in new issue
Block a user