Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c677f0161 | ||
|
|
1d0b1b2d38 | ||
|
|
cdcd03d7b0 | ||
|
|
b6e32a1b18 | ||
|
|
786e3ae9b3 | ||
|
|
ccb89191b5 |
No files matched your search
@@ -1,5 +1,48 @@
|
|||||||
# Änderungsprotokoll
|
# Änderungsprotokoll
|
||||||
|
|
||||||
|
## 1.0.15 – 2026-09-29
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Automatisches und stilles Ignorieren blockierter Dateien:** Werden Dateien während eines Laufs als gesperrt (`BlockedFile`), zugriffsverweigert (`PermissionError`) oder fehlerhaft erkannt, merkt sich SyncVito diese relativen Dateipfade automatisch pro Aufgabe. Bei allen nachfolgenden Durchläufen werden diese Dateien vollständig ignoriert – sie werden weder erneut versucht, noch erscheinen sie im Protokoll oder in Fehlermeldungen, sodass der Abgleich sauber und ohne Unterbrechungen mit dem Status „Erfolg“ abschließt.
|
||||||
|
- **Verwaltung ignorierter Dateien im Aufgabeneditor:** In den Aufgabeneinstellungen unter „Konflikte und Löschungen“ wird die Anzahl aktuell ignorierter Fehlerdateien angezeigt. Über die Schaltfläche „Ignorier-Liste leeren“ kann die Liste jederzeit mit einem Klick zurückgesetzt werden, falls Dateien wieder synchronisiert werden sollen.
|
||||||
|
|
||||||
|
## 1.0.14 – 2026-09-29
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Netzwerk- und Boot-Resilienz bei der Konfigurationsprüfung:** Beim Windows-Start wird die Aufgabenvalidierung von Quell- und Zielordnern nicht mehr durch noch nicht initialisierte Netzwerkverbindungen oder vorübergehend nicht erreichbare UNC-Pfade (`\\Server\Freigabe`) unterbrochen. Fehler bei der Pfadauflösung werden sicher abgefangen.
|
||||||
|
- **Schutz vor fälschlichem Konfigurations-Reset:** Kurzzeitige Windows-Dateisperren (z. B. durch Hintergrund- oder Suchdienste beim Systemstart) führen nicht mehr zum irrtümlichen Umbenennen der Konfiguration in eine defekte Sicherung. Lesezugriffe auf die Konfigurationsdatei werden bei Sperren automatisch mehrfach wiederholt. Eine Sicherung als `config.defekt` erfolgt ausschließlich bei tatsächlichen Syntaxfehlern in der JSON-Datei.
|
||||||
|
- **Fehlertolerantes Laden von Aufgaben:** Sollte eine einzelne gespeicherte Aufgabe ungültige Pfade oder Parameter enthalten, bleibt die Gesamtheit aller anderen Aufgaben unberührt. Die betroffene Aufgabe wird sicherheitsrelevanterweise deaktiviert in der Liste geladen, statt den Start zu blockieren oder andere Aufgaben zu verwerfen.
|
||||||
|
|
||||||
|
## 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
|
## 1.0.10 – 2026-09-27
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
+17
-1
@@ -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." }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "syncvito"
|
name = "syncvito"
|
||||||
version = "1.0.10"
|
version = "1.0.15"
|
||||||
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
@@ -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:
|
||||||
|
|||||||
+140
-15
@@ -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),
|
||||||
if enabled:
|
winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key,
|
||||||
executable = Path(sys.executable)
|
):
|
||||||
if getattr(sys, "frozen", False):
|
winreg.DeleteValue(key, TASK_NAME)
|
||||||
command = f'"{executable}" --minimized'
|
|
||||||
else:
|
|
||||||
entry = Path(__file__).resolve().parents[2] / "main.py"
|
def _set_run_key_entry(command: str) -> None:
|
||||||
command = f'"{executable}" "{entry}" --minimized'
|
if os.name != "nt":
|
||||||
winreg.SetValueEx(key, "SyncVito", 0, winreg.REG_SZ, command)
|
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 getattr(sys, "frozen", False):
|
||||||
|
command_exe = Path(sys.executable)
|
||||||
|
arguments = "--minimized"
|
||||||
|
reg_command = f'"{command_exe}" --minimized'
|
||||||
else:
|
else:
|
||||||
with suppress(FileNotFoundError):
|
command_exe = Path(sys.executable)
|
||||||
winreg.DeleteValue(key, "SyncVito")
|
entry = Path(__file__).resolve().parents[2] / "main.py"
|
||||||
|
arguments = f'"{entry}" --minimized'
|
||||||
|
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:
|
||||||
|
_delete_scheduled_task()
|
||||||
|
_delete_run_key_entry()
|
||||||
|
return True
|
||||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -33,19 +35,47 @@ class ConfigStore:
|
|||||||
"""Lädt die Konfiguration; beschädigte Dateien werden gesichert."""
|
"""Lädt die Konfiguration; beschädigte Dateien werden gesichert."""
|
||||||
if not self.path.exists():
|
if not self.path.exists():
|
||||||
return AppConfig()
|
return AppConfig()
|
||||||
|
|
||||||
|
content: str | None = None
|
||||||
|
last_io_exc: OSError | None = None
|
||||||
|
for _ in range(5):
|
||||||
|
try:
|
||||||
|
content = self.path.read_text(encoding="utf-8")
|
||||||
|
break
|
||||||
|
except OSError as exc:
|
||||||
|
last_io_exc = exc
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if content is None:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Konfigurationsdatei '{self.path.name}' konnte nicht gelesen werden: {last_io_exc}"
|
||||||
|
) from last_io_exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
raw = json.loads(content)
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise ValueError("Das JSON-Wurzelelement ist kein Objekt.")
|
raise ValueError("Das JSON-Wurzelelement ist kein Objekt.")
|
||||||
return AppConfig.from_dict(raw)
|
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
recovery_path = self.path.with_name(f"config.defekt-{timestamp}.json")
|
recovery_path = self.path.with_name(f"config.defekt-{timestamp}.json")
|
||||||
try:
|
try:
|
||||||
self.path.replace(recovery_path)
|
self.path.replace(recovery_path)
|
||||||
except OSError:
|
except OSError:
|
||||||
recovery_path = self.path
|
recovery_path = self.path
|
||||||
raise ConfigError(f"Die Konfiguration war nicht lesbar. Sicherung: {recovery_path}") from exc
|
raise ConfigError(
|
||||||
|
f"Die Konfiguration war syntaktisch beschädigt ({exc}). Sicherung: {recovery_path}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
return AppConfig.from_dict(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup_path = self.path.with_name(f"config.bak-{timestamp}.json")
|
||||||
|
with suppress(OSError):
|
||||||
|
shutil.copy2(self.path, backup_path)
|
||||||
|
raise ConfigError(
|
||||||
|
f"Die Konfigurationsdaten konnten nicht verarbeitet werden ({exc}). Sicherungskopie: {backup_path}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
def save(self, config: AppConfig) -> None:
|
def save(self, config: AppConfig) -> None:
|
||||||
"""Speichert die Konfiguration atomar und UTF-8-kodiert."""
|
"""Speichert die Konfiguration atomar und UTF-8-kodiert."""
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal
|
|||||||
|
|
||||||
from syncvito.config_store import ConfigStore
|
from syncvito.config_store import ConfigStore
|
||||||
from syncvito.file_locks import is_file_locked
|
from syncvito.file_locks import is_file_locked
|
||||||
from syncvito.models import AppConfig, SyncTask, TaskRuntimeState, TriggerMode
|
from syncvito.models import AppConfig, SyncTask, TaskRuntimeState, TriggerMode, _normalize_relative_path
|
||||||
from syncvito.realtime import RealtimeMonitor
|
from syncvito.realtime import RealtimeMonitor
|
||||||
from syncvito.sync_engine import SyncEngine, SyncResult, UnavailablePathError
|
from syncvito.sync_engine import SyncEngine, SyncResult, UnavailablePathError
|
||||||
|
|
||||||
@@ -88,6 +88,8 @@ class SyncCoordinator(QObject):
|
|||||||
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
|
||||||
@@ -142,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
|
||||||
}
|
}
|
||||||
@@ -152,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)
|
||||||
@@ -179,6 +185,8 @@ class SyncCoordinator(QObject):
|
|||||||
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 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)
|
||||||
@@ -194,6 +202,8 @@ class SyncCoordinator(QObject):
|
|||||||
|
|
||||||
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)
|
||||||
@@ -213,6 +223,10 @@ class SyncCoordinator(QObject):
|
|||||||
for tid in 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):
|
if tid not in self._queue and any(t.id == tid and t.enabled for t in self.config.tasks):
|
||||||
self._queue.append(tid)
|
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):
|
with suppress(Exception):
|
||||||
self.config_store.save(self.config)
|
self.config_store.save(self.config)
|
||||||
@@ -223,7 +237,9 @@ class SyncCoordinator(QObject):
|
|||||||
"""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()
|
||||||
@@ -284,6 +300,15 @@ class SyncCoordinator(QObject):
|
|||||||
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:
|
if task_id in self.config.settings.pending_queue:
|
||||||
self.config.settings.pending_queue.remove(task_id)
|
self.config.settings.pending_queue.remove(task_id)
|
||||||
|
|
||||||
|
if result.failed_relatives:
|
||||||
|
new_ignored = set(task.ignored_paths)
|
||||||
|
for rel in result.failed_relatives:
|
||||||
|
with suppress(ValueError):
|
||||||
|
new_ignored.add(_normalize_relative_path(rel))
|
||||||
|
if new_ignored != set(task.ignored_paths):
|
||||||
|
task.ignored_paths = sorted(new_ignored)
|
||||||
|
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
self.config_store.save(self.config)
|
self.config_store.save(self.config)
|
||||||
|
|
||||||
@@ -292,8 +317,7 @@ class SyncCoordinator(QObject):
|
|||||||
self._retry_tasks[task_id] = paths
|
self._retry_tasks[task_id] = paths
|
||||||
message = (
|
message = (
|
||||||
f"{result.copied} kopiert, {len(result.blocked)} Dateien gesperrt – Sicherung unvollständig. "
|
f"{result.copied} kopiert, {len(result.blocked)} Dateien gesperrt – Sicherung unvollständig. "
|
||||||
"Andere Dateien wurden weiterverarbeitet. Automatischer Wiederholungsversuch nach Freigabe; "
|
"Betroffene Dateien werden künftig automatisch ignoriert. Details im Protokoll."
|
||||||
"ggf. betroffene Programme schließen. Details im Protokoll."
|
|
||||||
)
|
)
|
||||||
self._lock_messages[task_id] = message
|
self._lock_messages[task_id] = message
|
||||||
self._emit_lock_summary()
|
self._emit_lock_summary()
|
||||||
@@ -309,12 +333,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)
|
||||||
@@ -323,7 +352,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)
|
||||||
@@ -366,9 +397,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."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ class TaskDialog(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._original = task
|
self._original = task
|
||||||
self._selected_paths = list(task.selected_paths) if task else []
|
self._selected_paths = list(task.selected_paths) if task else []
|
||||||
|
self._ignored_paths = list(task.ignored_paths) if task else []
|
||||||
self.setWindowTitle("Aufgabe bearbeiten" if task else "Neue Aufgabe")
|
self.setWindowTitle("Aufgabe bearbeiten" if task else "Neue Aufgabe")
|
||||||
self.setMinimumWidth(690)
|
self.setMinimumWidth(690)
|
||||||
self.resize(720, 610)
|
self.resize(720, 610)
|
||||||
@@ -208,6 +209,19 @@ class TaskDialog(QDialog):
|
|||||||
warning.setProperty("muted", True)
|
warning.setProperty("muted", True)
|
||||||
behavior_layout.addRow("", warning)
|
behavior_layout.addRow("", warning)
|
||||||
|
|
||||||
|
ignored_count = len(self._ignored_paths)
|
||||||
|
self.ignored_label = QLabel(
|
||||||
|
f"{ignored_count} Datei(en) werden künftig ignoriert." if ignored_count else "Keine Dateien ignoriert."
|
||||||
|
)
|
||||||
|
self.ignored_label.setProperty("muted", True)
|
||||||
|
self.clear_ignored_button = QPushButton("Ignorier-Liste leeren")
|
||||||
|
self.clear_ignored_button.setEnabled(ignored_count > 0)
|
||||||
|
self.clear_ignored_button.clicked.connect(self._clear_ignored)
|
||||||
|
ignored_row = QHBoxLayout()
|
||||||
|
ignored_row.addWidget(self.ignored_label, 1)
|
||||||
|
ignored_row.addWidget(self.clear_ignored_button)
|
||||||
|
behavior_layout.addRow("Gesperrte Dateien", ignored_row)
|
||||||
|
|
||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
tabs.addTab(base_page, "Ordner und Zeitplan")
|
tabs.addTab(base_page, "Ordner und Zeitplan")
|
||||||
tabs.addTab(behavior_page, "Konflikte und Löschungen")
|
tabs.addTab(behavior_page, "Konflikte und Löschungen")
|
||||||
@@ -228,6 +242,11 @@ class TaskDialog(QDialog):
|
|||||||
self._update_visibility()
|
self._update_visibility()
|
||||||
self._update_selection_label()
|
self._update_selection_label()
|
||||||
|
|
||||||
|
def _clear_ignored(self) -> None:
|
||||||
|
self._ignored_paths.clear()
|
||||||
|
self.ignored_label.setText("Keine Dateien ignoriert.")
|
||||||
|
self.clear_ignored_button.setEnabled(False)
|
||||||
|
|
||||||
def task(self) -> SyncTask:
|
def task(self) -> SyncTask:
|
||||||
"""Erzeugt das validierte Modell aus den Formulardaten."""
|
"""Erzeugt das validierte Modell aus den Formulardaten."""
|
||||||
task = SyncTask(
|
task = SyncTask(
|
||||||
@@ -241,6 +260,7 @@ class TaskDialog(QDialog):
|
|||||||
conflict_mode=self.conflict_combo.currentData(),
|
conflict_mode=self.conflict_combo.currentData(),
|
||||||
interval_minutes=self.interval_spin.value(),
|
interval_minutes=self.interval_spin.value(),
|
||||||
selected_paths=self._selected_paths,
|
selected_paths=self._selected_paths,
|
||||||
|
ignored_paths=self._ignored_paths,
|
||||||
enabled=self.enabled_check.isChecked(),
|
enabled=self.enabled_check.isChecked(),
|
||||||
last_run=self._original.last_run if self._original else None,
|
last_run=self._original.last_run if self._original else None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -572,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:
|
||||||
@@ -652,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")
|
||||||
@@ -740,18 +750,32 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _update_pause_action(self) -> None:
|
def _update_pause_action(self) -> None:
|
||||||
is_paused = self.config.settings.paused
|
is_paused = self.config.settings.paused
|
||||||
|
is_running = self.coordinator.is_running
|
||||||
|
has_queue = bool(self.coordinator.queued_task_ids)
|
||||||
|
|
||||||
if is_paused:
|
if is_paused:
|
||||||
btn_text = "▶ Fortsetzen"
|
btn_text = "▶ Fortsetzen"
|
||||||
tray_text = "Fortsetzen"
|
tray_text = "Fortsetzen"
|
||||||
else:
|
btn_enabled = True
|
||||||
btn_text = "⏸ Pausieren" if self.coordinator.is_running else "Pausieren"
|
primary = True
|
||||||
|
elif is_running or has_queue:
|
||||||
|
btn_text = "⏸ Pausieren"
|
||||||
tray_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.setText(tray_text)
|
||||||
|
self.pause_action.setEnabled(btn_enabled)
|
||||||
for btn in (getattr(self, "header_pause_button", None), getattr(self, "dock_pause_button", None)):
|
for btn in (getattr(self, "header_pause_button", None), getattr(self, "dock_pause_button", None)):
|
||||||
if btn is not None:
|
if btn is not None:
|
||||||
btn.setText(btn_text)
|
btn.setText(btn_text)
|
||||||
btn.setProperty("primary", is_paused)
|
btn.setEnabled(btn_enabled)
|
||||||
|
btn.setProperty("primary", primary)
|
||||||
btn.style().unpolish(btn)
|
btn.style().unpolish(btn)
|
||||||
btn.style().polish(btn)
|
btn.style().polish(btn)
|
||||||
|
|
||||||
@@ -779,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:
|
||||||
|
|||||||
+34
-8
@@ -73,6 +73,7 @@ class SyncTask:
|
|||||||
conflict_mode: ConflictMode = ConflictMode.NEWER_WINS
|
conflict_mode: ConflictMode = ConflictMode.NEWER_WINS
|
||||||
interval_minutes: int = 30
|
interval_minutes: int = 30
|
||||||
selected_paths: list[str] = field(default_factory=list)
|
selected_paths: list[str] = field(default_factory=list)
|
||||||
|
ignored_paths: list[str] = field(default_factory=list)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
last_run: str | None = None
|
last_run: str | None = None
|
||||||
|
|
||||||
@@ -82,8 +83,8 @@ class SyncTask:
|
|||||||
raise ValueError("Bitte einen Namen für die Aufgabe eingeben.")
|
raise ValueError("Bitte einen Namen für die Aufgabe eingeben.")
|
||||||
if not self.source.strip() or not self.target.strip():
|
if not self.source.strip() or not self.target.strip():
|
||||||
raise ValueError("Quell- und Zielordner müssen angegeben werden.")
|
raise ValueError("Quell- und Zielordner müssen angegeben werden.")
|
||||||
source = Path(self.source).expanduser().resolve(strict=False)
|
source = _safe_resolve(Path(self.source).expanduser())
|
||||||
target = Path(self.target).expanduser().resolve(strict=False)
|
target = _safe_resolve(Path(self.target).expanduser())
|
||||||
source_norm = os.path.normcase(str(source))
|
source_norm = os.path.normcase(str(source))
|
||||||
target_norm = os.path.normcase(str(target))
|
target_norm = os.path.normcase(str(target))
|
||||||
if source_norm == target_norm:
|
if source_norm == target_norm:
|
||||||
@@ -96,6 +97,7 @@ class SyncTask:
|
|||||||
self.source = str(source)
|
self.source = str(source)
|
||||||
self.target = str(target)
|
self.target = str(target)
|
||||||
self.selected_paths = sorted({_normalize_relative_path(item) for item in self.selected_paths if item.strip()})
|
self.selected_paths = sorted({_normalize_relative_path(item) for item in self.selected_paths if item.strip()})
|
||||||
|
self.ignored_paths = sorted({_normalize_relative_path(item) for item in self.ignored_paths if item.strip()})
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""Serialisiert die Aufgabe in ein JSON-kompatibles Dictionary."""
|
"""Serialisiert die Aufgabe in ein JSON-kompatibles Dictionary."""
|
||||||
@@ -104,6 +106,7 @@ class SyncTask:
|
|||||||
data["trigger"] = self.trigger.value
|
data["trigger"] = self.trigger.value
|
||||||
data["delete_mode"] = self.delete_mode.value
|
data["delete_mode"] = self.delete_mode.value
|
||||||
data["conflict_mode"] = self.conflict_mode.value
|
data["conflict_mode"] = self.conflict_mode.value
|
||||||
|
data["ignored_paths"] = list(self.ignored_paths)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -120,10 +123,14 @@ class SyncTask:
|
|||||||
conflict_mode=ConflictMode(data.get("conflict_mode", ConflictMode.NEWER_WINS.value)),
|
conflict_mode=ConflictMode(data.get("conflict_mode", ConflictMode.NEWER_WINS.value)),
|
||||||
interval_minutes=int(data.get("interval_minutes", 30)),
|
interval_minutes=int(data.get("interval_minutes", 30)),
|
||||||
selected_paths=[str(item) for item in data.get("selected_paths", [])],
|
selected_paths=[str(item) for item in data.get("selected_paths", [])],
|
||||||
|
ignored_paths=[str(item) for item in data.get("ignored_paths", [])],
|
||||||
enabled=bool(data.get("enabled", True)),
|
enabled=bool(data.get("enabled", True)),
|
||||||
last_run=data.get("last_run"),
|
last_run=data.get("last_run"),
|
||||||
)
|
)
|
||||||
task.validate()
|
try:
|
||||||
|
task.validate()
|
||||||
|
except ValueError:
|
||||||
|
task.enabled = False
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
@@ -191,12 +198,28 @@ class AppConfig:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any]) -> AppConfig:
|
def from_dict(cls, data: dict[str, Any]) -> AppConfig:
|
||||||
"""Erzeugt eine vollständige Konfiguration aus JSON-Daten."""
|
"""Erzeugt eine vollständige Konfiguration aus JSON-Daten."""
|
||||||
|
tasks: list[SyncTask] = []
|
||||||
|
for item in data.get("tasks", []):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
tasks.append(SyncTask.from_dict(item))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
return cls(
|
return cls(
|
||||||
tasks=[SyncTask.from_dict(item) for item in data.get("tasks", [])],
|
tasks=tasks,
|
||||||
settings=AppSettings.from_dict(data.get("settings", {})),
|
settings=AppSettings.from_dict(data.get("settings", {})),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_resolve(path: Path) -> Path:
|
||||||
|
"""Löst Pfade sicher auf; fängt Netzwerkfehler und unvollständige Pfade ab."""
|
||||||
|
try:
|
||||||
|
return path.resolve(strict=False)
|
||||||
|
except OSError:
|
||||||
|
return path.absolute()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_relative_path(value: str) -> str:
|
def _normalize_relative_path(value: str) -> str:
|
||||||
normalized = value.replace("/", os.sep).replace("\\", os.sep).strip(os.sep)
|
normalized = value.replace("/", os.sep).replace("\\", os.sep).strip(os.sep)
|
||||||
path = Path(normalized)
|
path = Path(normalized)
|
||||||
@@ -207,7 +230,10 @@ def _normalize_relative_path(value: str) -> str:
|
|||||||
|
|
||||||
def _is_relative_to(path: Path, base: Path) -> bool:
|
def _is_relative_to(path: Path, base: Path) -> bool:
|
||||||
try:
|
try:
|
||||||
path.relative_to(base)
|
return path.is_relative_to(base)
|
||||||
except ValueError:
|
except (ValueError, OSError, AttributeError):
|
||||||
return False
|
try:
|
||||||
return True
|
path.relative_to(base)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
|||||||
from time import monotonic
|
from time import monotonic
|
||||||
|
|
||||||
from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked
|
from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked
|
||||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask, _normalize_relative_path
|
||||||
from syncvito.state_store import StateStore
|
from syncvito.state_store import StateStore
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -22,6 +22,7 @@ __all__ = [
|
|||||||
"SyncEngine",
|
"SyncEngine",
|
||||||
"SyncResult",
|
"SyncResult",
|
||||||
"UnavailablePathError",
|
"UnavailablePathError",
|
||||||
|
"is_relative_path_ignored",
|
||||||
]
|
]
|
||||||
|
|
||||||
ProgressCallback = Callable[[int, str], None]
|
ProgressCallback = Callable[[int, str], None]
|
||||||
@@ -31,6 +32,24 @@ class UnavailablePathError(RuntimeError):
|
|||||||
"""Ein für die Synchronisation benötigter Pfad ist nicht erreichbar."""
|
"""Ein für die Synchronisation benötigter Pfad ist nicht erreichbar."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_relative_path_ignored(relative: str, ignored_set: set[str]) -> bool:
|
||||||
|
"""Prüft, ob ein relativer Pfad oder eines seiner Elternverzeichnisse ignoriert wird."""
|
||||||
|
if not ignored_set or not relative.strip():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
norm = _normalize_relative_path(relative)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if norm in ignored_set:
|
||||||
|
return True
|
||||||
|
rel_path = Path(norm)
|
||||||
|
for parent in rel_path.parents:
|
||||||
|
parent_str = str(parent)
|
||||||
|
if parent_str != "." and parent_str in ignored_set:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class FileRecord:
|
class FileRecord:
|
||||||
"""Kompakte Dateisystemmetadaten für Vergleiche."""
|
"""Kompakte Dateisystemmetadaten für Vergleiche."""
|
||||||
@@ -64,6 +83,7 @@ class SyncResult:
|
|||||||
paused: bool = False
|
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)
|
||||||
|
failed_relatives: set[str] = field(default_factory=set)
|
||||||
inaccessible: bool = False
|
inaccessible: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -103,12 +123,22 @@ class SyncEngine:
|
|||||||
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_files, source_dirs = self._inventory(
|
||||||
source_root, task.selected_paths, progress, "Quelle", cancel_check=cancel_check
|
source_root,
|
||||||
|
task.selected_paths,
|
||||||
|
progress,
|
||||||
|
"Quelle",
|
||||||
|
cancel_check=cancel_check,
|
||||||
|
ignored_paths=task.ignored_paths,
|
||||||
)
|
)
|
||||||
if cancel_check and cancel_check():
|
if cancel_check and cancel_check():
|
||||||
return SyncResult(paused=True)
|
return SyncResult(paused=True)
|
||||||
target_files, target_dirs = self._inventory(
|
target_files, target_dirs = self._inventory(
|
||||||
target_root, task.selected_paths, progress, "Ziel", cancel_check=cancel_check
|
target_root,
|
||||||
|
task.selected_paths,
|
||||||
|
progress,
|
||||||
|
"Ziel",
|
||||||
|
cancel_check=cancel_check,
|
||||||
|
ignored_paths=task.ignored_paths,
|
||||||
)
|
)
|
||||||
if cancel_check and cancel_check():
|
if cancel_check and cancel_check():
|
||||||
return SyncResult(paused=True)
|
return SyncResult(paused=True)
|
||||||
@@ -141,6 +171,11 @@ class SyncEngine:
|
|||||||
if stage_name not in stages:
|
if stage_name not in stages:
|
||||||
stages.append(stage_name)
|
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):
|
||||||
@@ -154,20 +189,40 @@ class SyncEngine:
|
|||||||
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)
|
||||||
else operation.target
|
else operation.target
|
||||||
)
|
)
|
||||||
|
if operation.relative and operation.relative.strip():
|
||||||
|
result.failed_relatives.add(operation.relative)
|
||||||
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:
|
if len(stages) > 1:
|
||||||
stage_idx = stages.index(stage) + 1
|
stage_idx = stages.index(stage) + 1
|
||||||
detail = f"[{stage_idx}/{len(stages)} {stage}] {operation.kind}: {operation.relative}"
|
detail = f"[{stage_idx}/{len(stages)} {stage}] {operation.kind}: {operation.relative}"
|
||||||
@@ -179,8 +234,8 @@ class SyncEngine:
|
|||||||
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:
|
||||||
refreshed_source, _ = self._inventory(source_root, task.selected_paths)
|
refreshed_source, _ = self._inventory(source_root, task.selected_paths, ignored_paths=task.ignored_paths)
|
||||||
refreshed_target, _ = self._inventory(target_root, task.selected_paths)
|
refreshed_target, _ = self._inventory(target_root, task.selected_paths, ignored_paths=task.ignored_paths)
|
||||||
self.state_store.set(task.id, self._state_from_inventories(refreshed_source, refreshed_target))
|
self.state_store.set(task.id, self._state_from_inventories(refreshed_source, refreshed_target))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -203,10 +258,15 @@ class SyncEngine:
|
|||||||
progress: ProgressCallback | None = None,
|
progress: ProgressCallback | None = None,
|
||||||
label: str = "Ordner",
|
label: str = "Ordner",
|
||||||
cancel_check: Callable[[], bool] | None = None,
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
ignored_paths: list[str] | set[str] | 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()
|
||||||
selections = tuple(Path(item).parts for item in selected_paths)
|
selections = tuple(Path(item).parts for item in selected_paths)
|
||||||
|
ignored_set: set[str] = set()
|
||||||
|
for item in ignored_paths or ():
|
||||||
|
with suppress(ValueError):
|
||||||
|
ignored_set.add(_normalize_relative_path(item))
|
||||||
last_report = monotonic()
|
last_report = monotonic()
|
||||||
if progress:
|
if progress:
|
||||||
progress(0, f"{label} wird eingelesen: {root}")
|
progress(0, f"{label} wird eingelesen: {root}")
|
||||||
@@ -221,6 +281,7 @@ class SyncEngine:
|
|||||||
for name in dir_names
|
for name in dir_names
|
||||||
if _is_selected((relative_dir / name).parts, selections)
|
if _is_selected((relative_dir / name).parts, selections)
|
||||||
and not _is_directory_link(current_path / name)
|
and not _is_directory_link(current_path / name)
|
||||||
|
and not is_relative_path_ignored(str(relative_dir / name), ignored_set)
|
||||||
]
|
]
|
||||||
if progress and monotonic() - last_report >= 0.25:
|
if progress and monotonic() - last_report >= 0.25:
|
||||||
progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}")
|
progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}")
|
||||||
@@ -234,6 +295,8 @@ class SyncEngine:
|
|||||||
if path.is_symlink():
|
if path.is_symlink():
|
||||||
continue
|
continue
|
||||||
relative = path.relative_to(root)
|
relative = path.relative_to(root)
|
||||||
|
if is_relative_path_ignored(str(relative), ignored_set):
|
||||||
|
continue
|
||||||
if not _is_selected(relative.parts, selections):
|
if not _is_selected(relative.parts, selections):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
@@ -291,7 +354,13 @@ class SyncEngine:
|
|||||||
_Operation("Ordner anlegen", None, source_root / relative, relative)
|
_Operation("Ordner anlegen", None, source_root / relative, relative)
|
||||||
for relative in sorted(target_dirs - source_dirs)
|
for relative in sorted(target_dirs - source_dirs)
|
||||||
)
|
)
|
||||||
|
ignored_set: set[str] = set()
|
||||||
|
for item in task.ignored_paths or ():
|
||||||
|
with suppress(ValueError):
|
||||||
|
ignored_set.add(_normalize_relative_path(item))
|
||||||
all_paths = sorted(source_files.keys() | target_files.keys() | previous.keys())
|
all_paths = sorted(source_files.keys() | target_files.keys() | previous.keys())
|
||||||
|
if ignored_set:
|
||||||
|
all_paths = [p for p in all_paths if not is_relative_path_ignored(p, ignored_set)]
|
||||||
for relative in all_paths:
|
for relative in all_paths:
|
||||||
source = source_files.get(relative)
|
source = source_files.get(relative)
|
||||||
target = target_files.get(relative)
|
target = target_files.get(relative)
|
||||||
@@ -344,6 +413,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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from syncvito.config_store import ConfigStore
|
import pytest
|
||||||
|
|
||||||
|
from syncvito.config_store import ConfigError, ConfigStore
|
||||||
from syncvito.models import AppConfig, AppSettings, SyncDirection, SyncTask
|
from syncvito.models import AppConfig, AppSettings, SyncDirection, SyncTask
|
||||||
|
|
||||||
|
|
||||||
@@ -28,3 +31,64 @@ def test_config_roundtrip(tmp_path: Path) -> None:
|
|||||||
assert loaded.tasks[0].name == "Archiv"
|
assert loaded.tasks[0].name == "Archiv"
|
||||||
assert loaded.tasks[0].direction is SyncDirection.BIDIRECTIONAL
|
assert loaded.tasks[0].direction is SyncDirection.BIDIRECTIONAL
|
||||||
assert loaded.tasks[0].selected_paths == ["Dokumente"]
|
assert loaded.tasks[0].selected_paths == ["Dokumente"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_corrupt_json_creates_backup(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
path.write_text("{ unvollstaendiges json...", encoding="utf-8")
|
||||||
|
|
||||||
|
store = ConfigStore(path)
|
||||||
|
with pytest.raises(ConfigError, match="syntaktisch beschädigt"):
|
||||||
|
store.load()
|
||||||
|
|
||||||
|
defekt_files = list(tmp_path.glob("config.defekt-*.json"))
|
||||||
|
assert len(defekt_files) == 1
|
||||||
|
assert not path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_read_locked_does_not_rename_file(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
path.write_text('{"schema_version": 1, "tasks": [], "settings": {}}', encoding="utf-8")
|
||||||
|
|
||||||
|
store = ConfigStore(path)
|
||||||
|
with (
|
||||||
|
patch.object(Path, "read_text", side_effect=PermissionError(13, "Permission denied")),
|
||||||
|
pytest.raises(ConfigError, match="konnte nicht gelesen werden"),
|
||||||
|
):
|
||||||
|
store.load()
|
||||||
|
|
||||||
|
# Originaldatei darf bei Lese-/Sperrfehler NICHT umbenannt/gelöscht werden!
|
||||||
|
assert path.exists()
|
||||||
|
assert list(tmp_path.glob("config.defekt-*.json")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_load_with_invalid_task_keeps_task_disabled() -> None:
|
||||||
|
data = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "Ungültig",
|
||||||
|
"source": "C:\\Ordner",
|
||||||
|
"target": "C:\\Ordner", # Gleiche Quelle und Ziel -> ValueError bei validate()
|
||||||
|
"interval_minutes": 30,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {},
|
||||||
|
}
|
||||||
|
config = AppConfig.from_dict(data)
|
||||||
|
assert len(config.tasks) == 1
|
||||||
|
assert config.tasks[0].name == "Ungültig"
|
||||||
|
assert config.tasks[0].enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_task_safe_resolve_network_oserror(tmp_path: Path) -> None:
|
||||||
|
task = SyncTask(
|
||||||
|
name="Netzwerk-Task",
|
||||||
|
source=str(tmp_path / "lokal"),
|
||||||
|
target=r"\\UnerreichbarerServer\Freigabe\Ordner",
|
||||||
|
)
|
||||||
|
with patch.object(Path, "resolve", side_effect=OSError("Network unreachable")):
|
||||||
|
task.validate()
|
||||||
|
|
||||||
|
assert task.source == str((tmp_path / "lokal").resolve())
|
||||||
|
assert r"\\UnerreichbarerServer\Freigabe\Ordner" in task.target
|
||||||
@@ -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(
|
||||||
@@ -137,3 +161,33 @@ def test_stop_persists_running_task_and_queue_for_restart(coordinator: SyncCoord
|
|||||||
|
|
||||||
assert coordinator.config.settings.paused is True
|
assert coordinator.config.settings.paused is True
|
||||||
assert coordinator.config.settings.pending_queue == [task1.id, task2.id]
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_files_automatically_added_to_task_ignored_paths_and_persisted(
|
||||||
|
coordinator: SyncCoordinator,
|
||||||
|
) -> None:
|
||||||
|
task = coordinator.config.tasks[0]
|
||||||
|
assert task.ignored_paths == []
|
||||||
|
|
||||||
|
res = SyncResult(copied=5, errors=["Zugriff verweigert: lock.dat"], failed_relatives={"lock.dat", "sub/cache.bin"})
|
||||||
|
coordinator._on_finished(task.id, res)
|
||||||
|
|
||||||
|
assert "lock.dat" in task.ignored_paths
|
||||||
|
|
||||||
|
loaded = coordinator.config_store.load()
|
||||||
|
loaded_task = next(t for t in loaded.tasks if t.id == task.id)
|
||||||
|
assert "lock.dat" in loaded_task.ignored_paths
|
||||||
@@ -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
|
||||||
@@ -269,3 +269,79 @@ def test_pause_and_resume_continues_where_interrupted(tmp_path: Path) -> None:
|
|||||||
assert (target / "a.txt").exists()
|
assert (target / "a.txt").exists()
|
||||||
assert (target / "b.txt").exists()
|
assert (target / "b.txt").exists()
|
||||||
assert (target / "c.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()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignored_paths_are_completely_skipped(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
target.mkdir()
|
||||||
|
(source / "normal.txt").write_text("kopieren", encoding="utf-8")
|
||||||
|
(source / "locked.db").write_text("gesperrt", encoding="utf-8")
|
||||||
|
(source / "subdir").mkdir()
|
||||||
|
(source / "subdir" / "ignored_file.txt").write_text("in ordner", encoding="utf-8")
|
||||||
|
|
||||||
|
task = SyncTask(
|
||||||
|
name="IgnoredTest",
|
||||||
|
source=str(source),
|
||||||
|
target=str(target),
|
||||||
|
ignored_paths=["locked.db", "subdir/ignored_file.txt"],
|
||||||
|
)
|
||||||
|
engine = _engine(tmp_path)
|
||||||
|
result = engine.synchronize(task)
|
||||||
|
|
||||||
|
assert result.successful
|
||||||
|
assert result.copied == 1
|
||||||
|
assert (target / "normal.txt").exists()
|
||||||
|
assert not (target / "locked.db").exists()
|
||||||
|
assert not (target / "subdir" / "ignored_file.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_files_added_to_failed_relatives(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
target.mkdir()
|
||||||
|
(source / "ok.txt").write_text("ok", encoding="utf-8")
|
||||||
|
(source / "fail.txt").write_text("fail", encoding="utf-8")
|
||||||
|
task = SyncTask(name="FailTest", source=str(source), target=str(target))
|
||||||
|
engine = _engine(tmp_path)
|
||||||
|
|
||||||
|
real_execute = SyncEngine._execute
|
||||||
|
|
||||||
|
def mock_execute(operation, result):
|
||||||
|
if operation.relative == "fail.txt":
|
||||||
|
raise PermissionError("Access denied")
|
||||||
|
return real_execute(operation, result)
|
||||||
|
|
||||||
|
monkeypatch.setattr(engine, "_execute", mock_execute)
|
||||||
|
result = engine.synchronize(task)
|
||||||
|
|
||||||
|
assert not result.successful
|
||||||
|
assert "fail.txt" in result.failed_relatives
|
||||||
|
assert "ok.txt" not in result.failed_relatives
|
||||||
|
assert (target / "ok.txt").exists()
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.0.10
|
1.0.15
|
||||||
Reference in new issue
Block a user