Initial release with Gitea updater

This commit is contained in:
Davito committed 2026-09-23 19:02:23 +02:00
commit 46c2474985
36 files changed
+3674

No files matched your search

+25
View File
@@ -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",
]
+5
View File
@@ -0,0 +1,5 @@
"""Ausführung mit ``python -m syncvito``."""
from syncvito.app import main
raise SystemExit(main())
+55
View File
@@ -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())
+31
View File
@@ -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")
+63
View File
@@ -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
+299
View File
@@ -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()))
+282
View File
@@ -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)
+114
View File
@@ -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
+52
View File
@@ -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)
+653
View File
@@ -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]
+210
View File
@@ -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
+65
View File
@@ -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
+51
View File
@@ -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)
+328
View File
@@ -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)
+85
View File
@@ -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; }}
"""
+114
View File
@@ -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)
+83
View File
@@ -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)
+286
View File
@@ -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
}
"""
+5
View File
@@ -0,0 +1,5 @@
"""Zentrale, maschinenlesbare Anwendungsversion."""
__all__ = ["__version__"]
__version__ = "1.0.0"