Initial SyncVito release with installer updater

This commit is contained in:
Davito committed 2026-09-23 19:40:36 +02:00
commit b9e4c09f49
41 files changed
+4331

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())
+61
View File
@@ -0,0 +1,61 @@
"""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
from syncvito.version import __version__
__all__ = ["main"]
def main() -> int:
"""Startet SyncVito als einzelne Windows-Instanz."""
if "--version" in sys.argv:
print(__version__)
return 0
if "--smoke-test" in sys.argv:
return 0
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)
+670
View File
@@ -0,0 +1,670 @@
"""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)
cleanup_attempts = 6 if "--updated" in sys.argv else 1
QTimer.singleShot(3000, lambda: self._cleanup_update_installers(cleanup_attempts))
if "--updated" in sys.argv:
QTimer.singleShot(900, self._show_update_completed)
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, info.version)
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 _show_update_completed(self) -> None:
message = f"Update abgeschlossen – SyncVito {__version__} wurde erfolgreich gestartet."
self._append_log("Update", "Erfolg", message)
self.tray.showMessage("SyncVito aktualisiert", message, QSystemTrayIcon.MessageIcon.Information, 5000)
def _cleanup_update_installers(self, attempts_remaining: int = 1) -> None:
_removed, pending = self.updater.service.cleanup_installers()
if pending and attempts_remaining > 1:
QTimer.singleShot(
3000,
lambda: self._cleanup_update_installers(attempts_remaining - 1),
)
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)
+84
View File
@@ -0,0 +1,84 @@
"""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 den neuen Windows-Installer herunter, prüft seine SHA-256-Summe und startet "
"ihn erst, nachdem die Anwendung vollständig beendet wurde. "
"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)
+426
View File
@@ -0,0 +1,426 @@
"""Sicherer Gitea-Updater für den installierten SyncVito-Client."""
from __future__ import annotations
import base64
import hashlib
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Callable, Iterable
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"
MAX_RELEASE_BYTES = 2 * 1024 * 1024
MAX_CHECKSUM_BYTES = 1024 * 1024
MAX_INSTALLER_BYTES = 1024 * 1024 * 1024
USER_AGENT = "SyncVito-AutoUpdater"
ProgressCallback = Callable[[int, str], None]
_VERSION_PATTERN = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
_SHA256_PATTERN = re.compile(r"^[0-9a-fA-F]{64}$")
_MANAGED_INSTALLER_PATTERN = re.compile(
r"^SyncVitoSetup-\d+\.\d+\.\d+\.exe(?:\.part)?$",
re.IGNORECASE,
)
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 Installer-Wächter 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
installer_name: 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 = _VERSION_PATTERN.fullmatch(str(value or "").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]
def _powershell_literal(value: str | Path) -> str:
return "'" + str(value).replace("'", "''") + "'"
def _start_detached(executable: str, arguments: list[str]) -> None:
"""Startet einen Prozess unabhängig vom Lebenszyklus der Anwendung."""
if os.name == "nt":
os.startfile(
executable,
operation="open",
arguments=subprocess.list2cmdline(arguments),
show_cmd=0,
)
return
subprocess.Popen(
[executable, *arguments],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
def _start_installer_after_exit(
installer_path: Path,
arguments: list[str],
wait_pids: Iterable[int],
bootstrap_log_path: Path,
) -> None:
"""Startet Inno Setup erst nach dem Ende aller PyInstaller-Prozesse."""
if os.name != "nt":
_start_detached(str(installer_path), arguments)
return
system_root = os.environ.get("SYSTEMROOT", r"C:\Windows")
powershell_path = Path(system_root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
if not powershell_path.is_file():
raise UpdateInstallError(f"Windows PowerShell wurde nicht gefunden: {powershell_path}")
normalized_pids = sorted({int(pid) for pid in wait_pids if int(pid) > 0})
pid_list = ", ".join(str(pid) for pid in normalized_pids)
setup_arguments = ", ".join(_powershell_literal(argument) for argument in arguments)
script = f"""
$ErrorActionPreference = 'Stop'
$bootstrapLog = {_powershell_literal(bootstrap_log_path)}
try {{
[System.IO.File]::WriteAllText(
$bootstrapLog,
((Get-Date).ToString('o') + ' Warte auf SyncVito-Prozesse: {pid_list}' + [Environment]::NewLine)
)
foreach ($processId in @({pid_list})) {{
$deadline = [DateTime]::UtcNow.AddMinutes(2)
while (Get-Process -Id $processId -ErrorAction SilentlyContinue) {{
if ([DateTime]::UtcNow -ge $deadline) {{
throw "SyncVito-Prozess $processId wurde nicht innerhalb von zwei Minuten beendet."
}}
Start-Sleep -Milliseconds 200
}}
}}
[System.IO.File]::AppendAllText(
$bootstrapLog,
((Get-Date).ToString('o') + ' Starte verifizierten Installer.' + [Environment]::NewLine)
)
$setupArguments = @({setup_arguments})
& {_powershell_literal(installer_path)} @setupArguments
$setupExitCode = $LASTEXITCODE
[System.IO.File]::AppendAllText(
$bootstrapLog,
((Get-Date).ToString('o') + ' Installer-Exitcode: ' + $setupExitCode + [Environment]::NewLine)
)
exit $setupExitCode
}}
catch {{
[System.IO.File]::AppendAllText(
$bootstrapLog,
((Get-Date).ToString('o') + ' Bootstrap-Fehler: ' + $_.Exception.Message + [Environment]::NewLine)
)
exit 1
}}
"""
encoded_script = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
_start_detached(
str(powershell_path),
[
"-NoProfile",
"-NonInteractive",
"-WindowStyle",
"Hidden",
"-EncodedCommand",
encoded_script,
],
)
class _TrustedRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, trusted_host: str) -> None:
super().__init__()
self.trusted_host = trusted_host
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
_validate_update_url(newurl, self.trusted_host)
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _validate_update_url(url: str, trusted_host: str) -> str:
parsed = urllib.parse.urlparse(str(url or ""))
try:
port = parsed.port
except ValueError as exc:
raise UpdateFormatError("Gitea lieferte eine ungültige Downloadadresse.") from exc
if (
parsed.scheme.lower() != "https"
or parsed.hostname != trusted_host
or port not in (None, 443)
or parsed.username is not None
or parsed.password is not None
):
raise UpdateFormatError("Gitea lieferte eine nicht vertrauenswürdige Downloadadresse.")
return parsed.geturl()
class GiteaUpdateService:
"""Kapselt das öffentliche Installer-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"
parsed_base = urllib.parse.urlparse(self.base_url)
if parsed_base.scheme.lower() != "https" or not parsed_base.hostname:
raise ValueError("Die Updatequelle muss eine gültige HTTPS-Adresse sein.")
self.trusted_host = parsed_base.hostname
parse_version(current_version)
def check(self) -> UpdateInfo | None:
"""Liefert den neuesten stabilen Installer, sofern er neuer ist."""
endpoint = f"{self.base_url}/api/v1/repos/{self.owner}/{self.repository}/releases/latest"
try:
payload = self._read_json(endpoint)
except UpdateNetworkError as exc:
if "HTTP 404" in str(exc):
return None
raise
if not isinstance(payload, dict) or payload.get("draft") or payload.get("prerelease"):
raise UpdateFormatError("Gitea lieferte keinen stabilen Release.")
try:
version_tuple = parse_version(str(payload.get("tag_name", "")))
except ValueError as exc:
raise UpdateFormatError("Der neueste Gitea-Release besitzt keine gültige Version.") from exc
if version_tuple <= parse_version(self.current_version):
return None
version = ".".join(str(part) for part in version_tuple)
return self._parse_release(payload, version)
def download(self, info: UpdateInfo, progress: ProgressCallback | None = None) -> Path:
"""Lädt den Installer und veröffentlicht ihn erst nach SHA-256-Prüfung."""
self.update_directory.mkdir(parents=True, exist_ok=True)
expected_hash = self._read_checksum(info.checksum_url, info.installer_name)
destination = self.update_directory / info.installer_name
temporary = destination.with_suffix(".exe.part")
digest = hashlib.sha256()
received = 0
try:
with self._request(info.download_url, timeout=30) as response, temporary.open("wb") as output:
announced = int(response.headers.get("Content-Length", info.size or 0))
if announced > MAX_INSTALLER_BYTES:
raise UpdateFormatError("Der angebotene Installer ist unerwartet groß.")
while chunk := response.read(1024 * 1024):
received += len(chunk)
if received > MAX_INSTALLER_BYTES:
raise UpdateFormatError("Der geladene Installer ist unerwartet groß.")
output.write(chunk)
digest.update(chunk)
if progress and announced > 0:
progress(min(99, round(received / announced * 100)), "Installer wird heruntergeladen …")
if received <= 0:
raise UpdateFormatError("Der heruntergeladene Installer ist leer.")
if digest.hexdigest().lower() != expected_hash:
raise UpdateIntegrityError(
"Die SHA-256-Prüfung des Installers ist fehlgeschlagen; die Datei wird nicht ausgeführt."
)
os.replace(temporary, destination)
except UpdateError:
temporary.unlink(missing_ok=True)
raise
except (OSError, TimeoutError, urllib.error.URLError, ValueError) as exc:
temporary.unlink(missing_ok=True)
raise UpdateNetworkError(f"Der Installer konnte nicht heruntergeladen werden: {exc}") from exc
if progress:
progress(100, "Installer geprüft und bereit")
self._remove_old_downloads(destination)
return destination
def launch_installer(self, installer_path: Path, version: str) -> None:
"""Startet einen Wächter, der nach dem App-Ende Inno Setup ausführt."""
if os.name != "nt" or not getattr(sys, "frozen", False):
raise UpdateInstallError("Updates können nur aus der installierten Windows-Version gestartet werden.")
installer = installer_path.resolve()
expected_name = f"SyncVitoSetup-{version}.exe"
if installer.parent != self.update_directory.resolve() or installer.name != expected_name or not installer.is_file():
raise UpdateInstallError("Der geprüfte Installer wurde nicht gefunden.")
safe_version = re.sub(r"[^0-9A-Za-z._-]+", "_", version).strip("._") or "unknown"
setup_log = installer.with_name(f"SyncVitoSetup-{safe_version}.log")
bootstrap_log = installer.with_name(f"SyncVitoUpdater-{safe_version}-bootstrap.log")
current_executable = Path(sys.executable).resolve()
wait_pids = [os.getpid()]
parent_pid = os.getppid()
if parent_pid > 0 and parent_pid not in wait_pids:
wait_pids.append(parent_pid)
arguments = [
"/VERYSILENT",
"/SUPPRESSMSGBOXES",
"/NORESTART",
"/CLOSEAPPLICATIONS",
f"/DIR={current_executable.parent}",
f"/LOG={setup_log}",
]
try:
_start_installer_after_exit(installer, arguments, wait_pids, bootstrap_log)
except (OSError, ValueError, UpdateError) as exc:
raise UpdateInstallError(f"Die Installation konnte nicht gestartet werden: {exc}") from exc
def cleanup_installers(self) -> tuple[int, int]:
"""Entfernt ausschließlich von SyncVito verwaltete Setup-Dateien."""
try:
candidates = tuple(self.update_directory.resolve().iterdir())
except FileNotFoundError:
return 0, 0
except OSError:
return 0, 1
removed = 0
pending = 0
for candidate in candidates:
if not _MANAGED_INSTALLER_PATTERN.fullmatch(candidate.name) or candidate.is_dir():
continue
try:
candidate.unlink()
except OSError:
pending += 1
else:
removed += 1
return removed, pending
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)}
installer_name = f"SyncVitoSetup-{version}.exe"
checksum_name = f"{installer_name}.sha256"
installer = by_name.get(installer_name)
checksum = by_name.get(checksum_name)
if installer is None or checksum is None:
raise UpdateFormatError(f"Release {version} benötigt {installer_name} und {checksum_name}.")
download_url = _validate_update_url(str(installer.get("browser_download_url", "")), self.trusted_host)
checksum_url = _validate_update_url(str(checksum.get("browser_download_url", "")), self.trusted_host)
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),
installer_name=installer_name,
download_url=download_url,
checksum_url=checksum_url,
size=max(0, int(installer.get("size") or 0)),
)
def _request(self, url: str, timeout: int): # type: ignore[no-untyped-def]
request = urllib.request.Request(
_validate_update_url(url, self.trusted_host),
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
)
opener = urllib.request.build_opener(_TrustedRedirectHandler(self.trusted_host))
try:
response = opener.open(request, timeout=timeout)
_validate_update_url(response.geturl(), self.trusted_host)
return response
except urllib.error.HTTPError as exc:
raise UpdateNetworkError(f"Gitea antwortete mit HTTP {exc.code}.") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise UpdateNetworkError(f"Der Gitea-Server ist nicht erreichbar: {exc}") from exc
def _read_json(self, url: str) -> Any:
try:
with self._request(url, timeout=12) as response:
payload = response.read(MAX_RELEASE_BYTES + 1)
if len(payload) > MAX_RELEASE_BYTES:
raise UpdateFormatError("Die Release-Antwort ist unerwartet groß.")
return json.loads(payload)
except UpdateError:
raise
except (json.JSONDecodeError, UnicodeDecodeError, TypeError) as exc:
raise UpdateFormatError("Gitea hat ungültige Release-Daten geliefert.") from exc
def _read_checksum(self, url: str, filename: str) -> str:
try:
with self._request(url, timeout=12) as response:
payload = response.read(MAX_CHECKSUM_BYTES + 1)
if len(payload) > MAX_CHECKSUM_BYTES:
raise UpdateFormatError("Die Prüfsummendatei ist unerwartet groß.")
content = payload.decode("utf-8")
except UpdateError:
raise
except UnicodeDecodeError as exc:
raise UpdateFormatError("Die Update-Prüfsumme ist ungültig.") from exc
fallback = ""
for raw_line in content.splitlines():
parts = raw_line.strip().replace(" ", " ").split()
if not parts or not _SHA256_PATTERN.fullmatch(parts[0]):
continue
digest = parts[0].lower()
if len(parts) == 1:
fallback = digest
elif " ".join(parts[1:]).lstrip("*").strip().casefold() == filename.casefold():
return digest
if fallback:
return fallback
raise UpdateFormatError(f"Keine SHA-256-Prüfsumme für {filename} gefunden.")
def _remove_old_downloads(self, keep: Path) -> None:
for candidate in self.update_directory.glob("SyncVitoSetup-*.exe"):
if candidate != keep:
try:
candidate.unlink()
except OSError:
continue
+27
View File
@@ -0,0 +1,27 @@
"""Zentrale, aus ``version.txt`` geladene Anwendungsversion."""
from __future__ import annotations
import sys
from pathlib import Path
__all__ = ["__version__"]
def _read_version() -> str:
candidates: list[Path] = []
bundle_directory = getattr(sys, "_MEIPASS", None)
if bundle_directory:
candidates.append(Path(bundle_directory) / "version.txt")
candidates.append(Path(__file__).resolve().parents[2] / "version.txt")
for candidate in candidates:
try:
value = candidate.read_text(encoding="utf-8").strip()
except OSError:
continue
if value:
return value
raise RuntimeError("version.txt fehlt oder ist leer.")
__version__ = _read_version()