5 Commits
Author SHA1 Message Date
Davito cdcd03d7b0 Release SyncVito 1.0.13 2026-09-28 11:08:14 +02:00
Davito b6e32a1b18 Release SyncVito 1.0.12 2026-09-27 23:48:19 +02:00
Davito 786e3ae9b3 Release SyncVito 1.0.12 2026-09-27 23:46:11 +02:00
Davito ccb89191b5 Release SyncVito 1.0.11 2026-09-27 22:42:23 +02:00
Davito 7098fa1c0c Release SyncVito 1.0.10 2026-09-27 22:26:13 +02:00
15 changed files with 809 additions and 42 deletions

No files matched your search

+37
View File
@@ -1,5 +1,42 @@
# Änderungsprotokoll # Änderungsprotokoll
## 1.0.13 – 2026-09-28
### Behoben
- **Zuverlässiger Windows-Autostart via Aufgabenplanung:** Da SyncVito mit Administratorrechten (`requireAdministrator`) läuft, blockierte der Windows Explorer beim Systemstart den Aufruf aus dem Registry-`Run`-Schlüssel. Der Autostart wird nun über eine geplante Windows-Aufgabe (`RunLevel=HighestAvailable`) bei Benutzeranmeldung ausgeführt, sodass SyncVito nach dem Hochfahren zuverlässig und ohne UAC-Dialog im Infobereich startet.
- **Warteschleife für den Windows-Infobereich:** Falls der Windows Explorer beim Booten den Benachrichtigungsbereich noch nicht initialisiert hat, wartet SyncVito bis zu 15 Sekunden auf den Tray, statt sich sofort mit einem Fehler zu beenden.
- **Automatische Autostart-Migration:** Bestehende Konfigurationen mit aktiviertem Autostart werden beim Programmstart automatisch auf die geplante Aufgabe umgestellt; alte Einträge im Run-Schlüssel werden bereinigt.
- **Saubere Deinstallation:** Bei einer Deinstallation über Inno Setup wird die registrierte Windows-Aufgabe für den Autostart automatisch entfernt.
## 1.0.12 – 2026-09-27
### Behoben
- **Endlosschleife bei Wiederholungen beseitigt:** Aufgaben in Wiederholungsschleifen werden nach dem Einreihen sauber aus der Retry-Warteliste entfernt und bei reinen Fehlern ohne gesperrte Dateien nicht mehr dauerhaft im 10-Sekunden-Takt neu gestartet.
- **Wiederholungs-Backoff und Versuchslimit:** Für gesperrte Dateien greift ein exponentielles Backoff (30s, 60s, 120s bis max. 300s) sowie ein Limit von 5 automatischen Versuchen, um Festplatte, Netzwerk und Prozessor vor unnötigen Dauerläufen zu schützen.
- **Flüchtige Cache-Dateien ohne Fehlermeldung:** Dateien, die zwischen Inventarerstellung und Kopiervorgang gelöscht wurden (z. B. dynamische Browser- oder Anwendungs-Caches), werden sauber als übersprungen gewertet und brechen die Sicherung nicht mehr mit `[WinError 2]` ab.
- **Kein Einfrieren der Benutzeroberfläche:** Das automatische Weiterscrollen der Protokolltabelle bei vielen gleichzeitigen Meldungen wird gebündelt entprellt, die Abfrage des Windows Restart Managers für gesperrte Ordner wird pro Verzeichnis zwischengespeichert und Fortschrittsmeldungen werden im GUI-Thread gedrosselt.
## 1.0.11 – 2026-09-27
### Behoben
- Der Button „Pausieren“ ist im Leerlauf deaktiviert, damit SyncVito nicht versehentlich ohne aktive Aufgabe in den Pausenzustand versetzt werden kann.
- Beim Klick auf „Alle jetzt synchronisieren“ oder „Auswahl synchronisieren“ wird ein eventuell aktiver Pausenzustand automatisch aufgehoben und der Lauf sofort gestartet.
- Klickt man auf „Fortsetzen“, wenn zuvor noch keine Aufgabe eingereiht war, startet SyncVito direkt alle aktiven Aufgaben, statt wirkungslos zu bleiben.
- Die automatische Hintergrund-Updateprüfung setzt die Fortschrittsleiste im Leerlauf nicht mehr dauerhaft auf 100 % mit „SyncVito ist aktuell“, sondern die Leiste bleibt ordnungsgemäß auf „Bereit“.
- Die Tray-Benachrichtigung beim Schließen des Fensters lautet nun unmissverständlich „SyncVito minimiert“ statt „SyncVito läuft weiter“.
## 1.0.10 – 2026-09-27
### Neu
- **Etappen- und Bereichsanzeige:** Beim Synchronisieren wird der aktuelle Hauptordner (z. B. AppData, Dokumente oder definierte Unterauswahlen) zusammen mit der aktuellen Etappe (z. B. `[2/4 AppData]`) im Fortschrittsdock angezeigt, sodass stets ersichtlich ist, in welchem Bereich SyncVito gerade arbeitet und wie viele Ordner noch ausstehen.
- **Live-Status in der Aufgabenliste:** Die Aufgaben-Tabelle zeigt laufende Vorgänge live mit aktuellem Prozentsatz (z. B. `🔄 Läuft (45 %)`), wartende Aufgaben mit ihrer Listenposition (`⏳ In Warteschlange (#2)`) sowie pausierte Aufgaben an.
- **Pause- und Fortsetzen-Funktion:** Synchronisationsläufe können jederzeit über Buttons im Header, im Progress-Dock sowie im Tray-Menü pausiert und fortgesetzt werden.
- **Nahtlose Wiederaufnahme nach Neustart:** Der Pausenzustand und die verbleibende Aufgaben-Warteschlange werden dauerhaft gesichert. Nach einem Neustart des Tools oder des Rechners setzt SyncVito genau an der unterbrochenen Stelle fort; bereits kopierte Dateien werden anhand ihrer Signatur sofort übersprungen.
## 1.0.9 – 2026-09-24 ## 1.0.9 – 2026-09-24
### Behoben ### Behoben
+17 -1
View File
@@ -150,7 +150,23 @@ VSVersionInfo(
if ($LASTEXITCODE -ne 0) { throw "Der Release-Commit ist fehlgeschlagen." } if ($LASTEXITCODE -ne 0) { throw "Der Release-Commit ist fehlgeschlagen." }
} }
$sourceCommitted = $true $sourceCommitted = $true
git push origin main $pushTarget = "origin"
$tokenPath = @(
(Join-Path $env:LOCALAPPDATA "SyncVito\developer\gitea-token.dpapi"),
(Join-Path $env:LOCALAPPDATA "MusicBrain\developer\gitea-token.dpapi")
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
if (-not [string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) {
$pushTarget = "https://Davito:$($env:GITEA_TOKEN.Trim())@gitea.davito.synology.me/Davito/SyncVito.git"
}
elseif ($tokenPath) {
try {
$sec = (Get-Content $tokenPath -Raw).Trim() | ConvertTo-SecureString
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
$t = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
$pushTarget = "https://Davito:$t@gitea.davito.synology.me/Davito/SyncVito.git"
} catch { }
}
git push $pushTarget main
if ($LASTEXITCODE -ne 0) { throw "Der Quellstand konnte nicht nach Gitea gepusht werden." } if ($LASTEXITCODE -ne 0) { throw "Der Quellstand konnte nicht nach Gitea gepusht werden." }
} }
+3
View File
@@ -55,3 +55,6 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de
[Run] [Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{#MyAppName} starten"; Flags: nowait postinstall skipifsilent runascurrentuser Filename: "{app}\{#MyAppExeName}"; Description: "{#MyAppName} starten"; Flags: nowait postinstall skipifsilent runascurrentuser
Filename: "{app}\{#MyAppExeName}"; Parameters: "--updated"; Flags: nowait skipifnotsilent runascurrentuser Filename: "{app}\{#MyAppExeName}"; Parameters: "--updated"; Flags: nowait skipifnotsilent runascurrentuser
[UninstallRun]
Filename: "schtasks.exe"; Parameters: "/delete /tn ""{#MyAppName}"" /f"; Flags: runhidden
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "syncvito" name = "syncvito"
version = "1.0.9" version = "1.0.13"
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"
+20 -2
View File
@@ -2,12 +2,15 @@
from __future__ import annotations from __future__ import annotations
import os
import sys import sys
import time
from pathlib import Path from pathlib import Path
from PyQt6.QtCore import QLockFile, QStandardPaths from PyQt6.QtCore import QLockFile, QStandardPaths
from PyQt6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon from PyQt6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
from syncvito.autostart import set_autostart
from syncvito.config_store import ConfigError, ConfigStore from syncvito.config_store import ConfigError, ConfigStore
from syncvito.icons import app_icon from syncvito.icons import app_icon
from syncvito.main_window import MainWindow from syncvito.main_window import MainWindow
@@ -18,6 +21,19 @@ from syncvito.version import __version__
__all__ = ["main"] __all__ = ["main"]
def _wait_for_system_tray(application: QApplication, timeout_seconds: float = 15.0) -> bool:
"""Wartet beim Windows-Start auf die Verfügbarkeit des Infobereichs."""
if QSystemTrayIcon.isSystemTrayAvailable():
return True
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
application.processEvents()
time.sleep(0.25)
if QSystemTrayIcon.isSystemTrayAvailable():
return True
return False
def main() -> int: def main() -> int:
"""Startet SyncVito als einzelne Windows-Instanz.""" """Startet SyncVito als einzelne Windows-Instanz."""
if "--version" in sys.argv: if "--version" in sys.argv:
@@ -33,11 +49,11 @@ def main() -> int:
lock_path = Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.TempLocation)) / "syncvito.lock" lock_path = Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.TempLocation)) / "syncvito.lock"
lock = QLockFile(str(lock_path)) lock = QLockFile(str(lock_path))
lock.setStaleLockTime(0) lock.setStaleLockTime(30000)
if not lock.tryLock(100): if not lock.tryLock(100):
QMessageBox.information(None, "SyncVito läuft bereits", "SyncVito ist bereits im Infobereich aktiv.") QMessageBox.information(None, "SyncVito läuft bereits", "SyncVito ist bereits im Infobereich aktiv.")
return 0 return 0
if not QSystemTrayIcon.isSystemTrayAvailable(): if not _wait_for_system_tray(application):
QMessageBox.critical(None, "Infobereich nicht verfügbar", "SyncVito benötigt den Windows-Infobereich.") QMessageBox.critical(None, "Infobereich nicht verfügbar", "SyncVito benötigt den Windows-Infobereich.")
return 1 return 1
@@ -47,6 +63,8 @@ def main() -> int:
except ConfigError as exc: except ConfigError as exc:
QMessageBox.warning(None, "Konfiguration zurückgesetzt", str(exc)) QMessageBox.warning(None, "Konfiguration zurückgesetzt", str(exc))
config = AppConfig() config = AppConfig()
if os.name == "nt" and config.settings.autostart:
set_autostart(True)
apply_theme(application, config.settings) apply_theme(application, config.settings)
window = MainWindow(config, store) window = MainWindow(config, store)
if "--minimized" not in sys.argv: if "--minimized" not in sys.argv:
+136 -11
View File
@@ -3,29 +3,154 @@
from __future__ import annotations from __future__ import annotations
import os import os
import subprocess
import sys import sys
import tempfile
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from xml.sax.saxutils import escape as xml_escape
__all__ = ["set_autostart"] __all__ = ["RUN_KEY_PATH", "TASK_NAME", "generate_task_xml", "set_autostart"]
TASK_NAME = "SyncVito"
RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
def set_autostart(enabled: bool) -> None: def generate_task_xml(command_exe: Path, arguments: str = "--minimized") -> str:
"""Setzt oder entfernt SyncVito unter HKCU\\...\\Run.""" """Erzeugt die XML-Definition für die Windows-Aufgabenplanung."""
escaped_exe = xml_escape(str(command_exe.resolve()))
escaped_args = xml_escape(arguments)
return (
'<?xml version="1.0" encoding="UTF-16"?>\n'
'<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">\n'
" <RegistrationInfo>\n"
" <Description>SyncVito Windows Autostart</Description>\n"
" </RegistrationInfo>\n"
" <Triggers>\n"
" <LogonTrigger>\n"
" <Enabled>true</Enabled>\n"
" <Delay>PT5S</Delay>\n"
" </LogonTrigger>\n"
" </Triggers>\n"
" <Principals>\n"
' <Principal id="Author">\n'
" <LogonType>InteractiveToken</LogonType>\n"
" <RunLevel>HighestAvailable</RunLevel>\n"
" </Principal>\n"
" </Principals>\n"
" <Settings>\n"
" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n"
" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n"
" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n"
" <AllowHardTerminate>true</AllowHardTerminate>\n"
" <StartWhenAvailable>true</StartWhenAvailable>\n"
" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n"
" <IdleSettings>\n"
" <StopOnIdleEnd>false</StopOnIdleEnd>\n"
" <RestartOnIdle>false</RestartOnIdle>\n"
" </IdleSettings>\n"
" <AllowStartOnDemand>true</AllowStartOnDemand>\n"
" <Enabled>true</Enabled>\n"
" <Hidden>false</Hidden>\n"
" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n"
" <WakeToRun>false</WakeToRun>\n"
" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n"
" <Priority>7</Priority>\n"
" </Settings>\n"
' <Actions Context="Author">\n'
" <Exec>\n"
f" <Command>{escaped_exe}</Command>\n"
f" <Arguments>{escaped_args}</Arguments>\n"
" </Exec>\n"
" </Actions>\n"
"</Task>"
)
def _run_schtasks(args: list[str]) -> subprocess.CompletedProcess[str]:
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
return subprocess.run(
["schtasks", *args],
capture_output=True,
text=True,
creationflags=flags,
check=False,
)
def _delete_run_key_entry() -> None:
if os.name != "nt": if os.name != "nt":
return return
import winreg import winreg
key_path = r"Software\Microsoft\Windows\CurrentVersion\Run" with (
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as key: suppress(FileNotFoundError, OSError),
winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key,
):
winreg.DeleteValue(key, TASK_NAME)
def _set_run_key_entry(command: str) -> None:
if os.name != "nt":
return
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
winreg.SetValueEx(key, TASK_NAME, 0, winreg.REG_SZ, command)
def _register_scheduled_task(command_exe: Path, arguments: str) -> bool:
xml_content = generate_task_xml(command_exe, arguments)
fd, tmp_file = tempfile.mkstemp(suffix=".xml")
tmp_path = Path(tmp_file)
try:
with os.fdopen(fd, "w", encoding="utf-16") as f:
f.write(xml_content)
result = _run_schtasks(["/create", "/tn", TASK_NAME, "/xml", str(tmp_path), "/f"])
return result.returncode == 0
except OSError:
return False
finally:
with suppress(OSError):
tmp_path.unlink()
def _delete_scheduled_task() -> bool:
result = _run_schtasks(["/delete", "/tn", TASK_NAME, "/f"])
return result.returncode == 0
def set_autostart(enabled: bool) -> bool:
"""Aktiviert oder entfernt SyncVito im Windows-Autostart.
Da SyncVito Administratorrechte (requireAdministrator) benötigt,
ignoriert der Windows-Explorer Einträge unter HKCU\\...\\Run beim Systemstart
vollständig, um unerwünschte UAC-Abfragen beim Logon zu verhindern.
Für erhöhte Anwendungen muss daher ein Scheduled Task in der Windows-
Aufgabenplanung mit 'RunLevel=HighestAvailable' verwendet werden.
"""
if os.name != "nt":
return False
if enabled: if enabled:
executable = Path(sys.executable)
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
command = f'"{executable}" --minimized' command_exe = Path(sys.executable)
arguments = "--minimized"
reg_command = f'"{command_exe}" --minimized'
else: else:
command_exe = Path(sys.executable)
entry = Path(__file__).resolve().parents[2] / "main.py" entry = Path(__file__).resolve().parents[2] / "main.py"
command = f'"{executable}" "{entry}" --minimized' arguments = f'"{entry}" --minimized'
winreg.SetValueEx(key, "SyncVito", 0, winreg.REG_SZ, command) reg_command = f'"{command_exe}" "{entry}" --minimized'
if _register_scheduled_task(command_exe, arguments):
_delete_run_key_entry()
return True
# Fallback auf Run-Schlüssel (z. B. nicht-erhöhte Entwicklungsumgebung)
_set_run_key_entry(reg_command)
return False
else: else:
with suppress(FileNotFoundError): _delete_scheduled_task()
winreg.DeleteValue(key, "SyncVito") _delete_run_key_entry()
return True
+149 -12
View File
@@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
import threading
import traceback import traceback
from collections import deque from collections import deque
from contextlib import suppress
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -40,15 +42,17 @@ class _WorkerSignals(QObject):
class _SyncWorker(QRunnable): class _SyncWorker(QRunnable):
def __init__(self, task: SyncTask, engine: SyncEngine) -> None: def __init__(self, task: SyncTask, engine: SyncEngine, pause_event: threading.Event | None = None) -> None:
super().__init__() super().__init__()
self.task = task self.task = task
self.engine = engine self.engine = engine
self.pause_event = pause_event
self.signals = _WorkerSignals() self.signals = _WorkerSignals()
def run(self) -> None: def run(self) -> None:
try: try:
result = self.engine.synchronize(self.task, self.signals.progress.emit) cancel_check = self.pause_event.is_set if self.pause_event else None
result = self.engine.synchronize(self.task, self.signals.progress.emit, cancel_check=cancel_check)
except UnavailablePathError as exc: except UnavailablePathError as exc:
self.signals.failed.emit(self.task.id, str(exc), True) self.signals.failed.emit(self.task.id, str(exc), True)
except Exception as exc: # noqa: BLE001 - Worker-Grenze muss UI-Absturz verhindern. except Exception as exc: # noqa: BLE001 - Worker-Grenze muss UI-Absturz verhindern.
@@ -79,9 +83,13 @@ class SyncCoordinator(QObject):
self.engine = SyncEngine() self.engine = SyncEngine()
self.pool = QThreadPool.globalInstance() self.pool = QThreadPool.globalInstance()
self.pool.setMaxThreadCount(max(2, self.pool.maxThreadCount())) self.pool.setMaxThreadCount(max(2, self.pool.maxThreadCount()))
self._pause_event = threading.Event()
self._paused_task_id: str | None = None
self._queue: deque[str] = deque() self._queue: deque[str] = deque()
self._running_task_id: str | None = None self._running_task_id: str | None = None
self._retry_tasks: dict[str, list[Path]] = {} self._retry_tasks: dict[str, list[Path]] = {}
self._retry_attempts: dict[str, int] = {}
self._retry_cooldowns: dict[str, datetime] = {}
self._lock_messages: dict[str, str] = {} self._lock_messages: dict[str, str] = {}
self._task_states: dict[str, TaskRuntimeState] = { self._task_states: dict[str, TaskRuntimeState] = {
task.id: TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED for task in config.tasks task.id: TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED for task in config.tasks
@@ -91,6 +99,13 @@ class SyncCoordinator(QObject):
self._bridge.changed.connect(self._on_realtime_event) self._bridge.changed.connect(self._on_realtime_event)
self.monitor = RealtimeMonitor(self._bridge.changed.emit) self.monitor = RealtimeMonitor(self._bridge.changed.emit)
if self.config.settings.pending_queue:
for task_id in self.config.settings.pending_queue:
if any(t.id == task_id and t.enabled for t in self.config.tasks) and task_id not in self._queue:
self._queue.append(task_id)
if self.config.settings.paused and self._queue:
self._paused_task_id = self._queue[0]
self._schedule_timer = QTimer(self) self._schedule_timer = QTimer(self)
self._schedule_timer.setInterval(30_000) self._schedule_timer.setInterval(30_000)
self._schedule_timer.timeout.connect(self._check_intervals) self._schedule_timer.timeout.connect(self._check_intervals)
@@ -101,6 +116,21 @@ class SyncCoordinator(QObject):
self._retry_timer.start() self._retry_timer.start()
self.rebuild_monitors() self.rebuild_monitors()
@property
def running_task_id(self) -> str | None:
"""ID der aktuell ausgeführten Aufgabe."""
return self._running_task_id
@property
def queued_task_ids(self) -> list[str]:
"""IDs aller Aufgaben in der aktuellen Warteschlange."""
return list(self._queue)
@property
def paused_task_id(self) -> str | None:
"""ID der zuletzt unterbrochenen Aufgabe."""
return self._paused_task_id
def task_state(self, task_id: str) -> TaskRuntimeState: def task_state(self, task_id: str) -> TaskRuntimeState:
"""Liefert den aktuellen Engine-Zustand einer Aufgabe.""" """Liefert den aktuellen Engine-Zustand einer Aufgabe."""
return self._task_states.get(task_id, TaskRuntimeState.PAUSED) return self._task_states.get(task_id, TaskRuntimeState.PAUSED)
@@ -114,6 +144,8 @@ class SyncCoordinator(QObject):
known_ids = {task.id for task in self.config.tasks} 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._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._retry_tasks = {task_id: paths for task_id, paths in self._retry_tasks.items() if task_id in known_ids}
self._retry_attempts = {task_id: att for task_id, att in self._retry_attempts.items() if task_id in known_ids}
self._retry_cooldowns = {task_id: cd for task_id, cd in self._retry_cooldowns.items() if task_id in known_ids}
self._lock_messages = { self._lock_messages = {
task_id: message for task_id, message in self._lock_messages.items() if task_id in known_ids task_id: message for task_id, message in self._lock_messages.items() if task_id in known_ids
} }
@@ -124,6 +156,8 @@ class SyncCoordinator(QObject):
self._set_task_state(task.id, desired) self._set_task_state(task.id, desired)
if not task.enabled: if not task.enabled:
self._retry_tasks.pop(task.id, None) self._retry_tasks.pop(task.id, None)
self._retry_attempts.pop(task.id, None)
self._retry_cooldowns.pop(task.id, None)
self._lock_messages.pop(task.id, None) self._lock_messages.pop(task.id, None)
enabled_ids = {task.id for task in self.config.tasks if task.enabled} 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._queue = deque(task_id for task_id in self._queue if task_id in enabled_ids)
@@ -135,20 +169,41 @@ class SyncCoordinator(QObject):
"""Zeigt an, ob momentan ein Worker aktiv ist.""" """Zeigt an, ob momentan ein Worker aktiv ist."""
return self._running_task_id is not None return self._running_task_id is not None
def _save_pending_queue(self) -> None:
pending: list[str] = []
if self._running_task_id is not None:
pending.append(self._running_task_id)
for tid in self._queue:
if tid not in pending:
pending.append(tid)
self.config.settings.pending_queue = pending
with suppress(Exception):
self.config_store.save(self.config)
def enqueue(self, task_id: str) -> None: def enqueue(self, task_id: str) -> None:
"""Stellt eine Aufgabe dedupliziert in die serielle Warteschlange.""" """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) task = self._task(task_id)
if task is None or not task.enabled: if task is None or not task.enabled:
return return
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
if self.config.settings.paused:
if task_id != self._running_task_id and task_id not in self._queue: if task_id != self._running_task_id and task_id not in self._queue:
self._queue.append(task_id) self._queue.append(task_id)
self._save_pending_queue()
self._set_task_state(task_id, TaskRuntimeState.PAUSED)
self.log_created.emit("Synchronisation", "Hinweis", "SyncVito ist pausiert. Aufgabe eingereiht.")
return
if task_id != self._running_task_id and task_id not in self._queue:
self._queue.append(task_id)
self._save_pending_queue()
self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
self._start_next() self._start_next()
def enqueue_all(self) -> None: def enqueue_all(self) -> None:
"""Stellt alle aktiven Aufgaben zur manuellen Ausführung an.""" """Stellt alle aktiven Aufgaben zur manuellen Ausführung an."""
if self.config.settings.paused:
self.set_paused(False)
for task in self.config.tasks: for task in self.config.tasks:
if task.enabled: if task.enabled:
self.enqueue(task.id) self.enqueue(task.id)
@@ -156,19 +211,35 @@ class SyncCoordinator(QObject):
def set_paused(self, paused: bool) -> None: def set_paused(self, paused: bool) -> None:
"""Ändert den globalen Pausenzustand.""" """Ändert den globalen Pausenzustand."""
self.config.settings.paused = paused self.config.settings.paused = paused
self.config_store.save(self.config)
if paused: if paused:
self.log_created.emit("Status", "Hinweis", "Alle automatischen Auslöser sind pausiert.") self._pause_event.set()
self._save_pending_queue()
self.log_created.emit("Status", "Hinweis", "Synchronisation pausiert.")
else: else:
self._pause_event.clear()
self._paused_task_id = None
self.log_created.emit("Status", "Erfolg", "Synchronisation fortgesetzt.") self.log_created.emit("Status", "Erfolg", "Synchronisation fortgesetzt.")
if not self._queue and self.config.settings.pending_queue:
for tid in self.config.settings.pending_queue:
if tid not in self._queue and any(t.id == tid and t.enabled for t in self.config.tasks):
self._queue.append(tid)
if not self._queue and not self._running_task_id:
for task in self.config.tasks:
if task.enabled:
self._queue.append(task.id)
self._start_next() self._start_next()
with suppress(Exception):
self.config_store.save(self.config)
self._emit_overall_status() self._emit_overall_status()
self.refresh_task_states()
def rebuild_monitors(self) -> None: def rebuild_monitors(self) -> None:
"""Aktualisiert alle Dateisystemüberwachungen.""" """Aktualisiert alle Dateisystemüberwachungen."""
errors = self.monitor.rebuild(self.config.tasks) errors = self.monitor.rebuild(self.config.tasks)
for task_id, message in errors: for task_id, message in errors:
self._retry_tasks[task_id] = [] self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
self._set_task_state(task_id, TaskRuntimeState.ERROR) self._set_task_state(task_id, TaskRuntimeState.ERROR)
self.log_created.emit("Echtzeit", "Fehler", message) self.log_created.emit("Echtzeit", "Fehler", message)
self._emit_overall_status() self._emit_overall_status()
@@ -176,6 +247,14 @@ class SyncCoordinator(QObject):
def stop(self) -> None: def stop(self) -> None:
"""Stoppt die Dateisystemüberwachung beim Beenden.""" """Stoppt die Dateisystemüberwachung beim Beenden."""
self.monitor.stop() self.monitor.stop()
if self._running_task_id is not None:
self._pause_event.set()
self._save_pending_queue()
self.config.settings.paused = True
with suppress(Exception):
self.config_store.save(self.config)
if hasattr(self.pool, "waitForDone"):
self.pool.waitForDone(1500)
def _start_next(self) -> None: def _start_next(self) -> None:
if self._running_task_id is not None or self.config.settings.paused or not self._queue: if self._running_task_id is not None or self.config.settings.paused or not self._queue:
@@ -186,11 +265,14 @@ class SyncCoordinator(QObject):
self._start_next() self._start_next()
return return
self._running_task_id = task_id self._running_task_id = task_id
self._paused_task_id = None
self._save_pending_queue()
self.progress_changed.emit(0, f"{task.name}: Synchronisation wird gestartet …") self.progress_changed.emit(0, f"{task.name}: Synchronisation wird gestartet …")
self.idle_changed.emit(False) self.idle_changed.emit(False)
self._emit_overall_status() self._emit_overall_status()
self.log_created.emit(task.name, "Start", "Synchronisation gestartet.") self.log_created.emit(task.name, "Start", "Synchronisation gestartet.")
worker = _SyncWorker(task, self.engine) self._pause_event.clear()
worker = _SyncWorker(task, self.engine, self._pause_event)
worker.signals.progress.connect(self.progress_changed) worker.signals.progress.connect(self.progress_changed)
worker.signals.finished.connect(self._on_finished) worker.signals.finished.connect(self._on_finished)
worker.signals.failed.connect(self._on_failed) worker.signals.failed.connect(self._on_failed)
@@ -201,8 +283,26 @@ class SyncCoordinator(QObject):
if task is None: if task is None:
self._complete_current() self._complete_current()
return return
if result.paused:
self._paused_task_id = task_id
if task_id not in self._queue:
self._queue.appendleft(task_id)
self._save_pending_queue()
self._set_task_state(task_id, TaskRuntimeState.PAUSED)
self.log_created.emit(task.name, "Pausiert", f"Synchronisation bei {result.copied} Dateien angehalten.")
self._running_task_id = None
self.progress_changed.emit(0, f"{task.name}: Pausiert – zum Fortsetzen klicken")
self.idle_changed.emit(True)
self._emit_overall_status()
return
task.last_run = datetime.now().isoformat(timespec="seconds") task.last_run = datetime.now().isoformat(timespec="seconds")
if task_id in self.config.settings.pending_queue:
self.config.settings.pending_queue.remove(task_id)
with suppress(Exception):
self.config_store.save(self.config) self.config_store.save(self.config)
if result.blocked: if result.blocked:
paths = [blocked.path for blocked in result.blocked] paths = [blocked.path for blocked in result.blocked]
self._retry_tasks[task_id] = paths self._retry_tasks[task_id] = paths
@@ -225,12 +325,17 @@ class SyncCoordinator(QObject):
self._emit_lock_summary() self._emit_lock_summary()
if result.errors: if result.errors:
# Auch zusätzliche Fehler eines teilweise blockierten Laufs sichtbar halten. # Auch zusätzliche Fehler eines teilweise blockierten Laufs sichtbar halten.
self._retry_tasks[task_id] = [] if not result.blocked:
self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
for error in result.errors: for error in result.errors:
self.log_created.emit(task.name, "Fehler", error) self.log_created.emit(task.name, "Fehler", error)
self._set_task_state(task_id, TaskRuntimeState.ERROR) self._set_task_state(task_id, TaskRuntimeState.ERROR)
elif not result.blocked: elif not result.blocked:
self._retry_tasks.pop(task_id, None) self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien" summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien"
self.log_created.emit(task.name, "Erfolg", summary) self.log_created.emit(task.name, "Erfolg", summary)
self._set_task_state(task_id, TaskRuntimeState.ACTIVE) self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
@@ -239,7 +344,9 @@ class SyncCoordinator(QObject):
def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None: def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None:
task = self._task(task_id) task = self._task(task_id)
name = task.name if task else "Aufgabe" name = task.name if task else "Aufgabe"
self._retry_tasks[task_id] = [] self._retry_tasks.pop(task_id, None)
self._retry_attempts.pop(task_id, None)
self._retry_cooldowns.pop(task_id, None)
self._lock_messages.pop(task_id, None) self._lock_messages.pop(task_id, None)
self._emit_lock_summary() self._emit_lock_summary()
self._set_task_state(task_id, TaskRuntimeState.ERROR) self._set_task_state(task_id, TaskRuntimeState.ERROR)
@@ -251,6 +358,9 @@ class SyncCoordinator(QObject):
self.progress_changed.emit(100, "Abgeschlossen") self.progress_changed.emit(100, "Abgeschlossen")
if not self._queue: if not self._queue:
self.idle_changed.emit(True) self.idle_changed.emit(True)
self.config.settings.pending_queue = []
with suppress(Exception):
self.config_store.save(self.config)
self._emit_overall_status() self._emit_overall_status()
QTimer.singleShot(200, self._start_next) QTimer.singleShot(200, self._start_next)
@@ -279,9 +389,36 @@ class SyncCoordinator(QObject):
def _check_retries(self) -> None: def _check_retries(self) -> None:
if self.config.settings.paused or self.is_running: if self.config.settings.paused or self.is_running:
return return
now = datetime.now()
ready_tasks: list[str] = []
for task_id, paths in list(self._retry_tasks.items()): for task_id, paths in list(self._retry_tasks.items()):
if paths and all(is_file_locked(path) for path in paths): cooldown_until = self._retry_cooldowns.get(task_id)
if cooldown_until and now < cooldown_until:
continue continue
attempts = self._retry_attempts.get(task_id, 0)
if attempts >= 5:
if attempts == 5:
task = self._task(task_id)
name = task.name if task else task_id
self.log_created.emit(
name,
"Hinweis",
"Automatische Wiederholungen vorübergehend ausgesetzt. Bitte sperrende Programme schließen.",
)
self._retry_attempts[task_id] = attempts + 1
continue
check_paths = paths[:10] if len(paths) > 10 else paths
if paths and all(is_file_locked(path) for path in check_paths):
continue
ready_tasks.append(task_id)
for task_id in ready_tasks:
self._retry_tasks.pop(task_id, None)
attempts = self._retry_attempts.get(task_id, 0) + 1
self._retry_attempts[task_id] = attempts
backoff_seconds = min(300, 30 * (2 ** min(attempts - 1, 4)))
self._retry_cooldowns[task_id] = now + timedelta(seconds=backoff_seconds)
self.log_created.emit( self.log_created.emit(
"Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft." "Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft."
) )
+96 -6
View File
@@ -140,8 +140,8 @@ class MainWindow(QMainWindow):
event.ignore() event.ignore()
self.hide() self.hide()
self.tray.showMessage( self.tray.showMessage(
"SyncVito läuft weiter", "SyncVito minimiert",
"Das Fenster wurde in den Infobereich verschoben.", "SyncVito bleibt im Infobereich aktiv.",
QSystemTrayIcon.MessageIcon.Information, QSystemTrayIcon.MessageIcon.Information,
2500, 2500,
) )
@@ -235,6 +235,9 @@ class MainWindow(QMainWindow):
self.sync_all_button.setProperty("primary", True) self.sync_all_button.setProperty("primary", True)
self.sync_all_button.clicked.connect(self.coordinator.enqueue_all) self.sync_all_button.clicked.connect(self.coordinator.enqueue_all)
layout.addWidget(self.sync_all_button, 0, Qt.AlignmentFlag.AlignVCenter) layout.addWidget(self.sync_all_button, 0, Qt.AlignmentFlag.AlignVCenter)
self.header_pause_button = QPushButton("Pausieren")
self.header_pause_button.clicked.connect(self._toggle_pause)
layout.addWidget(self.header_pause_button, 0, Qt.AlignmentFlag.AlignVCenter)
return header return header
def _build_tasks_page(self) -> QWidget: def _build_tasks_page(self) -> QWidget:
@@ -418,8 +421,14 @@ class MainWindow(QMainWindow):
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 %")
self.dock_pause_button = QPushButton("Pausieren")
self.dock_pause_button.setObjectName("DockPauseButton")
self.dock_pause_button.setFixedHeight(34)
if hasattr(self, "_toggle_pause"):
self.dock_pause_button.clicked.connect(self._toggle_pause)
layout.addWidget(self.progress_text) layout.addWidget(self.progress_text)
layout.addWidget(self.progress_bar) layout.addWidget(self.progress_bar)
layout.addWidget(self.dock_pause_button)
layout.addStretch(1) layout.addStretch(1)
return dock return dock
@@ -478,6 +487,11 @@ class MainWindow(QMainWindow):
def _refresh_tasks(self) -> None: def _refresh_tasks(self) -> None:
self.task_table.setRowCount(len(self.config.tasks)) self.task_table.setRowCount(len(self.config.tasks))
running_id = self.coordinator.running_task_id
queued_ids = self.coordinator.queued_task_ids
paused_id = self.coordinator.paused_task_id
is_globally_paused = self.config.settings.paused
for row, task in enumerate(self.config.tasks): for row, task in enumerate(self.config.tasks):
name = QTableWidgetItem(task.name) name = QTableWidgetItem(task.name)
name.setData(Qt.ItemDataRole.UserRole, task.id) name.setData(Qt.ItemDataRole.UserRole, task.id)
@@ -489,9 +503,26 @@ class MainWindow(QMainWindow):
TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.", TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.",
}[task.trigger] }[task.trigger]
trigger = QTableWidgetItem(trigger_text) trigger = QTableWidgetItem(trigger_text)
status = QTableWidgetItem(self._runtime_state_text(self.coordinator.task_state(task.id)))
base_state = self.coordinator.task_state(task.id)
if task.id == running_id:
val = self.progress_bar.value()
status_text = f"🔄 Läuft ({val}%)"
tip = f"Wird synchronisiert ({val}%)\n{self.progress_text.toolTip()}"
elif is_globally_paused and (task.id == paused_id or task.id in queued_ids):
status_text = "⏸ Pausiert"
tip = "Aufgabe pausiert – setzt beim Klick auf Fortsetzen fort."
elif task.id in queued_ids:
pos = queued_ids.index(task.id) + 1
status_text = f"⏳ In Warteschlange (#{pos})"
tip = f"Steht an Position {pos} der Warteschlange"
else:
status_text = self._runtime_state_text(base_state)
tip = status_text
status = QTableWidgetItem(status_text)
for column, item in enumerate((name, pair, direction, trigger, status)): for column, item in enumerate((name, pair, direction, trigger, status)):
item.setToolTip(item.text()) item.setToolTip(tip if column == 4 else item.text())
self.task_table.setItem(row, column, item) self.task_table.setItem(row, column, item)
def _selected_task(self) -> SyncTask | None: def _selected_task(self) -> SyncTask | None:
@@ -541,6 +572,8 @@ class MainWindow(QMainWindow):
if task is None: if task is None:
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.") QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
return return
if self.config.settings.paused:
self.coordinator.set_paused(False)
self.coordinator.enqueue(task.id) self.coordinator.enqueue(task.id)
def _persist_tasks(self, message: str) -> None: def _persist_tasks(self, message: str) -> None:
@@ -621,10 +654,18 @@ class MainWindow(QMainWindow):
self._mark_update_checked() self._mark_update_checked()
self._set_update_indicator(None) self._set_update_indicator(None)
self._notified_update_version = None self._notified_update_version = None
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:
self._update_progress(100, "SyncVito ist aktuell")
QMessageBox.information(self, "Keine Updates", "Du verwendest bereits die aktuelle SyncVito-Version.") QMessageBox.information(self, "Keine Updates", "Du verwendest bereits die aktuelle SyncVito-Version.")
elif not self.coordinator.is_running:
self.progress_text.setText("SyncVito ist aktuell")
QTimer.singleShot(3000, self._reset_idle_progress)
def _reset_idle_progress(self) -> None:
if not self.coordinator.is_running and not self.config.settings.paused:
self.progress_text.setText("Bereit")
self.progress_bar.setValue(0)
def _on_update_error(self, message: str, manual: bool) -> None: def _on_update_error(self, message: str, manual: bool) -> None:
self._update_progress(0, "Updateprüfung fehlgeschlagen") self._update_progress(0, "Updateprüfung fehlgeschlagen")
@@ -700,17 +741,57 @@ class MainWindow(QMainWindow):
self.tray_sync_action.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.update_button.setEnabled(update_idle and sync_idle)
self.tray_update_action.setEnabled(update_idle and sync_idle) self.tray_update_action.setEnabled(update_idle and sync_idle)
self._update_pause_action()
self._refresh_tasks()
def _toggle_pause(self) -> None: def _toggle_pause(self) -> None:
self.coordinator.set_paused(not self.config.settings.paused) self.coordinator.set_paused(not self.config.settings.paused)
self._update_pause_action() self._update_pause_action()
def _update_pause_action(self) -> None: def _update_pause_action(self) -> None:
self.pause_action.setText("Fortsetzen" if self.config.settings.paused else "Pausieren") is_paused = self.config.settings.paused
is_running = self.coordinator.is_running
has_queue = bool(self.coordinator.queued_task_ids)
if is_paused:
btn_text = "▶ Fortsetzen"
tray_text = "Fortsetzen"
btn_enabled = True
primary = True
elif is_running or has_queue:
btn_text = "⏸ Pausieren"
tray_text = "Pausieren"
btn_enabled = True
primary = False
else:
btn_text = "Pausieren"
tray_text = "Pausieren"
btn_enabled = False
primary = False
self.pause_action.setText(tray_text)
self.pause_action.setEnabled(btn_enabled)
for btn in (getattr(self, "header_pause_button", None), getattr(self, "dock_pause_button", None)):
if btn is not None:
btn.setText(btn_text)
btn.setEnabled(btn_enabled)
btn.setProperty("primary", primary)
btn.style().unpolish(btn)
btn.style().polish(btn)
def _update_progress(self, value: int, detail: str) -> None: def _update_progress(self, value: int, detail: str) -> None:
self.progress_bar.setValue(value) self.progress_bar.setValue(value)
self.progress_text.setText(detail) self.progress_text.setText(detail)
running_id = self.coordinator.running_task_id
if running_id:
for row in range(self.task_table.rowCount()):
item = self.task_table.item(row, 0)
if item and item.data(Qt.ItemDataRole.UserRole) == running_id:
status_item = self.task_table.item(row, 4)
if status_item:
status_item.setText(f"🔄 Läuft ({value}%)")
status_item.setToolTip(detail)
break
def _append_log(self, action: str, level: str, detail: str) -> None: def _append_log(self, action: str, level: str, detail: str) -> None:
row = self.log_table.rowCount() row = self.log_table.rowCount()
@@ -722,6 +803,15 @@ class MainWindow(QMainWindow):
self.log_table.setItem(row, column, item) self.log_table.setItem(row, column, item)
if self.log_table.rowCount() > 1000: if self.log_table.rowCount() > 1000:
self.log_table.removeRow(0) self.log_table.removeRow(0)
self._schedule_log_scroll()
def _schedule_log_scroll(self) -> None:
if not getattr(self, "_log_scroll_pending", False):
self._log_scroll_pending = True
QTimer.singleShot(50, self._do_scroll_log)
def _do_scroll_log(self) -> None:
self._log_scroll_pending = False
self.log_table.scrollToBottom() self.log_table.scrollToBottom()
def _set_global_status(self, status: str, text: str) -> None: def _set_global_status(self, status: str, text: str) -> None:
+3
View File
@@ -137,6 +137,7 @@ class AppSettings:
font_weight: str = "regular" font_weight: str = "regular"
ui_scale: int = 100 ui_scale: int = 100
paused: bool = False paused: bool = False
pending_queue: list[str] = field(default_factory=list)
check_updates_on_start: bool = True check_updates_on_start: bool = True
last_update_check: str | None = None last_update_check: str | None = None
@@ -148,6 +149,7 @@ class AppSettings:
self.font_weight = "regular" self.font_weight = "regular"
if not self.font_family.strip(): if not self.font_family.strip():
self.font_family = "Segoe UI Variable" self.font_family = "Segoe UI Variable"
self.pending_queue = [str(item) for item in self.pending_queue if str(item).strip()]
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
"""Serialisiert die Einstellungen.""" """Serialisiert die Einstellungen."""
@@ -163,6 +165,7 @@ class AppSettings:
font_weight=str(data.get("font_weight", "regular")), font_weight=str(data.get("font_weight", "regular")),
ui_scale=int(data.get("ui_scale", 100)), ui_scale=int(data.get("ui_scale", 100)),
paused=bool(data.get("paused", False)), paused=bool(data.get("paused", False)),
pending_queue=[str(item) for item in data.get("pending_queue", []) if str(item).strip()],
check_updates_on_start=bool(data.get("check_updates_on_start", True)), check_updates_on_start=bool(data.get("check_updates_on_start", True)),
last_update_check=data.get("last_update_check"), last_update_check=data.get("last_update_check"),
) )
+84 -7
View File
@@ -61,6 +61,7 @@ class SyncResult:
deleted: int = 0 deleted: int = 0
conflicts: int = 0 conflicts: int = 0
skipped: int = 0 skipped: int = 0
paused: bool = False
blocked: list[BlockedFile] = field(default_factory=list) blocked: list[BlockedFile] = field(default_factory=list)
errors: list[str] = field(default_factory=list) errors: list[str] = field(default_factory=list)
inaccessible: bool = False inaccessible: bool = False
@@ -68,7 +69,7 @@ class SyncResult:
@property @property
def successful(self) -> bool: def successful(self) -> bool:
"""Zeigt an, ob der Lauf ohne Fehler und Sperren abgeschlossen wurde.""" """Zeigt an, ob der Lauf ohne Fehler und Sperren abgeschlossen wurde."""
return not self.errors and not self.blocked and not self.inaccessible return not self.errors and not self.blocked and not self.inaccessible and not self.paused
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -86,16 +87,31 @@ class SyncEngine:
def __init__(self, state_store: StateStore | None = None) -> None: def __init__(self, state_store: StateStore | None = None) -> None:
self.state_store = state_store or StateStore() self.state_store = state_store or StateStore()
def synchronize(self, task: SyncTask, progress: ProgressCallback | None = None) -> SyncResult: def synchronize(
self,
task: SyncTask,
progress: ProgressCallback | None = None,
cancel_check: Callable[[], bool] | None = None,
) -> SyncResult:
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien.""" """Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
task.validate() task.validate()
source_root = Path(task.source) source_root = Path(task.source)
target_root = Path(task.target) target_root = Path(task.target)
if cancel_check and cancel_check():
return SyncResult(paused=True)
if progress: if progress:
progress(0, f"{task.name}: Ordner werden geprüft …") progress(0, f"{task.name}: Ordner werden geprüft …")
self._validate_roots(source_root, target_root) self._validate_roots(source_root, target_root)
source_files, source_dirs = self._inventory(source_root, task.selected_paths, progress, "Quelle") source_files, source_dirs = self._inventory(
target_files, target_dirs = self._inventory(target_root, task.selected_paths, progress, "Ziel") source_root, task.selected_paths, progress, "Quelle", cancel_check=cancel_check
)
if cancel_check and cancel_check():
return SyncResult(paused=True)
target_files, target_dirs = self._inventory(
target_root, task.selected_paths, progress, "Ziel", cancel_check=cancel_check
)
if cancel_check and cancel_check():
return SyncResult(paused=True)
if progress: if progress:
progress(0, f"{task.name}: Änderungen werden verglichen …") progress(0, f"{task.name}: Änderungen werden verglichen …")
previous = self.state_store.get(task.id) previous = self.state_store.get(task.id)
@@ -118,12 +134,34 @@ class SyncEngine:
result = SyncResult() result = SyncResult()
total = max(1, len(operations)) total = max(1, len(operations))
stages: list[str] = []
for op in operations:
is_dir = op.kind in {"Ordner anlegen", "Ordner löschen"}
stage_name = _operation_stage(op.relative, task.selected_paths, is_dir=is_dir)
if stage_name not in stages:
stages.append(stage_name)
known_locking_processes: dict[Path, tuple[LockingProcess, ...]] = {}
rm_query_count = 0
last_progress_time = 0.0
current_stage = ""
if progress: if progress:
progress(0, "Änderungen werden vorbereitet …") progress(0, "Änderungen werden vorbereitet …")
for index, operation in enumerate(operations, start=1): for index, operation in enumerate(operations, start=1):
is_dir = operation.kind in {"Ordner anlegen", "Ordner löschen"}
stage = _operation_stage(operation.relative, task.selected_paths, is_dir=is_dir)
if cancel_check and cancel_check():
result.paused = True
if progress:
progress(round((index - 1) / total * 100), f"Pausiert bei: {stage} – {operation.relative}")
break
try: try:
self._execute(operation, result) self._execute(operation, result)
except (OSError, shutil.Error) as exc: except (OSError, shutil.Error) as exc:
if isinstance(exc, FileNotFoundError) and operation.source and not operation.source.exists():
result.skipped += 1
continue
failed_path = ( failed_path = (
Path(exc.filename2 or exc.filename) Path(exc.filename2 or exc.filename)
if isinstance(exc, OSError) and (exc.filename2 or exc.filename) if isinstance(exc, OSError) and (exc.filename2 or exc.filename)
@@ -131,17 +169,36 @@ class SyncEngine:
) )
blocked_path = _blocked_path(operation, exc) blocked_path = _blocked_path(operation, exc)
if blocked_path is not None: if blocked_path is not None:
processes = tuple(find_locking_processes(blocked_path)) parent_dir = blocked_path.parent
if parent_dir in known_locking_processes:
processes = known_locking_processes[parent_dir]
elif rm_query_count < 10:
discovered = tuple(find_locking_processes(blocked_path))
rm_query_count += 1
if discovered:
known_locking_processes[parent_dir] = discovered
processes = discovered
else:
processes = next(iter(known_locking_processes.values()), ())
result.blocked.append(BlockedFile(blocked_path, processes)) result.blocked.append(BlockedFile(blocked_path, processes))
elif isinstance(exc, PermissionError): elif isinstance(exc, PermissionError):
result.errors.append(f"Zugriff verweigert: {failed_path} ({exc})") result.errors.append(f"Zugriff verweigert: {failed_path} ({exc})")
else: else:
result.errors.append(f"{failed_path}: {exc}") result.errors.append(f"{failed_path}: {exc}")
if progress: now = monotonic()
is_last = index == total
stage_changed = stage != current_stage
if progress and (is_last or stage_changed or now - last_progress_time >= 0.05):
last_progress_time = now
current_stage = stage
if len(stages) > 1:
stage_idx = stages.index(stage) + 1
detail = f"[{stage_idx}/{len(stages)} {stage}] {operation.kind}: {operation.relative}"
else:
detail = f"{operation.kind}: {operation.relative}" detail = f"{operation.kind}: {operation.relative}"
progress(round(index / total * 100), detail) progress(round(index / total * 100), detail)
if not operations and progress: if not operations and progress and not result.paused:
progress(100, "Keine Änderungen gefunden") progress(100, "Keine Änderungen gefunden")
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful: if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
@@ -168,6 +225,7 @@ class SyncEngine:
selected_paths: list[str], selected_paths: list[str],
progress: ProgressCallback | None = None, progress: ProgressCallback | None = None,
label: str = "Ordner", label: str = "Ordner",
cancel_check: Callable[[], bool] | None = None,
) -> tuple[dict[str, FileRecord], set[str]]: ) -> tuple[dict[str, FileRecord], set[str]]:
files: dict[str, FileRecord] = {} files: dict[str, FileRecord] = {}
directories: set[str] = set() directories: set[str] = set()
@@ -177,6 +235,8 @@ class SyncEngine:
progress(0, f"{label} wird eingelesen: {root}") progress(0, f"{label} wird eingelesen: {root}")
try: try:
for current, dir_names, file_names in os.walk(root): for current, dir_names, file_names in os.walk(root):
if cancel_check and cancel_check():
break
current_path = Path(current) current_path = Path(current)
relative_dir = current_path.relative_to(root) relative_dir = current_path.relative_to(root)
dir_names[:] = [ dir_names[:] = [
@@ -307,6 +367,9 @@ class SyncEngine:
return return
if operation.source is None: if operation.source is None:
return return
if not operation.source.exists():
result.skipped += 1
return
operation.target.parent.mkdir(parents=True, exist_ok=True) operation.target.parent.mkdir(parents=True, exist_ok=True)
if operation.conflict_copy and operation.target.exists(): if operation.conflict_copy and operation.target.exists():
conflict_path = _conflict_path(operation.target) conflict_path = _conflict_path(operation.target)
@@ -384,3 +447,17 @@ def _is_selected(parts: tuple[str, ...], selections: tuple[tuple[str, ...], ...]
if not selections: if not selections:
return True return True
return any(parts[: len(selection)] == selection or selection[: len(parts)] == parts for selection in selections) return any(parts[: len(selection)] == selection or selection[: len(parts)] == parts for selection in selections)
def _operation_stage(relative: str, selected_paths: list[str], is_dir: bool = False) -> str:
"""Ermittelt den relevanten Hauptordner oder die gewählte Unterauswahl."""
parts = Path(relative).parts
if not parts:
return "Hauptordner"
for selected in selected_paths:
selected_parts = Path(selected).parts
if parts[: len(selected_parts)] == selected_parts:
return "/".join(selected_parts)
if len(parts) > 1:
return parts[0]
return parts[0] if is_dir else "Hauptordner"
+101
View File
@@ -0,0 +1,101 @@
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import syncvito.autostart as autostart
from syncvito.autostart import generate_task_xml, set_autostart
def test_generate_task_xml_structure() -> None:
cmd = Path("C:/Program Files/SyncVito/SyncVito.exe")
args = "--minimized"
xml = generate_task_xml(cmd, args)
assert '<?xml version="1.0" encoding="UTF-16"?>' in xml
assert "<RunLevel>HighestAvailable</RunLevel>" in xml
assert "<LogonType>InteractiveToken</LogonType>" in xml
assert "<Delay>PT5S</Delay>" in xml
assert "<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>" in xml
assert "<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>" in xml
assert "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>" in xml
assert "<Command>C:\\Program Files\\SyncVito\\SyncVito.exe</Command>" in xml
assert "<Arguments>--minimized</Arguments>" in xml
def test_set_autostart_non_windows(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(autostart.os, "name", "posix")
assert not set_autostart(True)
assert not set_autostart(False)
def test_set_autostart_enable_scheduled_task_success(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(autostart.os, "name", "nt")
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "executable", r"C:\Program Files\SyncVito\SyncVito.exe")
mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=0))
monkeypatch.setattr(autostart, "_run_schtasks", mock_run)
deleted_run_key = False
def mock_delete_run_key() -> None:
nonlocal deleted_run_key
deleted_run_key = True
monkeypatch.setattr(autostart, "_delete_run_key_entry", mock_delete_run_key)
result = set_autostart(True)
assert result is True
assert mock_run.called
create_call_args = mock_run.call_args[0][0]
assert create_call_args[0] == "/create"
assert create_call_args[1] == "/tn"
assert create_call_args[2] == "SyncVito"
assert deleted_run_key is True
def test_set_autostart_enable_fallback_registry(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(autostart.os, "name", "nt")
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "executable", r"C:\Program Files\SyncVito\SyncVito.exe")
# schtasks fails (e.g. non-elevated prompt)
mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=1))
monkeypatch.setattr(autostart, "_run_schtasks", mock_run)
registry_command = None
def mock_set_run_key(cmd: str) -> None:
nonlocal registry_command
registry_command = cmd
monkeypatch.setattr(autostart, "_set_run_key_entry", mock_set_run_key)
result = set_autostart(True)
assert result is False
assert registry_command == r'"C:\Program Files\SyncVito\SyncVito.exe" --minimized'
def test_set_autostart_disable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(autostart.os, "name", "nt")
mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=0))
monkeypatch.setattr(autostart, "_run_schtasks", mock_run)
deleted_run_key = False
def mock_delete_run_key() -> None:
nonlocal deleted_run_key
deleted_run_key = True
monkeypatch.setattr(autostart, "_delete_run_key_entry", mock_delete_run_key)
result = set_autostart(False)
assert result is True
assert mock_run.called
delete_call_args = mock_run.call_args[0][0]
assert delete_call_args == ["/delete", "/tn", "SyncVito", "/f"]
assert deleted_run_key is True
+82
View File
@@ -73,6 +73,30 @@ def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch:
monkeypatch.setattr(coordinator, "enqueue", queued.append) monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries() coordinator._check_retries()
assert queued == ["first", "second"] assert queued == ["first", "second"]
assert "first" not in coordinator._retry_tasks
assert "second" not in coordinator._retry_tasks
def test_task_with_only_errors_is_not_queued_for_auto_retry(
coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch
) -> None:
task = coordinator.config.tasks[0]
coordinator._on_finished(task.id, SyncResult(errors=["Zugriff verweigert"]))
assert task.id not in coordinator._retry_tasks
queued = []
monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries()
assert queued == []
def test_retry_max_attempts_cap(coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch) -> None:
task = coordinator.config.tasks[0]
coordinator._retry_tasks[task.id] = []
coordinator._retry_attempts[task.id] = 5
queued = []
monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries()
assert queued == []
def test_start_resets_stale_progress_before_worker_starts( def test_start_resets_stale_progress_before_worker_starts(
@@ -93,3 +117,61 @@ def test_start_resets_stale_progress_before_worker_starts(
coordinator.enqueue(coordinator.config.tasks[0].id) coordinator.enqueue(coordinator.config.tasks[0].id)
assert len(workers) == 1 assert len(workers) == 1
assert coordinator.current_overall_status() == ("active", "Synchronisierung läuft") assert coordinator.current_overall_status() == ("active", "Synchronisierung läuft")
def test_pause_and_resume_preserves_queue_and_state(coordinator: SyncCoordinator) -> None:
task1 = coordinator.config.tasks[0]
task2 = SyncTask(name="Documents", source="c:/src2", target="c:/dst2")
coordinator.config.tasks.append(task2)
# Global pausieren
coordinator.set_paused(True)
assert coordinator.config.settings.paused
assert coordinator.current_overall_status() == ("paused", "Pausiert")
# Aufgaben in Warteschlange einreihen
coordinator.enqueue(task1.id)
coordinator.enqueue(task2.id)
assert coordinator.queued_task_ids == [task1.id, task2.id]
assert coordinator.config.settings.pending_queue == [task1.id, task2.id]
# Fortsetzen
workers = []
from types import SimpleNamespace
coordinator.pool = SimpleNamespace(start=workers.append)
coordinator.set_paused(False)
assert not coordinator.config.settings.paused
assert len(workers) == 1
assert coordinator.running_task_id == task1.id
assert coordinator.queued_task_ids == [task2.id]
def test_stop_persists_running_task_and_queue_for_restart(coordinator: SyncCoordinator) -> None:
task1 = coordinator.config.tasks[0]
task2 = SyncTask(name="Documents", source="c:/src2", target="c:/dst2")
coordinator.config.tasks.append(task2)
coordinator._running_task_id = task1.id
coordinator._queue.append(task2.id)
coordinator.stop()
assert coordinator.config.settings.paused is True
assert coordinator.config.settings.pending_queue == [task1.id, task2.id]
def test_enqueue_all_unpauses_and_runs(coordinator: SyncCoordinator) -> None:
from types import SimpleNamespace
coordinator.set_paused(True)
workers = []
coordinator.pool = SimpleNamespace(start=workers.append)
coordinator.enqueue_all()
assert not coordinator.config.settings.paused
assert len(workers) == 1
assert coordinator.running_task_id == coordinator.config.tasks[0].id
+9 -1
View File
@@ -3,9 +3,17 @@ from pathlib import Path
def test_installer_launches_admin_application_with_elevated_setup_token() -> None: def test_installer_launches_admin_application_with_elevated_setup_token() -> None:
script = Path("installer/SyncVito.iss").read_text(encoding="utf-8") script = Path("installer/SyncVito.iss").read_text(encoding="utf-8")
run_entries = [line for line in script.splitlines() if line.startswith("Filename:")] run_section = script.split("[Run]")[1].split("[")[0] if "[Run]" in script else ""
run_entries = [line for line in run_section.splitlines() if line.startswith("Filename:")]
assert len(run_entries) == 2 assert len(run_entries) == 2
assert all("runascurrentuser" in entry for entry in run_entries) assert all("runascurrentuser" in entry for entry in run_entries)
assert "postinstall" in run_entries[0] assert "postinstall" in run_entries[0]
assert 'Parameters: "--updated"' in run_entries[1] assert 'Parameters: "--updated"' in run_entries[1]
def test_installer_cleans_up_scheduled_task_on_uninstall() -> None:
script = Path("installer/SyncVito.iss").read_text(encoding="utf-8")
uninstall_section = script.split("[UninstallRun]")[1].split("[")[0] if "[UninstallRun]" in script else ""
assert "schtasks.exe" in uninstall_section
assert "{#MyAppName}" in uninstall_section
+70
View File
@@ -223,3 +223,73 @@ def test_bidirectional_mirrors_known_deletion(tmp_path: Path) -> None:
assert result.successful assert result.successful
assert not (source / "entfernen.txt").exists() assert not (source / "entfernen.txt").exists()
def test_stage_progress_reporting_for_multiple_folders(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
(source / "AppData").mkdir(parents=True)
(source / "Documents").mkdir(parents=True)
(source / "AppData" / "config.json").write_text("{}", encoding="utf-8")
(source / "Documents" / "doc.txt").write_text("text", encoding="utf-8")
task = SyncTask(name="Backup", source=str(source), target=str(target))
messages: list[tuple[int, str]] = []
result = _engine(tmp_path).synchronize(task, progress=lambda v, d: messages.append((v, d)))
assert result.successful
assert any("[1/2 AppData]" in detail for _, detail in messages)
assert any("[2/2 Documents]" in detail for _, detail in messages)
def test_pause_and_resume_continues_where_interrupted(tmp_path: Path) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
(source / "a.txt").write_text("a", encoding="utf-8")
(source / "b.txt").write_text("b", encoding="utf-8")
(source / "c.txt").write_text("c", encoding="utf-8")
task = SyncTask(name="ResumeTest", source=str(source), target=str(target))
engine = _engine(tmp_path)
# 1. Unterbrechung nach der ersten Datei
def cancel_after_one() -> bool:
return (target / "a.txt").exists()
result_paused = engine.synchronize(task, cancel_check=cancel_after_one)
assert result_paused.paused
assert not result_paused.successful
assert (target / "a.txt").exists()
assert not (target / "c.txt").exists()
# 2. Fortsetzen: Bereits kopierte Datei a.txt wird übersprungen, b und c werden kopiert
result_resumed = engine.synchronize(task)
assert result_resumed.successful
assert result_resumed.copied == 2
assert (target / "a.txt").exists()
assert (target / "b.txt").exists()
assert (target / "c.txt").exists()
def test_concurrently_deleted_source_file_is_skipped(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
target.mkdir()
(source / "ephemeral.txt").write_text("wird gelöscht")
task = SyncTask(name="Transient", source=str(source), target=str(target))
engine = _engine(tmp_path)
# Simuliere, dass die Datei kurz vor der Ausführung gelöscht wurde
def delete_before_execute(operation, result):
if operation.source and operation.source.name == "ephemeral.txt":
operation.source.unlink(missing_ok=True)
return SyncEngine._execute(operation, result)
monkeypatch.setattr(engine, "_execute", delete_before_execute)
result = engine.synchronize(task)
assert result.successful
assert result.skipped == 1
assert not result.errors
assert not (target / "ephemeral.txt").exists()
+1 -1
View File
@@ -1 +1 @@
1.0.9 1.0.13