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

+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]