3 Commits
Author SHA1 Message Date
Davito 3d6ab87ce3 Release SyncVito 1.0.6 2026-09-24 13:17:02 +02:00
Davito bb2a0db609 Release SyncVito 1.0.5 2026-09-24 13:10:07 +02:00
Davito 30982e8a4d Release SyncVito 1.0.3 2026-09-24 13:01:39 +02:00
9 changed files with 258 additions and 16 deletions

No files matched your search

+24
View File
@@ -1,5 +1,29 @@
# Änderungsprotokoll # Änderungsprotokoll
## 1.0.6 – 2026-09-24
### Behoben
- Dateianzeige und Fortschrittsleiste besitzen nun feste Breiten und verschieben sich beim Wechsel zwischen unterschiedlich langen Dateipfaden nicht mehr.
- Lange Dateipfade werden platzsparend gekürzt; der vollständige Text bleibt als Tooltip verfügbar.
## 1.0.5 – 2026-09-24
### Neu
- SyncVito prüft bei jedem Programmstart und während des Betriebs alle 30 Minuten automatisch auf Updates.
- Verfügbare Updates werden zusätzlich zum Hinweisfenster mit einer auffälligen „UPDATE“-Kennzeichnung am Eintrag „Einstellungen“ angezeigt.
### Behoben
- Wird eine automatische Prüfung durch eine laufende Synchronisation oder Updateaktion verhindert, wird sie nach einer Minute erneut versucht.
## 1.0.3 – 2026-09-24
### Behoben
- Der automatische Neustart nach einem internen Update setzt nun die geerbte PyInstaller-Laufzeitumgebung zurück und verhindert dadurch den Sicherheitsfehler beim Start der aktualisierten Anwendung.
## 1.0.2 – 2026-09-24 ## 1.0.2 – 2026-09-24
### Behoben ### Behoben
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "syncvito" name = "syncvito"
version = "1.0.2" version = "1.0.6"
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation" description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+82 -14
View File
@@ -3,11 +3,11 @@
from __future__ import annotations from __future__ import annotations
import sys import sys
from datetime import datetime, timedelta from datetime import datetime
from pathlib import Path from pathlib import Path
from PyQt6.QtCore import QEvent, Qt, QTimer, pyqtSignal from PyQt6.QtCore import QEvent, Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QAction, QCloseEvent, QFontDatabase from PyQt6.QtGui import QAction, QCloseEvent, QFontDatabase, QResizeEvent
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QApplication, QApplication,
@@ -48,6 +48,38 @@ from syncvito.version import __version__
__all__ = ["MainWindow"] __all__ = ["MainWindow"]
UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1000
UPDATE_RETRY_INTERVAL_MS = 60 * 1000
PROGRESS_TEXT_WIDTH = 300
PROGRESS_BAR_WIDTH = 450
class _ElidedLabel(QLabel):
"""Hält dynamische Texte in einer festen Breite vollständig zugänglich."""
def __init__(self, text: str = "", parent: QWidget | None = None) -> None:
super().__init__(parent)
self._full_text = ""
self.setText(text)
def setText(self, text: str) -> None: # noqa: N802 - Qt API
self._full_text = text
self.setToolTip(text)
self.setAccessibleName(text)
self._refresh_elided_text()
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
super().resizeEvent(event)
self._refresh_elided_text()
def _refresh_elided_text(self) -> None:
visible_text = self.fontMetrics().elidedText(
self._full_text,
Qt.TextElideMode.ElideMiddle,
max(0, self.contentsRect().width()),
)
super().setText(visible_text)
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
"""Zentrale SyncVito-Benutzeroberfläche.""" """Zentrale SyncVito-Benutzeroberfläche."""
@@ -59,9 +91,10 @@ class MainWindow(QMainWindow):
self.config = config self.config = config
self.config_store = config_store self.config_store = config_store
self._allow_close = False self._allow_close = False
self._notified_update_version: str | None = None
self.setWindowTitle("SyncVito") self.setWindowTitle("SyncVito")
self.setWindowIcon(app_icon()) self.setWindowIcon(app_icon())
self.setMinimumSize(980, 680) self.setMinimumSize(1040, 680)
self.resize(1220, 800) self.resize(1220, 800)
self.coordinator = SyncCoordinator(config, config_store, self) self.coordinator = SyncCoordinator(config, config_store, self)
@@ -71,6 +104,15 @@ class MainWindow(QMainWindow):
self._connect_coordinator() self._connect_coordinator()
self._refresh_tasks() self._refresh_tasks()
self._set_global_status(*self.coordinator.current_overall_status()) self._set_global_status(*self.coordinator.current_overall_status())
self.update_check_timer = QTimer(self)
self.update_check_timer.setInterval(UPDATE_CHECK_INTERVAL_MS)
self.update_check_timer.timeout.connect(self._automatic_update_check)
self.update_check_timer.start()
self.update_retry_timer = QTimer(self)
self.update_retry_timer.setSingleShot(True)
self.update_retry_timer.setInterval(UPDATE_RETRY_INTERVAL_MS)
self.update_retry_timer.timeout.connect(self._automatic_update_check)
QTimer.singleShot(3000, self._automatic_update_check) QTimer.singleShot(3000, self._automatic_update_check)
cleanup_attempts = 6 if "--updated" in sys.argv else 1 cleanup_attempts = 6 if "--updated" in sys.argv else 1
QTimer.singleShot(3000, lambda: self._cleanup_update_installers(cleanup_attempts)) QTimer.singleShot(3000, lambda: self._cleanup_update_installers(cleanup_attempts))
@@ -159,6 +201,7 @@ class MainWindow(QMainWindow):
layout.addWidget(button) layout.addWidget(button)
self.nav_buttons.append(button) self.nav_buttons.append(button)
self.nav_buttons[0].setChecked(True) self.nav_buttons[0].setChecked(True)
self.settings_nav_button = self.nav_buttons[2]
layout.addStretch(1) layout.addStretch(1)
version = QLabel(f"Version {__version__}\nby Davito") version = QLabel(f"Version {__version__}\nby Davito")
@@ -340,7 +383,7 @@ class MainWindow(QMainWindow):
version_label = QLabel(f"Installierte Version: {__version__}") version_label = QLabel(f"Installierte Version: {__version__}")
version_label.setProperty("muted", True) version_label.setProperty("muted", True)
update_layout.addWidget(version_label) update_layout.addWidget(version_label)
self.update_check_box = QCheckBox("Beim Start automatisch nach Updates suchen") self.update_check_box = QCheckBox("Beim Start und danach alle 30 Minuten automatisch nach Updates suchen")
self.update_check_box.setChecked(self.config.settings.check_updates_on_start) self.update_check_box.setChecked(self.config.settings.check_updates_on_start)
update_layout.addWidget(self.update_check_box) update_layout.addWidget(self.update_check_box)
update_hint = QLabel( update_hint = QLabel(
@@ -366,15 +409,17 @@ class MainWindow(QMainWindow):
dock.setFixedHeight(66) dock.setFixedHeight(66)
layout = QHBoxLayout(dock) layout = QHBoxLayout(dock)
layout.setContentsMargins(28, 12, 28, 12) layout.setContentsMargins(28, 12, 28, 12)
self.progress_text = QLabel("Bereit") self.progress_text = _ElidedLabel("Bereit")
self.progress_text.setMinimumWidth(250) self.progress_text.setFixedWidth(PROGRESS_TEXT_WIDTH)
self.progress_text.setProperty("muted", True) self.progress_text.setProperty("muted", True)
self.progress_bar = QProgressBar() self.progress_bar = QProgressBar()
self.progress_bar.setFixedWidth(PROGRESS_BAR_WIDTH)
self.progress_bar.setRange(0, 100) self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0) self.progress_bar.setValue(0)
self.progress_bar.setFormat("%p %") self.progress_bar.setFormat("%p %")
layout.addWidget(self.progress_text) layout.addWidget(self.progress_text)
layout.addWidget(self.progress_bar, 1) layout.addWidget(self.progress_bar)
layout.addStretch(1)
return dock return dock
def _build_tray(self) -> None: def _build_tray(self) -> None:
@@ -511,6 +556,7 @@ class MainWindow(QMainWindow):
def _save_settings(self) -> None: def _save_settings(self) -> None:
settings = self.config.settings settings = self.config.settings
old_autostart = settings.autostart old_autostart = settings.autostart
old_update_check = settings.check_updates_on_start
settings.autostart = self.autostart_check.isChecked() settings.autostart = self.autostart_check.isChecked()
settings.font_family = self.font_combo.currentText() settings.font_family = self.font_combo.currentText()
settings.font_size = self.font_size_spin.value() settings.font_size = self.font_size_spin.value()
@@ -528,18 +574,22 @@ class MainWindow(QMainWindow):
return return
apply_theme(QApplication.instance(), settings) apply_theme(QApplication.instance(), settings)
self._append_log("Einstellungen", "Erfolg", "Darstellung und Systemstart wurden übernommen.") self._append_log("Einstellungen", "Erfolg", "Darstellung und Systemstart wurden übernommen.")
if settings.check_updates_on_start and not old_update_check:
QTimer.singleShot(0, self._automatic_update_check)
elif not settings.check_updates_on_start:
self.update_retry_timer.stop()
def _automatic_update_check(self) -> None: def _automatic_update_check(self) -> None:
settings = self.config.settings settings = self.config.settings
if not settings.check_updates_on_start or self.coordinator.is_running: if not settings.check_updates_on_start:
self.update_retry_timer.stop()
return return
if settings.last_update_check: if self.coordinator.is_running or self.updater.busy:
try: if not self.update_retry_timer.isActive():
last_check = datetime.fromisoformat(settings.last_update_check) self.update_retry_timer.start()
except ValueError:
last_check = datetime.min
if datetime.now() - last_check < timedelta(hours=24):
return return
self.update_retry_timer.stop()
self.update_check_timer.start(UPDATE_CHECK_INTERVAL_MS)
self._check_updates(manual=False) self._check_updates(manual=False)
def _check_updates(self, manual: bool) -> None: def _check_updates(self, manual: bool) -> None:
@@ -558,12 +608,18 @@ class MainWindow(QMainWindow):
self._mark_update_checked() self._mark_update_checked()
self._update_progress(100, f"Version {info.version} ist verfügbar") self._update_progress(100, f"Version {info.version} ist verfügbar")
self._append_log("Update", "Hinweis", f"Version {info.version} ist verfügbar.") self._append_log("Update", "Hinweis", f"Version {info.version} ist verfügbar.")
self._set_update_indicator(info.version)
if not manual and self._notified_update_version == info.version:
return
self._notified_update_version = info.version
dialog = UpdateDialog(__version__, info, getattr(sys, "frozen", False), self) dialog = UpdateDialog(__version__, info, getattr(sys, "frozen", False), self)
if dialog.exec() == UpdateDialog.DialogCode.Accepted: if dialog.exec() == UpdateDialog.DialogCode.Accepted:
self.updater.download(info) self.updater.download(info)
def _on_no_update(self, manual: bool) -> None: def _on_no_update(self, manual: bool) -> None:
self._mark_update_checked() self._mark_update_checked()
self._set_update_indicator(None)
self._notified_update_version = None
self._update_progress(100, "SyncVito ist aktuell") self._update_progress(100, "SyncVito ist aktuell")
self._append_log("Update", "Erfolg", "Die installierte Version ist aktuell.") self._append_log("Update", "Erfolg", "Die installierte Version ist aktuell.")
if manual: if manual:
@@ -579,6 +635,18 @@ class MainWindow(QMainWindow):
f"{message}\n\nBitte die Netzwerkverbindung prüfen und anschließend erneut versuchen.", f"{message}\n\nBitte die Netzwerkverbindung prüfen und anschließend erneut versuchen.",
) )
def _set_update_indicator(self, version: str | None) -> None:
available = bool(version)
self.settings_nav_button.setText("Einstellungen • UPDATE" if available else "Einstellungen")
self.settings_nav_button.setProperty("updateAvailable", available)
description = f"SyncVito {version} ist verfügbar" if version else ""
self.settings_nav_button.setToolTip(description)
self.settings_nav_button.setAccessibleDescription(description)
style = self.settings_nav_button.style()
style.unpolish(self.settings_nav_button)
style.polish(self.settings_nav_button)
self.settings_nav_button.update()
def _install_update(self, path: Path, info: UpdateInfo) -> None: def _install_update(self, path: Path, info: UpdateInfo) -> None:
try: try:
self.updater.service.launch_installer(path, info.version) self.updater.service.launch_installer(path, info.version)
+6
View File
@@ -52,6 +52,12 @@ def _stylesheet(scale: float) -> str:
padding: {round(10 * scale)}px {round(12 * scale)}px; color: #9CACBF; }} padding: {round(10 * scale)}px {round(12 * scale)}px; color: #9CACBF; }}
QPushButton[nav="true"]:hover {{ background: #17212E; color: #E8EDF4; }} QPushButton[nav="true"]:hover {{ background: #17212E; color: #E8EDF4; }}
QPushButton[nav="true"]:checked {{ background: #1B2A3D; color: #FFFFFF; border-color: #2A568B; }} QPushButton[nav="true"]:checked {{ background: #1B2A3D; color: #FFFFFF; border-color: #2A568B; }}
QPushButton[nav="true"][updateAvailable="true"] {{ background: #3A2412; color: #FFF0DF;
border-color: #EA580C; }}
QPushButton[nav="true"][updateAvailable="true"]:hover {{ background: #4A2D14; color: #FFFFFF;
border-color: #FF7A2F; }}
QPushButton[nav="true"][updateAvailable="true"]:focus {{ border: 2px solid #FF9A5B;
padding: {round(9 * scale)}px {round(11 * scale)}px; }}
QLineEdit, QComboBox, QSpinBox, QListWidget, QTreeView, QTableWidget {{ QLineEdit, QComboBox, QSpinBox, QListWidget, QTreeView, QTableWidget {{
background: #0E141D; border: 1px solid #304052; border-radius: {radius}px; background: #0E141D; border: 1px solid #304052; border-radius: {radius}px;
padding: {round(7 * scale)}px; color: #E8EDF4; padding: {round(7 * scale)}px; color: #E8EDF4;
+1
View File
@@ -151,6 +151,7 @@ try {{
$bootstrapLog, $bootstrapLog,
((Get-Date).ToString('o') + ' Starte verifizierten Installer.' + [Environment]::NewLine) ((Get-Date).ToString('o') + ' Starte verifizierten Installer.' + [Environment]::NewLine)
) )
$env:PYINSTALLER_RESET_ENVIRONMENT = '1'
$setupArguments = @({setup_arguments}) $setupArguments = @({setup_arguments})
& {_powershell_literal(installer_path)} @setupArguments & {_powershell_literal(installer_path)} @setupArguments
$setupExitCode = $LASTEXITCODE $setupExitCode = $LASTEXITCODE
+28
View File
@@ -0,0 +1,28 @@
from types import SimpleNamespace
from PyQt6.QtWidgets import QApplication
from syncvito.main_window import PROGRESS_BAR_WIDTH, PROGRESS_TEXT_WIDTH, MainWindow
def test_progress_widgets_keep_fixed_widths_for_long_paths() -> None:
application = QApplication.instance() or QApplication([])
owner = SimpleNamespace()
dock = MainWindow._build_progress_dock(owner)
dock.resize(900, 66)
dock.show()
application.processEvents()
bar_x = owner.progress_bar.x()
long_path = "Kopieren: " + "sehr-langer-ordner\\" * 20 + "wichtige-datei.ini"
owner.progress_text.setText(long_path)
application.processEvents()
assert owner.progress_text.width() == PROGRESS_TEXT_WIDTH
assert owner.progress_bar.width() == PROGRESS_BAR_WIDTH
assert owner.progress_bar.x() == bar_x
assert owner.progress_text.text() != long_path
assert "…" in owner.progress_text.text()
assert owner.progress_text.toolTip() == long_path
assert owner.progress_text.accessibleName() == long_path
dock.close()
+112
View File
@@ -0,0 +1,112 @@
from types import SimpleNamespace
from syncvito.main_window import UPDATE_CHECK_INTERVAL_MS, UPDATE_RETRY_INTERVAL_MS, MainWindow
class FakeTimer:
def __init__(self, *, active: bool = False) -> None:
self.active = active
self.start_calls: list[int | None] = []
self.stop_calls = 0
def isActive(self) -> bool: # noqa: N802 - Qt API
return self.active
def start(self, interval: int | None = None) -> None:
self.active = True
self.start_calls.append(interval)
def stop(self) -> None:
self.active = False
self.stop_calls += 1
class FakeStyle:
def __init__(self) -> None:
self.unpolished = False
self.polished = False
def unpolish(self, _button: object) -> None:
self.unpolished = True
def polish(self, _button: object) -> None:
self.polished = True
class FakeButton:
def __init__(self) -> None:
self.text = ""
self.properties: dict[str, object] = {}
self.tooltip = ""
self.accessible_description = ""
self.button_style = FakeStyle()
self.updated = False
def setText(self, text: str) -> None: # noqa: N802 - Qt API
self.text = text
def setProperty(self, name: str, value: object) -> None: # noqa: N802 - Qt API
self.properties[name] = value
def setToolTip(self, text: str) -> None: # noqa: N802 - Qt API
self.tooltip = text
def setAccessibleDescription(self, text: str) -> None: # noqa: N802 - Qt API
self.accessible_description = text
def style(self) -> FakeStyle:
return self.button_style
def update(self) -> None:
self.updated = True
def _window(*, sync_running: bool = False, updater_busy: bool = False) -> SimpleNamespace:
checks: list[bool] = []
return SimpleNamespace(
config=SimpleNamespace(settings=SimpleNamespace(check_updates_on_start=True)),
coordinator=SimpleNamespace(is_running=sync_running),
updater=SimpleNamespace(busy=updater_busy),
update_check_timer=FakeTimer(active=True),
update_retry_timer=FakeTimer(),
_check_updates=lambda manual: checks.append(manual),
checks=checks,
)
def test_automatic_update_check_runs_without_daily_throttle() -> None:
window = _window()
MainWindow._automatic_update_check(window)
assert window.checks == [False]
assert window.update_check_timer.start_calls == [UPDATE_CHECK_INTERVAL_MS]
assert window.update_retry_timer.stop_calls == 1
def test_automatic_update_check_retries_while_sync_is_running() -> None:
window = _window(sync_running=True)
MainWindow._automatic_update_check(window)
assert window.checks == []
assert window.update_retry_timer.start_calls == [None]
assert UPDATE_RETRY_INTERVAL_MS == 60_000
def test_periodic_update_interval_is_thirty_minutes() -> None:
assert UPDATE_CHECK_INTERVAL_MS == 30 * 60 * 1000
def test_update_indicator_uses_text_and_accessible_description() -> None:
button = FakeButton()
window = SimpleNamespace(settings_nav_button=button)
MainWindow._set_update_indicator(window, "1.0.5")
assert button.text == "Einstellungen • UPDATE"
assert button.properties["updateAvailable"] is True
assert button.tooltip == "SyncVito 1.0.5 ist verfügbar"
assert button.accessible_description == button.tooltip
assert button.button_style.unpolished and button.button_style.polished
assert button.updated
+3
View File
@@ -162,4 +162,7 @@ def test_update_watcher_waits_before_installer(monkeypatch: pytest.MonkeyPatch,
script = base64.b64decode(encoded).decode("utf-16-le") script = base64.b64decode(encoded).decode("utf-16-le")
assert "foreach ($processId in @(111, 222))" in script assert "foreach ($processId in @(111, 222))" in script
assert script.index("while (Get-Process") < script.index(str(installer)) assert script.index("while (Get-Process") < script.index(str(installer))
reset_environment = "$env:PYINSTALLER_RESET_ENVIRONMENT = '1'"
assert reset_environment in script
assert script.index(reset_environment) < script.index(f"& '{installer}'")
assert "'/VERYSILENT'" in script assert "'/VERYSILENT'" in script
+1 -1
View File
@@ -1 +1 @@
1.0.2 1.0.6