Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c31f6eb85 | ||
|
|
f83a85ed40 | ||
|
|
8218cfd9d5 | ||
|
|
6c677f0161 | ||
|
|
1d0b1b2d38 | ||
|
|
cdcd03d7b0 | ||
|
|
b6e32a1b18 | ||
|
|
786e3ae9b3 | ||
|
|
ccb89191b5 | ||
|
|
7098fa1c0c | ||
|
|
4efc1efa18 | ||
|
|
bb5bfda4fe | ||
|
|
a04db4ee96 |
No files matched your search
@@ -1,5 +1,104 @@
|
|||||||
# Änderungsprotokoll
|
# Änderungsprotokoll
|
||||||
|
|
||||||
|
## 1.0.18 – 2026-10-02
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Automatische Aufgabenwiederherstellung:** Wenn die Konfiguration fehlt oder leer ist, stellt SyncVito Aufgaben aus der neuesten lesbaren Sicherung wieder her. Die Sicherung bleibt erhalten; bereits geprüfte Sicherungen werden markiert, damit absichtlich geleerte Aufgabenlisten nicht später erneut erscheinen.
|
||||||
|
|
||||||
|
## 1.0.17 – 2026-10-01
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- **Aufgaben importieren und exportieren:** Einzelne Aufgaben und die vollständige Aufgabenliste lassen sich als JSON an frei gewählten Speicherorten sichern und laden. Exporte enthalten sämtliche Aufgabenpfade und Einstellungen. Ein Einzelimport fügt eine neue Aufgabe hinzu; ein Gesamtimport ersetzt nach Bestätigung die vorhandene Liste.
|
||||||
|
- **Einzelne Dateien synchronisieren:** Neben Ordnerpaaren kann eine Quelldatei ausgewählt und in einen frei gewählten Zielordner synchronisiert werden. Der Dateiname bleibt erhalten; Richtung, Zeitplan, Lösch- und Konfliktverhalten gelten wie bei Ordneraufgaben.
|
||||||
|
|
||||||
|
## 1.0.16 – 2026-10-01
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Verständliche Meldung bei abgelehnter NAS-Anmeldung:** Der Zugriff auf Quell- und Zielordner fängt Windows-Fehler 1326 jetzt bereits bei der Pfadprüfung ab und erklärt, dass die gespeicherten Windows-Anmeldeinformationen für den NAS-Server geprüft werden müssen.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Beim Start einer Synchronisation wird die Fortschrittsanzeige sofort zurückgesetzt. Bereits beim Einlesen von Quelle und Ziel erscheinen der aktuelle Ordner und die Anzahl gefundener Dateien; eine vorherige Updateanzeige bleibt nicht mehr unverändert stehen.
|
||||||
|
- Der Gesamtstatus kennzeichnet einen laufenden Vorgang eindeutig mit „Synchronisierung läuft“.
|
||||||
|
- Windows-Verzeichnisverknüpfungen (Junctions) werden beim Einlesen übersprungen, damit insbesondere AppData nicht mehrfach oder zyklisch durchsucht wird.
|
||||||
|
- Bei einer Unterauswahl werden nicht ausgewählte Verzeichniszweige nicht mehr vollständig durchsucht.
|
||||||
|
|
||||||
|
## 1.0.8 – 2026-09-24
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Der Updatewächter wartet zusätzlich zu den auslösenden PyInstaller-Prozessen auf weitere Instanzen derselben installierten Anwendung. Solange diese noch laufen, wird der Installer nicht gestartet.
|
||||||
|
- Das tatsächliche Ende des Installers und sein Exitcode werden zuverlässig erfasst. Der Wächter wartet dabei nicht auf die anschließend neu gestartete Anwendung.
|
||||||
|
- Abgebrochene Installationen und weiterhin laufende Instanzen werden protokolliert und beim nächsten Programmstart mit der tatsächlich gestarteten Version angezeigt.
|
||||||
|
- Installations- und Protokollpfade mit Leerzeichen oder Apostrophen werden beim Aufruf des Installers korrekt übergeben.
|
||||||
|
|
||||||
|
## 1.0.7 – 2026-09-24
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Bei gesperrten Dateien, etwa in AppData, zeigt SyncVito eine kompakte Zusammenfassung und kennzeichnet die Sicherung als unvollständig. Vollständige Dateipfade und bekannte Sperrprozesse erscheinen im Protokoll.
|
||||||
|
- Fehlende Zugriffsrechte werden von tatsächlichen Dateisperren unterschieden; zusätzliche Fehler bleiben auch bei teilweise blockierten Sicherungen sichtbar.
|
||||||
|
- Die Sperrerkennung berücksichtigt die tatsächlich betroffene Quell- oder Zieldatei und verarbeitet Windows-Dateihandles und Fehlercodes korrekt.
|
||||||
|
- Automatische Wiederholungsversuche starten bereits nach Freigabe einzelner Dateien und berücksichtigen alle bereitstehenden Aufgaben.
|
||||||
|
- Während eines erneuten Synchronisationslaufs bleibt dessen Aktivität im Gesamtstatus sichtbar.
|
||||||
|
|
||||||
## 1.0.6 – 2026-09-24
|
## 1.0.6 – 2026-09-24
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
+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.6"
|
version = "1.0.18"
|
||||||
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"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from syncvito.models import (
|
|||||||
ConflictMode,
|
ConflictMode,
|
||||||
DeleteMode,
|
DeleteMode,
|
||||||
SyncDirection,
|
SyncDirection,
|
||||||
|
SyncItemType,
|
||||||
SyncTask,
|
SyncTask,
|
||||||
TaskRuntimeState,
|
TaskRuntimeState,
|
||||||
TriggerMode,
|
TriggerMode,
|
||||||
@@ -19,6 +20,7 @@ __all__ = [
|
|||||||
"ConflictMode",
|
"ConflictMode",
|
||||||
"DeleteMode",
|
"DeleteMode",
|
||||||
"SyncDirection",
|
"SyncDirection",
|
||||||
|
"SyncItemType",
|
||||||
"SyncTask",
|
"SyncTask",
|
||||||
"TaskRuntimeState",
|
"TaskRuntimeState",
|
||||||
"TriggerMode",
|
"TriggerMode",
|
||||||
|
|||||||
+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
|
||||||
@@ -28,24 +30,160 @@ class ConfigStore:
|
|||||||
|
|
||||||
def __init__(self, path: Path | None = None) -> None:
|
def __init__(self, path: Path | None = None) -> None:
|
||||||
self.path = path or default_data_directory() / "config.json"
|
self.path = path or default_data_directory() / "config.json"
|
||||||
|
self._recovery_marker = self.path.with_name("config.recovery.json")
|
||||||
|
|
||||||
def load(self) -> AppConfig:
|
def load(self) -> AppConfig:
|
||||||
"""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 self._recover_from_backup(None) or 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")
|
||||||
|
backup_created = False
|
||||||
try:
|
try:
|
||||||
self.path.replace(recovery_path)
|
self.path.replace(recovery_path)
|
||||||
|
backup_created = True
|
||||||
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
|
if backup_created:
|
||||||
|
recovered = self._recover_from_backup(None)
|
||||||
|
if recovered is not None:
|
||||||
|
return recovered
|
||||||
|
raise ConfigError(
|
||||||
|
f"Die Konfiguration war syntaktisch beschädigt ({exc}). Sicherung: {recovery_path}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = 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")
|
||||||
|
try:
|
||||||
|
shutil.copy2(self.path, backup_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
recovered = self._recover_from_backup(None)
|
||||||
|
if recovered is not None:
|
||||||
|
return recovered
|
||||||
|
raise ConfigError(
|
||||||
|
f"Die Konfigurationsdaten konnten nicht verarbeitet werden ({exc}). Sicherungskopie: {backup_path}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if config.tasks:
|
||||||
|
self._mark_backups_handled(self._recovery_backups())
|
||||||
|
return config
|
||||||
|
return self._recover_from_backup(config) or config
|
||||||
|
|
||||||
|
def _recovery_backups(self) -> list[Path]:
|
||||||
|
"""Findet mögliche Konfigurationssicherungen in absteigender Zeitfolge."""
|
||||||
|
try:
|
||||||
|
backups = [
|
||||||
|
*self.path.parent.glob("config.defekt-*.json"),
|
||||||
|
*self.path.parent.glob("config.bak-*.json"),
|
||||||
|
]
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
return sorted(
|
||||||
|
backups,
|
||||||
|
key=lambda path: path.stem.removeprefix("config.defekt-").removeprefix("config.bak-"),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _latest_recovery_backup(self) -> tuple[Path, AppConfig] | None:
|
||||||
|
"""Liest die neueste syntaktisch gültige Sicherung."""
|
||||||
|
for backup_path in self._recovery_backups():
|
||||||
|
try:
|
||||||
|
raw = json.loads(backup_path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
return backup_path, AppConfig.from_dict(raw)
|
||||||
|
except (OSError, UnicodeError, ValueError, TypeError, AttributeError, OverflowError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handled_backup_names(self) -> set[str]:
|
||||||
|
"""Lädt die Namen bereits geprüfter Sicherungen."""
|
||||||
|
try:
|
||||||
|
raw = json.loads(self._recovery_marker.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||||
|
return set()
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return set()
|
||||||
|
names = raw.get("handled_backups", [])
|
||||||
|
if not isinstance(names, list):
|
||||||
|
return set()
|
||||||
|
return {name for name in names if isinstance(name, str) and Path(name).name == name}
|
||||||
|
|
||||||
|
def _mark_backups_handled(self, backups: list[Path]) -> None:
|
||||||
|
"""Speichert, welche Sicherungen nicht erneut automatisch geladen werden sollen."""
|
||||||
|
if not backups:
|
||||||
|
return
|
||||||
|
handled_names = self._handled_backup_names()
|
||||||
|
handled_names.update(path.name for path in backups)
|
||||||
|
temporary = self._recovery_marker.with_suffix(".json.tmp")
|
||||||
|
try:
|
||||||
|
temporary.write_text(
|
||||||
|
json.dumps({"handled_backups": sorted(handled_names)}, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
os.replace(temporary, self._recovery_marker)
|
||||||
|
except OSError:
|
||||||
|
with suppress(OSError):
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _recover_from_backup(self, current_config: AppConfig | None) -> AppConfig | None:
|
||||||
|
"""Stellt Aufgaben aus der neuesten Sicherung wieder her, wenn die Liste leer ist."""
|
||||||
|
latest = self._latest_recovery_backup()
|
||||||
|
if latest is None:
|
||||||
|
return None
|
||||||
|
backup_path, backup_config = latest
|
||||||
|
handled_names = self._handled_backup_names()
|
||||||
|
if backup_path.name in handled_names:
|
||||||
|
return None
|
||||||
|
if not backup_config.tasks:
|
||||||
|
self._mark_backups_handled(self._recovery_backups())
|
||||||
|
return None
|
||||||
|
|
||||||
|
if current_config is None:
|
||||||
|
recovered_config = backup_config
|
||||||
|
else:
|
||||||
|
snapshot_path = self.path.with_name(
|
||||||
|
f"config.vor-wiederherstellung-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.json"
|
||||||
|
)
|
||||||
|
if self.path.exists():
|
||||||
|
try:
|
||||||
|
shutil.copy2(self.path, snapshot_path)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
recovered_config = AppConfig(tasks=backup_config.tasks, settings=current_config.settings)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.save(recovered_config)
|
||||||
|
except ConfigError:
|
||||||
|
return recovered_config
|
||||||
|
self._mark_backups_handled(self._recovery_backups())
|
||||||
|
return recovered_config
|
||||||
|
|
||||||
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."""
|
||||||
|
|||||||
+184
-29
@@ -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
|
||||||
|
|
||||||
@@ -11,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
|
||||||
|
|
||||||
@@ -25,12 +27,12 @@ def derive_overall_status(
|
|||||||
if TaskRuntimeState.ERROR in states:
|
if TaskRuntimeState.ERROR in states:
|
||||||
return "error", "Fehler"
|
return "error", "Fehler"
|
||||||
if TaskRuntimeState.WAITING_FOR_RELEASE in states:
|
if TaskRuntimeState.WAITING_FOR_RELEASE in states:
|
||||||
return "error", "Wartet auf Dateifreigabe"
|
return "error", "Synchronisierung läuft" if running else "Unvollständig – Dateien gesperrt"
|
||||||
if globally_paused:
|
if globally_paused:
|
||||||
return "paused", "Pausiert"
|
return "paused", "Pausiert"
|
||||||
if TaskRuntimeState.ACTIVE not in states:
|
if TaskRuntimeState.ACTIVE not in states:
|
||||||
return "paused", "Inaktiv"
|
return "paused", "Inaktiv"
|
||||||
return "active", "Synchronisiert" if running else "Bereit"
|
return "active", "Synchronisierung läuft" if running else "Bereit"
|
||||||
|
|
||||||
|
|
||||||
class _WorkerSignals(QObject):
|
class _WorkerSignals(QObject):
|
||||||
@@ -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:
|
||||||
|
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:
|
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.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,10 +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.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)
|
||||||
@@ -200,30 +283,67 @@ 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")
|
||||||
self.config_store.save(self.config)
|
if task_id in self.config.settings.pending_queue:
|
||||||
|
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):
|
||||||
|
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
|
||||||
descriptions: list[str] = []
|
message = (
|
||||||
for blocked in result.blocked:
|
f"{result.copied} kopiert, {len(result.blocked)} Dateien gesperrt – Sicherung unvollständig. "
|
||||||
names = ", ".join(f"{process.name} (PID {process.pid})" for process in blocked.processes)
|
"Betroffene Dateien werden künftig automatisch ignoriert. Details im Protokoll."
|
||||||
descriptions.append(f"{blocked.path.name}: {names or 'sperrender Prozess nicht ermittelbar'}")
|
)
|
||||||
message = "Zurückgestellt – " + "; ".join(descriptions)
|
|
||||||
self._lock_messages[task_id] = message
|
self._lock_messages[task_id] = message
|
||||||
self._emit_lock_summary()
|
self._emit_lock_summary()
|
||||||
self.log_created.emit(task.name, "Blockiert", message)
|
self.log_created.emit(task.name, "Blockiert", message)
|
||||||
|
for blocked in result.blocked:
|
||||||
|
names = ", ".join(f"{process.name} (PID {process.pid})" for process in blocked.processes)
|
||||||
|
self.log_created.emit(
|
||||||
|
task.name, "Blockiert", f"{blocked.path}: {names or 'sperrender Prozess nicht ermittelbar'}"
|
||||||
|
)
|
||||||
self._set_task_state(task_id, TaskRuntimeState.WAITING_FOR_RELEASE)
|
self._set_task_state(task_id, TaskRuntimeState.WAITING_FOR_RELEASE)
|
||||||
elif result.errors:
|
|
||||||
self._retry_tasks[task_id] = []
|
|
||||||
self._lock_messages.pop(task_id, None)
|
|
||||||
self._emit_lock_summary()
|
|
||||||
self.log_created.emit(task.name, "Fehler", " | ".join(result.errors[:5]))
|
|
||||||
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
|
||||||
else:
|
else:
|
||||||
self._retry_tasks.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()
|
||||||
|
if result.errors:
|
||||||
|
# Auch zusätzliche Fehler eines teilweise blockierten Laufs sichtbar halten.
|
||||||
|
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:
|
||||||
|
self.log_created.emit(task.name, "Fehler", error)
|
||||||
|
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||||
|
elif not result.blocked:
|
||||||
|
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)
|
||||||
@@ -232,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)
|
||||||
@@ -244,6 +366,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)
|
||||||
|
|
||||||
@@ -272,14 +397,40 @@ 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 any(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."
|
||||||
)
|
)
|
||||||
self.enqueue(task_id)
|
self.enqueue(task_id)
|
||||||
break
|
|
||||||
|
|
||||||
def _task(self, task_id: str) -> SyncTask | None:
|
def _task(self, task_id: str) -> SyncTask | None:
|
||||||
return next((task for task in self.config.tasks if task.id == task_id), None)
|
return next((task for task in self.config.tasks if task.id == task_id), None)
|
||||||
@@ -296,4 +447,8 @@ class SyncCoordinator(QObject):
|
|||||||
self.overall_status_changed.emit(status, text)
|
self.overall_status_changed.emit(status, text)
|
||||||
|
|
||||||
def _emit_lock_summary(self) -> None:
|
def _emit_lock_summary(self) -> None:
|
||||||
self.lock_changed.emit("\n".join(self._lock_messages.values()))
|
messages = list(self._lock_messages.values())
|
||||||
|
summary = "\n".join(messages[:2])
|
||||||
|
if len(messages) > 2:
|
||||||
|
summary += f"\nWeitere {len(messages) - 2} Aufgaben mit gesperrten Dateien. Details im Protokoll."
|
||||||
|
self.lock_changed.emit(summary)
|
||||||
+58
-4
@@ -27,7 +27,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask, TriggerMode
|
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncItemType, SyncTask, TriggerMode
|
||||||
|
|
||||||
__all__ = ["TaskDialog"]
|
__all__ = ["TaskDialog"]
|
||||||
|
|
||||||
@@ -35,6 +35,8 @@ __all__ = ["TaskDialog"]
|
|||||||
class _PathField(QWidget):
|
class _PathField(QWidget):
|
||||||
def __init__(self, title: str, parent: QWidget | None = None) -> None:
|
def __init__(self, title: str, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self._title = title
|
||||||
|
self._select_file = False
|
||||||
self.edit = QLineEdit()
|
self.edit = QLineEdit()
|
||||||
self.edit.setPlaceholderText(title)
|
self.edit.setPlaceholderText(title)
|
||||||
self.button = QPushButton("Auswählen …")
|
self.button = QPushButton("Auswählen …")
|
||||||
@@ -45,7 +47,17 @@ class _PathField(QWidget):
|
|||||||
layout.addWidget(self.edit, 1)
|
layout.addWidget(self.edit, 1)
|
||||||
layout.addWidget(self.button)
|
layout.addWidget(self.button)
|
||||||
|
|
||||||
|
def set_file_selection(self, enabled: bool) -> None:
|
||||||
|
"""Schaltet zwischen Ordner- und Dateiauswahl um."""
|
||||||
|
self._select_file = enabled
|
||||||
|
self.edit.setPlaceholderText("Einzelne Quelldatei wählen" if enabled else self._title)
|
||||||
|
|
||||||
def _browse(self) -> None:
|
def _browse(self) -> None:
|
||||||
|
if self._select_file:
|
||||||
|
selected, _ = QFileDialog.getOpenFileName(self, "Quelldatei auswählen", self.edit.text(), "Alle Dateien (*)")
|
||||||
|
if selected:
|
||||||
|
self.edit.setText(QDir.toNativeSeparators(selected))
|
||||||
|
return
|
||||||
selected = QFileDialog.getExistingDirectory(self, "Ordner auswählen", self.edit.text())
|
selected = QFileDialog.getExistingDirectory(self, "Ordner auswählen", self.edit.text())
|
||||||
if selected:
|
if selected:
|
||||||
self.edit.setText(QDir.toNativeSeparators(selected))
|
self.edit.setText(QDir.toNativeSeparators(selected))
|
||||||
@@ -133,6 +145,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)
|
||||||
@@ -141,9 +154,13 @@ class TaskDialog(QDialog):
|
|||||||
self.name_edit.setPlaceholderText("z. B. Projektarchiv")
|
self.name_edit.setPlaceholderText("z. B. Projektarchiv")
|
||||||
self.source_field = _PathField("Lokalen Quellordner wählen")
|
self.source_field = _PathField("Lokalen Quellordner wählen")
|
||||||
self.target_field = _PathField("NAS- oder Zielordner wählen")
|
self.target_field = _PathField("NAS- oder Zielordner wählen")
|
||||||
|
self.item_type_combo = QComboBox()
|
||||||
|
self.item_type_combo.addItem("Ordnerpaar", SyncItemType.FOLDER)
|
||||||
|
self.item_type_combo.addItem("Einzelne Datei → Zielordner", SyncItemType.FILE)
|
||||||
if task:
|
if task:
|
||||||
self.source_field.edit.setText(task.source)
|
self.source_field.edit.setText(task.source)
|
||||||
self.target_field.edit.setText(task.target)
|
self.target_field.edit.setText(task.target)
|
||||||
|
self._set_combo(self.item_type_combo, task.item_type)
|
||||||
|
|
||||||
self.direction_combo = QComboBox()
|
self.direction_combo = QComboBox()
|
||||||
self.direction_combo.addItem("Einweg – Quelle → Ziel", SyncDirection.ONE_WAY)
|
self.direction_combo.addItem("Einweg – Quelle → Ziel", SyncDirection.ONE_WAY)
|
||||||
@@ -178,6 +195,8 @@ class TaskDialog(QDialog):
|
|||||||
selection_row = QHBoxLayout()
|
selection_row = QHBoxLayout()
|
||||||
selection_row.addWidget(self.selection_label, 1)
|
selection_row.addWidget(self.selection_label, 1)
|
||||||
selection_row.addWidget(selection_button)
|
selection_row.addWidget(selection_button)
|
||||||
|
self.selection_row_widget = QWidget()
|
||||||
|
self.selection_row_widget.setLayout(selection_row)
|
||||||
|
|
||||||
base_page = QWidget()
|
base_page = QWidget()
|
||||||
base_form = QFormLayout(base_page)
|
base_form = QFormLayout(base_page)
|
||||||
@@ -185,12 +204,15 @@ class TaskDialog(QDialog):
|
|||||||
base_form.setHorizontalSpacing(18)
|
base_form.setHorizontalSpacing(18)
|
||||||
base_form.setVerticalSpacing(14)
|
base_form.setVerticalSpacing(14)
|
||||||
base_form.addRow("Name", self.name_edit)
|
base_form.addRow("Name", self.name_edit)
|
||||||
|
base_form.addRow("Aufgabentyp", self.item_type_combo)
|
||||||
base_form.addRow("Quellordner", self.source_field)
|
base_form.addRow("Quellordner", self.source_field)
|
||||||
|
self.source_label = base_form.labelForField(self.source_field)
|
||||||
base_form.addRow("Zielordner", self.target_field)
|
base_form.addRow("Zielordner", self.target_field)
|
||||||
base_form.addRow("Richtung", self.direction_combo)
|
base_form.addRow("Richtung", self.direction_combo)
|
||||||
base_form.addRow("Auslösung", self.trigger_combo)
|
base_form.addRow("Auslösung", self.trigger_combo)
|
||||||
base_form.addRow("Intervall", self.interval_spin)
|
base_form.addRow("Intervall", self.interval_spin)
|
||||||
base_form.addRow("Umfang", selection_row)
|
base_form.addRow("Umfang", self.selection_row_widget)
|
||||||
|
self.selection_row_label = base_form.labelForField(self.selection_row_widget)
|
||||||
base_form.addRow("", self.enabled_check)
|
base_form.addRow("", self.enabled_check)
|
||||||
|
|
||||||
behavior_page = QWidget()
|
behavior_page = QWidget()
|
||||||
@@ -208,8 +230,21 @@ 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, "Pfade und Zeitplan")
|
||||||
tabs.addTab(behavior_page, "Konflikte und Löschungen")
|
tabs.addTab(behavior_page, "Konflikte und Löschungen")
|
||||||
|
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
|
||||||
@@ -224,10 +259,16 @@ class TaskDialog(QDialog):
|
|||||||
layout.addWidget(buttons)
|
layout.addWidget(buttons)
|
||||||
self.trigger_combo.currentIndexChanged.connect(self._update_visibility)
|
self.trigger_combo.currentIndexChanged.connect(self._update_visibility)
|
||||||
self.direction_combo.currentIndexChanged.connect(self._update_visibility)
|
self.direction_combo.currentIndexChanged.connect(self._update_visibility)
|
||||||
|
self.item_type_combo.currentIndexChanged.connect(self._update_visibility)
|
||||||
self.source_field.edit.textChanged.connect(self._update_selection_label)
|
self.source_field.edit.textChanged.connect(self._update_selection_label)
|
||||||
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(
|
||||||
@@ -235,12 +276,14 @@ class TaskDialog(QDialog):
|
|||||||
name=self.name_edit.text(),
|
name=self.name_edit.text(),
|
||||||
source=self.source_field.edit.text(),
|
source=self.source_field.edit.text(),
|
||||||
target=self.target_field.edit.text(),
|
target=self.target_field.edit.text(),
|
||||||
|
item_type=self.item_type_combo.currentData(),
|
||||||
direction=self.direction_combo.currentData(),
|
direction=self.direction_combo.currentData(),
|
||||||
trigger=self.trigger_combo.currentData(),
|
trigger=self.trigger_combo.currentData(),
|
||||||
delete_mode=self.delete_combo.currentData(),
|
delete_mode=self.delete_combo.currentData(),
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -248,6 +291,8 @@ class TaskDialog(QDialog):
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
def _choose_selection(self) -> None:
|
def _choose_selection(self) -> None:
|
||||||
|
if self.item_type_combo.currentData() is SyncItemType.FILE:
|
||||||
|
return
|
||||||
root = Path(self.source_field.edit.text())
|
root = Path(self.source_field.edit.text())
|
||||||
if not root.is_dir():
|
if not root.is_dir():
|
||||||
QMessageBox.warning(self, "Quellordner fehlt", "Bitte zuerst einen erreichbaren Quellordner auswählen.")
|
QMessageBox.warning(self, "Quellordner fehlt", "Bitte zuerst einen erreichbaren Quellordner auswählen.")
|
||||||
@@ -266,11 +311,20 @@ class TaskDialog(QDialog):
|
|||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
def _update_visibility(self) -> None:
|
def _update_visibility(self) -> None:
|
||||||
|
is_file_task = self.item_type_combo.currentData() is SyncItemType.FILE
|
||||||
|
self.source_field.set_file_selection(is_file_task)
|
||||||
|
if self.source_label is not None:
|
||||||
|
self.source_label.setText("Quelldatei" if is_file_task else "Quellordner")
|
||||||
|
self.selection_row_widget.setVisible(not is_file_task)
|
||||||
|
if self.selection_row_label is not None:
|
||||||
|
self.selection_row_label.setVisible(not is_file_task)
|
||||||
self.interval_spin.setEnabled(self.trigger_combo.currentData() is TriggerMode.INTERVAL)
|
self.interval_spin.setEnabled(self.trigger_combo.currentData() is TriggerMode.INTERVAL)
|
||||||
self.conflict_combo.setEnabled(self.direction_combo.currentData() is SyncDirection.BIDIRECTIONAL)
|
self.conflict_combo.setEnabled(self.direction_combo.currentData() is SyncDirection.BIDIRECTIONAL)
|
||||||
|
|
||||||
def _update_selection_label(self) -> None:
|
def _update_selection_label(self) -> None:
|
||||||
if self._selected_paths:
|
if self.item_type_combo.currentData() is SyncItemType.FILE:
|
||||||
|
self.selection_label.setText("Nur die ausgewählte Quelldatei")
|
||||||
|
elif self._selected_paths:
|
||||||
self.selection_label.setText(f"{len(self._selected_paths)} Einträge ausgewählt")
|
self.selection_label.setText(f"{len(self._selected_paths)} Einträge ausgewählt")
|
||||||
else:
|
else:
|
||||||
self.selection_label.setText("Gesamter Quellordner")
|
self.selection_label.setText("Gesamter Quellordner")
|
||||||
|
|||||||
@@ -23,7 +23,22 @@ def is_file_locked(path: Path) -> bool:
|
|||||||
"""Prüft eine vorhandene Datei durch exklusives Öffnen ohne Schreibzugriff."""
|
"""Prüft eine vorhandene Datei durch exklusives Öffnen ohne Schreibzugriff."""
|
||||||
if not path.exists() or os.name != "nt":
|
if not path.exists() or os.name != "nt":
|
||||||
return False
|
return False
|
||||||
handle = ctypes.windll.kernel32.CreateFileW(
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
create_file = kernel32.CreateFileW
|
||||||
|
create_file.argtypes = [
|
||||||
|
wintypes.LPCWSTR,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.DWORD,
|
||||||
|
ctypes.c_void_p,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.HANDLE,
|
||||||
|
]
|
||||||
|
create_file.restype = wintypes.HANDLE
|
||||||
|
close_handle = kernel32.CloseHandle
|
||||||
|
close_handle.argtypes = [wintypes.HANDLE]
|
||||||
|
close_handle.restype = wintypes.BOOL
|
||||||
|
handle = create_file(
|
||||||
str(path),
|
str(path),
|
||||||
0x80000000,
|
0x80000000,
|
||||||
0,
|
0,
|
||||||
@@ -34,8 +49,8 @@ def is_file_locked(path: Path) -> bool:
|
|||||||
)
|
)
|
||||||
invalid_handle = ctypes.c_void_p(-1).value
|
invalid_handle = ctypes.c_void_p(-1).value
|
||||||
if handle == invalid_handle:
|
if handle == invalid_handle:
|
||||||
return ctypes.get_last_error() in {5, 32, 33}
|
return ctypes.get_last_error() in {32, 33}
|
||||||
ctypes.windll.kernel32.CloseHandle(handle)
|
close_handle(handle)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+252
-8
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -13,6 +14,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QApplication,
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
|
QFileDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
@@ -40,6 +42,7 @@ from syncvito.dialogs import TaskDialog
|
|||||||
from syncvito.icons import app_icon, status_icon
|
from syncvito.icons import app_icon, status_icon
|
||||||
from syncvito.models import AppConfig, SyncDirection, SyncTask, TaskRuntimeState, TriggerMode
|
from syncvito.models import AppConfig, SyncDirection, SyncTask, TaskRuntimeState, TriggerMode
|
||||||
from syncvito.state_store import StateStore
|
from syncvito.state_store import StateStore
|
||||||
|
from syncvito.task_transfer import TaskTransferError, export_task, export_tasks, import_task, import_tasks
|
||||||
from syncvito.theme import apply_theme
|
from syncvito.theme import apply_theme
|
||||||
from syncvito.update_controller import UpdateController
|
from syncvito.update_controller import UpdateController
|
||||||
from syncvito.update_dialog import UpdateDialog
|
from syncvito.update_dialog import UpdateDialog
|
||||||
@@ -114,6 +117,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.update_retry_timer.setInterval(UPDATE_RETRY_INTERVAL_MS)
|
self.update_retry_timer.setInterval(UPDATE_RETRY_INTERVAL_MS)
|
||||||
self.update_retry_timer.timeout.connect(self._automatic_update_check)
|
self.update_retry_timer.timeout.connect(self._automatic_update_check)
|
||||||
QTimer.singleShot(3000, self._automatic_update_check)
|
QTimer.singleShot(3000, self._automatic_update_check)
|
||||||
|
QTimer.singleShot(1000, self._show_previous_update_error)
|
||||||
cleanup_attempts = 6 if "--updated" in sys.argv else 1
|
cleanup_attempts = 6 if "--updated" in sys.argv else 1
|
||||||
QTimer.singleShot(3000, lambda: self._cleanup_update_installers(cleanup_attempts))
|
QTimer.singleShot(3000, lambda: self._cleanup_update_installers(cleanup_attempts))
|
||||||
if "--updated" in sys.argv:
|
if "--updated" in sys.argv:
|
||||||
@@ -139,8 +143,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,
|
||||||
)
|
)
|
||||||
@@ -234,6 +238,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:
|
||||||
@@ -247,6 +254,15 @@ class MainWindow(QMainWindow):
|
|||||||
edit_button = QPushButton("Bearbeiten")
|
edit_button = QPushButton("Bearbeiten")
|
||||||
delete_button = QPushButton("Löschen")
|
delete_button = QPushButton("Löschen")
|
||||||
delete_button.setProperty("danger", True)
|
delete_button.setProperty("danger", True)
|
||||||
|
transfer_button = QPushButton("Import / Export")
|
||||||
|
transfer_menu = QMenu(transfer_button)
|
||||||
|
transfer_menu.addSection("Exportieren")
|
||||||
|
transfer_menu.addAction("Ausgewählte Aufgabe exportieren …", self._export_selected_task)
|
||||||
|
transfer_menu.addAction("Alle Aufgaben exportieren …", self._export_all_tasks)
|
||||||
|
transfer_menu.addSection("Importieren")
|
||||||
|
transfer_menu.addAction("Einzelne Aufgabe laden …", self._import_single_task)
|
||||||
|
transfer_menu.addAction("Alle Aufgaben ersetzen …", self._import_all_tasks)
|
||||||
|
transfer_button.setMenu(transfer_menu)
|
||||||
self.sync_selected_button = QPushButton("Auswahl synchronisieren")
|
self.sync_selected_button = QPushButton("Auswahl synchronisieren")
|
||||||
add_button.clicked.connect(self._add_task)
|
add_button.clicked.connect(self._add_task)
|
||||||
edit_button.clicked.connect(self._edit_task)
|
edit_button.clicked.connect(self._edit_task)
|
||||||
@@ -255,6 +271,7 @@ class MainWindow(QMainWindow):
|
|||||||
toolbar.addWidget(add_button)
|
toolbar.addWidget(add_button)
|
||||||
toolbar.addWidget(edit_button)
|
toolbar.addWidget(edit_button)
|
||||||
toolbar.addWidget(delete_button)
|
toolbar.addWidget(delete_button)
|
||||||
|
toolbar.addWidget(transfer_button)
|
||||||
toolbar.addStretch(1)
|
toolbar.addStretch(1)
|
||||||
toolbar.addWidget(self.sync_selected_button)
|
toolbar.addWidget(self.sync_selected_button)
|
||||||
layout.addLayout(toolbar)
|
layout.addLayout(toolbar)
|
||||||
@@ -417,8 +434,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
|
||||||
|
|
||||||
@@ -477,6 +500,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)
|
||||||
@@ -488,9 +516,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:
|
||||||
@@ -535,23 +580,155 @@ class MainWindow(QMainWindow):
|
|||||||
StateStore().remove(task.id)
|
StateStore().remove(task.id)
|
||||||
self._persist_tasks("Aufgabe gelöscht")
|
self._persist_tasks("Aufgabe gelöscht")
|
||||||
|
|
||||||
|
def _export_selected_task(self) -> None:
|
||||||
|
task = self._selected_task()
|
||||||
|
if task is None:
|
||||||
|
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||||
|
return
|
||||||
|
safe_name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", task.name).strip(" .")[:80] or "Aufgabe"
|
||||||
|
suggested_path = Path.home() / f"SyncVito-{safe_name}.json"
|
||||||
|
filename, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Aufgabe exportieren",
|
||||||
|
str(suggested_path),
|
||||||
|
"JSON-Dateien (*.json)",
|
||||||
|
)
|
||||||
|
if not filename:
|
||||||
|
return
|
||||||
|
export_path = self._json_export_path(filename)
|
||||||
|
try:
|
||||||
|
export_task(export_path, task)
|
||||||
|
except TaskTransferError as exc:
|
||||||
|
QMessageBox.critical(self, "Export fehlgeschlagen", str(exc))
|
||||||
|
self._append_log("Aufgabe exportiert", "Fehler", str(exc))
|
||||||
|
return
|
||||||
|
self._append_log("Aufgabe exportiert", "Erfolg", f"{task.name}: {export_path}")
|
||||||
|
QMessageBox.information(self, "Export abgeschlossen", f"Die Aufgabe wurde gespeichert unter:\n{export_path}")
|
||||||
|
|
||||||
|
def _export_all_tasks(self) -> None:
|
||||||
|
suggested_path = Path.home() / "SyncVito-Aufgaben.json"
|
||||||
|
filename, _ = QFileDialog.getSaveFileName(
|
||||||
|
self,
|
||||||
|
"Alle Aufgaben exportieren",
|
||||||
|
str(suggested_path),
|
||||||
|
"JSON-Dateien (*.json)",
|
||||||
|
)
|
||||||
|
if not filename:
|
||||||
|
return
|
||||||
|
export_path = self._json_export_path(filename)
|
||||||
|
try:
|
||||||
|
export_tasks(export_path, self.config.tasks)
|
||||||
|
except TaskTransferError as exc:
|
||||||
|
QMessageBox.critical(self, "Export fehlgeschlagen", str(exc))
|
||||||
|
self._append_log("Alle Aufgaben exportiert", "Fehler", str(exc))
|
||||||
|
return
|
||||||
|
detail = f"{len(self.config.tasks)} Aufgaben: {export_path}"
|
||||||
|
self._append_log("Alle Aufgaben exportiert", "Erfolg", detail)
|
||||||
|
QMessageBox.information(self, "Export abgeschlossen", f"Alle Aufgaben wurden gespeichert unter:\n{export_path}")
|
||||||
|
|
||||||
|
def _import_single_task(self) -> None:
|
||||||
|
filename, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
"Einzelne Aufgabe laden",
|
||||||
|
str(Path.home()),
|
||||||
|
"JSON-Dateien (*.json);;Alle Dateien (*)",
|
||||||
|
)
|
||||||
|
if not filename:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
task = import_task(Path(filename))
|
||||||
|
except TaskTransferError as exc:
|
||||||
|
QMessageBox.critical(self, "Import fehlgeschlagen", str(exc))
|
||||||
|
self._append_log("Aufgabe importiert", "Fehler", str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.config.tasks.append(task)
|
||||||
|
if not self._persist_tasks("Aufgabe importiert"):
|
||||||
|
self.config.tasks.remove(task)
|
||||||
|
self._refresh_tasks()
|
||||||
|
return
|
||||||
|
QMessageBox.information(self, "Import abgeschlossen", f"„{task.name}“ wurde als neue Aufgabe hinzugefügt.")
|
||||||
|
|
||||||
|
def _import_all_tasks(self) -> None:
|
||||||
|
if self._has_pending_sync_work():
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Synchronisation läuft",
|
||||||
|
"Alle Aufgaben können erst ersetzt werden, wenn keine Synchronisation läuft oder wartet.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
filename, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
"Alle Aufgaben laden",
|
||||||
|
str(Path.home()),
|
||||||
|
"JSON-Dateien (*.json);;Alle Dateien (*)",
|
||||||
|
)
|
||||||
|
if not filename:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
imported_tasks = import_tasks(Path(filename))
|
||||||
|
except TaskTransferError as exc:
|
||||||
|
QMessageBox.critical(self, "Import fehlgeschlagen", str(exc))
|
||||||
|
self._append_log("Alle Aufgaben importiert", "Fehler", str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
answer = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Alle Aufgaben ersetzen",
|
||||||
|
f"Die Datei enthält {len(imported_tasks)} Aufgaben. "
|
||||||
|
f"Damit werden alle {len(self.config.tasks)} vorhandenen Aufgaben ersetzt. Fortfahren?",
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No,
|
||||||
|
)
|
||||||
|
if answer != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
if self._has_pending_sync_work():
|
||||||
|
QMessageBox.information(
|
||||||
|
self,
|
||||||
|
"Synchronisation läuft",
|
||||||
|
"Während des Imports wurde eine Synchronisation gestartet. Bitte den Import danach wiederholen.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
previous_tasks = self.config.tasks[:]
|
||||||
|
previous_queue = self.config.settings.pending_queue[:]
|
||||||
|
self.config.tasks[:] = imported_tasks
|
||||||
|
self.config.settings.pending_queue = []
|
||||||
|
if not self._persist_tasks("Alle Aufgaben importiert"):
|
||||||
|
self.config.tasks[:] = previous_tasks
|
||||||
|
self.config.settings.pending_queue = previous_queue
|
||||||
|
self._refresh_tasks()
|
||||||
|
return
|
||||||
|
QMessageBox.information(self, "Import abgeschlossen", f"{len(imported_tasks)} Aufgaben wurden geladen.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json_export_path(filename: str) -> Path:
|
||||||
|
path = Path(filename)
|
||||||
|
return path if path.suffix else path.with_suffix(".json")
|
||||||
|
|
||||||
|
def _has_pending_sync_work(self) -> bool:
|
||||||
|
return self.coordinator.is_running or bool(self.coordinator.queued_task_ids)
|
||||||
|
|
||||||
def _sync_selected(self) -> None:
|
def _sync_selected(self) -> None:
|
||||||
task = self._selected_task()
|
task = self._selected_task()
|
||||||
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) -> bool:
|
||||||
try:
|
try:
|
||||||
self.config_store.save(self.config)
|
self.config_store.save(self.config)
|
||||||
self.coordinator.refresh_task_states()
|
self.coordinator.refresh_task_states()
|
||||||
self.coordinator.rebuild_monitors()
|
self.coordinator.rebuild_monitors()
|
||||||
except ConfigError as exc:
|
except ConfigError as exc:
|
||||||
QMessageBox.critical(self, "Speicherfehler", str(exc))
|
QMessageBox.critical(self, "Speicherfehler", str(exc))
|
||||||
return
|
return False
|
||||||
self._refresh_tasks()
|
self._refresh_tasks()
|
||||||
self._append_log("Aufgaben", "Erfolg", message)
|
self._append_log("Aufgaben", "Erfolg", message)
|
||||||
|
return True
|
||||||
|
|
||||||
def _save_settings(self) -> None:
|
def _save_settings(self) -> None:
|
||||||
settings = self.config.settings
|
settings = self.config.settings
|
||||||
@@ -620,10 +797,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")
|
||||||
@@ -666,6 +851,16 @@ class MainWindow(QMainWindow):
|
|||||||
self._append_log("Update", "Erfolg", message)
|
self._append_log("Update", "Erfolg", message)
|
||||||
self.tray.showMessage("SyncVito aktualisiert", message, QSystemTrayIcon.MessageIcon.Information, 5000)
|
self.tray.showMessage("SyncVito aktualisiert", message, QSystemTrayIcon.MessageIcon.Information, 5000)
|
||||||
|
|
||||||
|
def _show_previous_update_error(self) -> None:
|
||||||
|
message = self.updater.service.take_install_error()
|
||||||
|
if message:
|
||||||
|
self._append_log("Update", "Fehler", message)
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"Update nicht abgeschlossen",
|
||||||
|
f"{message}\n\nGestartete Version: {__version__}\nProtokolle: {self.updater.service.update_directory}",
|
||||||
|
)
|
||||||
|
|
||||||
def _cleanup_update_installers(self, attempts_remaining: int = 1) -> None:
|
def _cleanup_update_installers(self, attempts_remaining: int = 1) -> None:
|
||||||
_removed, pending = self.updater.service.cleanup_installers()
|
_removed, pending = self.updater.service.cleanup_installers()
|
||||||
if pending and attempts_remaining > 1:
|
if pending and attempts_remaining > 1:
|
||||||
@@ -689,17 +884,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()
|
||||||
@@ -711,6 +946,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:
|
||||||
|
|||||||
+71
-14
@@ -15,6 +15,7 @@ __all__ = [
|
|||||||
"ConflictMode",
|
"ConflictMode",
|
||||||
"DeleteMode",
|
"DeleteMode",
|
||||||
"SyncDirection",
|
"SyncDirection",
|
||||||
|
"SyncItemType",
|
||||||
"SyncTask",
|
"SyncTask",
|
||||||
"TaskRuntimeState",
|
"TaskRuntimeState",
|
||||||
"TriggerMode",
|
"TriggerMode",
|
||||||
@@ -28,6 +29,13 @@ class SyncDirection(StrEnum):
|
|||||||
BIDIRECTIONAL = "bidirectional"
|
BIDIRECTIONAL = "bidirectional"
|
||||||
|
|
||||||
|
|
||||||
|
class SyncItemType(StrEnum):
|
||||||
|
"""Art der zu synchronisierenden Quelle."""
|
||||||
|
|
||||||
|
FOLDER = "folder"
|
||||||
|
FILE = "file"
|
||||||
|
|
||||||
|
|
||||||
class TriggerMode(StrEnum):
|
class TriggerMode(StrEnum):
|
||||||
"""Unterstützte Auslöser für eine Aufgabe."""
|
"""Unterstützte Auslöser für eine Aufgabe."""
|
||||||
|
|
||||||
@@ -61,7 +69,7 @@ class TaskRuntimeState(StrEnum):
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class SyncTask:
|
class SyncTask:
|
||||||
"""Persistierbare Konfiguration eines Ordnerpaars."""
|
"""Persistierbare Konfiguration eines Ordner- oder Dateiabgleichs."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
source: str
|
source: str
|
||||||
@@ -73,37 +81,59 @@ 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
|
||||||
|
item_type: SyncItemType = SyncItemType.FOLDER
|
||||||
|
|
||||||
def validate(self) -> None:
|
def validate(self) -> None:
|
||||||
"""Validiert sicherheitsrelevante Aufgabeneigenschaften frühzeitig."""
|
"""Validiert sicherheitsrelevante Aufgabeneigenschaften frühzeitig."""
|
||||||
if not self.name.strip():
|
if not self.name.strip():
|
||||||
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 Zielpfade müssen angegeben werden.")
|
||||||
source = Path(self.source).expanduser().resolve(strict=False)
|
self.item_type = SyncItemType(self.item_type)
|
||||||
target = Path(self.target).expanduser().resolve(strict=False)
|
source = _safe_resolve(Path(self.source).expanduser())
|
||||||
source_norm = os.path.normcase(str(source))
|
target = _safe_resolve(Path(self.target).expanduser())
|
||||||
|
source_root = source.parent if self.item_type is SyncItemType.FILE else source
|
||||||
|
source_norm = os.path.normcase(str(source_root))
|
||||||
target_norm = os.path.normcase(str(target))
|
target_norm = os.path.normcase(str(target))
|
||||||
if source_norm == target_norm:
|
if source_norm == target_norm:
|
||||||
raise ValueError("Quell- und Zielordner dürfen nicht identisch sein.")
|
raise ValueError("Quell- und Zielpfade dürfen nicht identisch sein oder ineinander liegen.")
|
||||||
if _is_relative_to(source, target) or _is_relative_to(target, source):
|
if _is_relative_to(source_root, target) or _is_relative_to(target, source_root):
|
||||||
raise ValueError("Quell- und Zielordner dürfen nicht ineinander liegen.")
|
raise ValueError("Quell- und Zielordner dürfen nicht ineinander liegen.")
|
||||||
|
if self.item_type is SyncItemType.FILE:
|
||||||
|
if source.is_dir():
|
||||||
|
raise ValueError("Die Quelle einer Dateiaufgabe muss eine Datei sein.")
|
||||||
|
if target.exists() and not target.is_dir():
|
||||||
|
raise ValueError("Das Ziel einer Dateiaufgabe muss ein Ordner sein.")
|
||||||
|
else:
|
||||||
|
if source.exists() and not source.is_dir():
|
||||||
|
raise ValueError("Die Quelle einer Ordneraufgabe muss ein Ordner sein.")
|
||||||
|
if target.exists() and not target.is_dir():
|
||||||
|
raise ValueError("Das Ziel einer Ordneraufgabe muss ein Ordner sein.")
|
||||||
if not 1 <= self.interval_minutes <= 10080:
|
if not 1 <= self.interval_minutes <= 10080:
|
||||||
raise ValueError("Das Intervall muss zwischen 1 Minute und 7 Tagen liegen.")
|
raise ValueError("Das Intervall muss zwischen 1 Minute und 7 Tagen liegen.")
|
||||||
self.name = self.name.strip()
|
self.name = self.name.strip()
|
||||||
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()})
|
if self.item_type is SyncItemType.FILE:
|
||||||
|
self.selected_paths = [source.name]
|
||||||
|
else:
|
||||||
|
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."""
|
||||||
data = asdict(self)
|
data = asdict(self)
|
||||||
|
data["item_type"] = self.item_type.value
|
||||||
data["direction"] = self.direction.value
|
data["direction"] = self.direction.value
|
||||||
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
|
||||||
@@ -114,16 +144,21 @@ class SyncTask:
|
|||||||
name=str(data.get("name", "Unbenannte Aufgabe")),
|
name=str(data.get("name", "Unbenannte Aufgabe")),
|
||||||
source=str(data.get("source", "")),
|
source=str(data.get("source", "")),
|
||||||
target=str(data.get("target", "")),
|
target=str(data.get("target", "")),
|
||||||
|
item_type=SyncItemType(data.get("item_type", SyncItemType.FOLDER.value)),
|
||||||
direction=SyncDirection(data.get("direction", SyncDirection.ONE_WAY.value)),
|
direction=SyncDirection(data.get("direction", SyncDirection.ONE_WAY.value)),
|
||||||
trigger=TriggerMode(data.get("trigger", TriggerMode.MANUAL.value)),
|
trigger=TriggerMode(data.get("trigger", TriggerMode.MANUAL.value)),
|
||||||
delete_mode=DeleteMode(data.get("delete_mode", DeleteMode.KEEP.value)),
|
delete_mode=DeleteMode(data.get("delete_mode", DeleteMode.KEEP.value)),
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -137,6 +172,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 +184,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 +200,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"),
|
||||||
)
|
)
|
||||||
@@ -188,12 +226,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)
|
||||||
@@ -204,7 +258,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
|
||||||
@@ -2,23 +2,38 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
|
|
||||||
from syncvito.models import SyncDirection, SyncTask, TriggerMode
|
from syncvito.models import SyncDirection, SyncItemType, SyncTask, TriggerMode
|
||||||
|
|
||||||
__all__ = ["RealtimeMonitor"]
|
__all__ = ["RealtimeMonitor"]
|
||||||
|
|
||||||
|
|
||||||
class _TaskEventHandler(FileSystemEventHandler):
|
class _TaskEventHandler(FileSystemEventHandler):
|
||||||
def __init__(self, task_id: str, callback: Callable[[str], None]) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
callback: Callable[[str], None],
|
||||||
|
watched_files: set[str] | None = None,
|
||||||
|
) -> None:
|
||||||
self.task_id = task_id
|
self.task_id = task_id
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
|
self.watched_files = watched_files
|
||||||
|
|
||||||
def on_any_event(self, event: FileSystemEvent) -> None:
|
def on_any_event(self, event: FileSystemEvent) -> None:
|
||||||
|
if self.watched_files is not None:
|
||||||
|
event_paths = {event.src_path}
|
||||||
|
destination = getattr(event, "dest_path", None)
|
||||||
|
if destination:
|
||||||
|
event_paths.add(destination)
|
||||||
|
normalized_paths = {os.path.normcase(os.path.abspath(path)) for path in event_paths}
|
||||||
|
if not self.watched_files.intersection(normalized_paths):
|
||||||
|
return
|
||||||
if not event.is_directory or event.event_type in {"created", "deleted", "moved", "modified"}:
|
if not event.is_directory or event.event_type in {"created", "deleted", "moved", "modified"}:
|
||||||
self.callback(self.task_id)
|
self.callback(self.task_id)
|
||||||
|
|
||||||
@@ -39,16 +54,26 @@ class RealtimeMonitor:
|
|||||||
for task in tasks:
|
for task in tasks:
|
||||||
if not task.enabled or task.trigger is not TriggerMode.REALTIME:
|
if not task.enabled or task.trigger is not TriggerMode.REALTIME:
|
||||||
continue
|
continue
|
||||||
handler = _TaskEventHandler(task.id, self.callback)
|
watched_files: set[str] | None = None
|
||||||
paths = [Path(task.source)]
|
if task.item_type is SyncItemType.FILE:
|
||||||
if task.direction is SyncDirection.BIDIRECTIONAL:
|
source_file = Path(task.source)
|
||||||
paths.append(Path(task.target))
|
watched_files = {os.path.normcase(os.path.abspath(str(source_file)))}
|
||||||
|
paths = [source_file.parent]
|
||||||
|
if task.direction is SyncDirection.BIDIRECTIONAL:
|
||||||
|
target_file = Path(task.target) / source_file.name
|
||||||
|
watched_files.add(os.path.normcase(os.path.abspath(str(target_file))))
|
||||||
|
paths.append(Path(task.target))
|
||||||
|
else:
|
||||||
|
paths = [Path(task.source)]
|
||||||
|
if task.direction is SyncDirection.BIDIRECTIONAL:
|
||||||
|
paths.append(Path(task.target))
|
||||||
|
handler = _TaskEventHandler(task.id, self.callback, watched_files)
|
||||||
for path in paths:
|
for path in paths:
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
errors.append((task.id, f"Echtzeitüberwachung nicht möglich: {path}"))
|
errors.append((task.id, f"Echtzeitüberwachung nicht möglich: {path}"))
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
observer.schedule(handler, str(path), recursive=True)
|
observer.schedule(handler, str(path), recursive=task.item_type is SyncItemType.FOLDER)
|
||||||
scheduled = True
|
scheduled = True
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
errors.append((task.id, f"Überwachung fehlgeschlagen ({path}): {exc}"))
|
errors.append((task.id, f"Überwachung fehlgeschlagen ({path}): {exc}"))
|
||||||
|
|||||||
+231
-23
@@ -4,14 +4,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
from syncvito.file_locks import LockingProcess, find_locking_processes
|
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, SyncItemType, SyncTask, _normalize_relative_path
|
||||||
from syncvito.state_store import StateStore
|
from syncvito.state_store import StateStore
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -20,6 +22,7 @@ __all__ = [
|
|||||||
"SyncEngine",
|
"SyncEngine",
|
||||||
"SyncResult",
|
"SyncResult",
|
||||||
"UnavailablePathError",
|
"UnavailablePathError",
|
||||||
|
"is_relative_path_ignored",
|
||||||
]
|
]
|
||||||
|
|
||||||
ProgressCallback = Callable[[int, str], None]
|
ProgressCallback = Callable[[int, str], None]
|
||||||
@@ -29,6 +32,36 @@ 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 _unavailable_path_message(label: str, path: Path, exc: OSError) -> str:
|
||||||
|
"""Erklärt typische NAS-Anmeldefehler verständlich und erhält den Pfadkontext."""
|
||||||
|
if getattr(exc, "winerror", None) == 1326:
|
||||||
|
return (
|
||||||
|
f"{label} nicht erreichbar: Windows hat die NAS-Anmeldung abgelehnt "
|
||||||
|
"(WinError 1326: Benutzername oder Kennwort falsch). Prüfe die gespeicherten "
|
||||||
|
"Windows-Anmeldeinformationen für den NAS-Server und teste den Zugriff im Explorer. "
|
||||||
|
f"Pfad: {path}"
|
||||||
|
)
|
||||||
|
return f"{label} nicht erreichbar: {path}"
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
@@ -59,14 +92,16 @@ 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)
|
||||||
|
failed_relatives: set[str] = field(default_factory=set)
|
||||||
inaccessible: bool = False
|
inaccessible: bool = False
|
||||||
|
|
||||||
@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)
|
||||||
@@ -84,14 +119,45 @@ 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_path = Path(task.source)
|
||||||
|
source_root = source_path.parent if task.item_type is SyncItemType.FILE else source_path
|
||||||
target_root = Path(task.target)
|
target_root = Path(task.target)
|
||||||
|
selected_paths = [source_path.name] if task.item_type is SyncItemType.FILE else task.selected_paths
|
||||||
|
if cancel_check and cancel_check():
|
||||||
|
return SyncResult(paused=True)
|
||||||
|
if progress:
|
||||||
|
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)
|
source_files, source_dirs = self._inventory(
|
||||||
target_files, target_dirs = self._inventory(target_root, task.selected_paths)
|
source_root,
|
||||||
|
selected_paths,
|
||||||
|
progress,
|
||||||
|
"Quelle",
|
||||||
|
cancel_check=cancel_check,
|
||||||
|
ignored_paths=task.ignored_paths,
|
||||||
|
)
|
||||||
|
if cancel_check and cancel_check():
|
||||||
|
return SyncResult(paused=True)
|
||||||
|
target_files, target_dirs = self._inventory(
|
||||||
|
target_root,
|
||||||
|
selected_paths,
|
||||||
|
progress,
|
||||||
|
"Ziel",
|
||||||
|
cancel_check=cancel_check,
|
||||||
|
ignored_paths=task.ignored_paths,
|
||||||
|
)
|
||||||
|
if cancel_check and cancel_check():
|
||||||
|
return SyncResult(paused=True)
|
||||||
|
if progress:
|
||||||
|
progress(0, f"{task.name}: Änderungen werden verglichen …")
|
||||||
previous = self.state_store.get(task.id)
|
previous = self.state_store.get(task.id)
|
||||||
|
|
||||||
if task.direction is SyncDirection.ONE_WAY:
|
if task.direction is SyncDirection.ONE_WAY:
|
||||||
@@ -112,52 +178,141 @@ 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, 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, 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 PermissionError as exc:
|
|
||||||
processes = tuple(find_locking_processes(operation.target))
|
|
||||||
result.blocked.append(BlockedFile(operation.target, processes))
|
|
||||||
result.errors.append(f"Zugriff verweigert: {operation.target} ({exc})")
|
|
||||||
except (OSError, shutil.Error) as exc:
|
except (OSError, shutil.Error) as exc:
|
||||||
result.errors.append(f"{operation.target}: {exc}")
|
if isinstance(exc, FileNotFoundError) and operation.source and not operation.source.exists():
|
||||||
if progress:
|
result.skipped += 1
|
||||||
detail = f"{operation.kind}: {operation.relative}"
|
continue
|
||||||
|
failed_path = (
|
||||||
|
Path(exc.filename2 or exc.filename)
|
||||||
|
if isinstance(exc, OSError) and (exc.filename2 or exc.filename)
|
||||||
|
else operation.target
|
||||||
|
)
|
||||||
|
if operation.relative and operation.relative.strip():
|
||||||
|
result.failed_relatives.add(operation.relative)
|
||||||
|
blocked_path = _blocked_path(operation, exc)
|
||||||
|
if blocked_path is not None:
|
||||||
|
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))
|
||||||
|
elif isinstance(exc, PermissionError):
|
||||||
|
result.errors.append(f"Zugriff verweigert: {failed_path} ({exc})")
|
||||||
|
else:
|
||||||
|
result.errors.append(f"{failed_path}: {exc}")
|
||||||
|
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}"
|
||||||
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:
|
||||||
refreshed_source, _ = self._inventory(source_root, task.selected_paths)
|
refreshed_source, _ = self._inventory(source_root, selected_paths, ignored_paths=task.ignored_paths)
|
||||||
refreshed_target, _ = self._inventory(target_root, task.selected_paths)
|
refreshed_target, _ = self._inventory(target_root, 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
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_roots(source_root: Path, target_root: Path) -> None:
|
def _validate_roots(source_root: Path, target_root: Path) -> None:
|
||||||
if not source_root.is_dir():
|
try:
|
||||||
|
source_is_dir = source_root.is_dir()
|
||||||
|
except OSError as exc:
|
||||||
|
raise UnavailablePathError(_unavailable_path_message("Quellordner", source_root, exc)) from exc
|
||||||
|
if not source_is_dir:
|
||||||
raise UnavailablePathError(f"Quellordner nicht erreichbar: {source_root}")
|
raise UnavailablePathError(f"Quellordner nicht erreichbar: {source_root}")
|
||||||
if not target_root.exists():
|
|
||||||
|
try:
|
||||||
|
target_exists = target_root.exists()
|
||||||
|
except OSError as exc:
|
||||||
|
raise UnavailablePathError(_unavailable_path_message("Zielordner", target_root, exc)) from exc
|
||||||
|
if not target_exists:
|
||||||
try:
|
try:
|
||||||
target_root.mkdir(parents=True, exist_ok=True)
|
target_root.mkdir(parents=True, exist_ok=True)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise UnavailablePathError(f"Zielordner nicht erreichbar: {target_root}") from exc
|
raise UnavailablePathError(_unavailable_path_message("Zielordner", target_root, exc)) from exc
|
||||||
if not target_root.is_dir():
|
try:
|
||||||
|
target_is_dir = target_root.is_dir()
|
||||||
|
except OSError as exc:
|
||||||
|
raise UnavailablePathError(_unavailable_path_message("Zielordner", target_root, exc)) from exc
|
||||||
|
if not target_is_dir:
|
||||||
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
|
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _inventory(root: Path, selected_paths: list[str]) -> tuple[dict[str, FileRecord], set[str]]:
|
def _inventory(
|
||||||
|
root: Path,
|
||||||
|
selected_paths: list[str],
|
||||||
|
progress: ProgressCallback | None = None,
|
||||||
|
label: str = "Ordner",
|
||||||
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
ignored_paths: list[str] | set[str] | None = None,
|
||||||
|
) -> 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()
|
||||||
|
if progress:
|
||||||
|
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[:] = [name for name in dir_names if not (current_path / name).is_symlink()]
|
dir_names[:] = [
|
||||||
|
name
|
||||||
|
for name in dir_names
|
||||||
|
if _is_selected((relative_dir / name).parts, selections)
|
||||||
|
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:
|
||||||
|
progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}")
|
||||||
|
last_report = monotonic()
|
||||||
for directory_name in dir_names:
|
for directory_name in dir_names:
|
||||||
relative = relative_dir / directory_name
|
relative = relative_dir / directory_name
|
||||||
if _is_selected(relative.parts, selections):
|
if _is_selected(relative.parts, selections):
|
||||||
@@ -167,6 +322,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:
|
||||||
@@ -174,6 +331,9 @@ class SyncEngine:
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
files[str(relative)] = FileRecord(path, file_stat.st_size, file_stat.st_mtime_ns)
|
files[str(relative)] = FileRecord(path, file_stat.st_size, file_stat.st_mtime_ns)
|
||||||
|
if progress and monotonic() - last_report >= 0.25:
|
||||||
|
progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}")
|
||||||
|
last_report = monotonic()
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc
|
raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc
|
||||||
return files, directories
|
return files, directories
|
||||||
@@ -221,7 +381,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)
|
||||||
@@ -274,6 +440,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)
|
||||||
@@ -295,6 +464,31 @@ class SyncEngine:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_directory_link(path: Path) -> bool:
|
||||||
|
"""Erkennt auch Windows-Junctions unter Python 3.11 ohne Path.is_junction."""
|
||||||
|
metadata = path.lstat()
|
||||||
|
return stat.S_ISLNK(metadata.st_mode) or getattr(metadata, "st_reparse_tag", 0) == stat.IO_REPARSE_TAG_MOUNT_POINT
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_path(operation: _Operation, exc: OSError | shutil.Error) -> Path | None:
|
||||||
|
"""Ordnet auch Windows-Kopierfehler ohne Dateinamen der gesperrten Seite zu."""
|
||||||
|
winerror = getattr(exc, "winerror", None)
|
||||||
|
if winerror not in {5, 32, 33}:
|
||||||
|
return None
|
||||||
|
reported = (exc.filename2 or exc.filename) if isinstance(exc, OSError) else None
|
||||||
|
if reported and winerror in {32, 33}:
|
||||||
|
return Path(reported)
|
||||||
|
candidates = [Path(reported)] if reported else []
|
||||||
|
candidates.extend(path for path in (operation.source, operation.target) if path is not None)
|
||||||
|
for path in dict.fromkeys(candidates):
|
||||||
|
if is_file_locked(path):
|
||||||
|
return path
|
||||||
|
# Eine inzwischen freigegebene Datei beim nächsten Lauf erneut versuchen.
|
||||||
|
if winerror in {32, 33}:
|
||||||
|
return operation.source or operation.target
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _atomic_copy(source: Path, target: Path) -> None:
|
def _atomic_copy(source: Path, target: Path) -> None:
|
||||||
temporary = target.with_name(f".{target.name}.syncvito.tmp")
|
temporary = target.with_name(f".{target.name}.syncvito.tmp")
|
||||||
try:
|
try:
|
||||||
@@ -326,3 +520,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"
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Versionierter JSON-Import und -Export für Synchronisationsaufgaben."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from contextlib import suppress
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from syncvito.models import SyncTask
|
||||||
|
|
||||||
|
__all__ = ["TaskTransferError", "export_task", "export_tasks", "import_task", "import_tasks"]
|
||||||
|
|
||||||
|
_FORMAT_VERSION = 1
|
||||||
|
_SINGLE_TASK_FORMAT = "syncvito.task"
|
||||||
|
_TASKS_FORMAT = "syncvito.tasks"
|
||||||
|
_TASK_FIELDS = {
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"source",
|
||||||
|
"target",
|
||||||
|
"item_type",
|
||||||
|
"direction",
|
||||||
|
"trigger",
|
||||||
|
"delete_mode",
|
||||||
|
"conflict_mode",
|
||||||
|
"interval_minutes",
|
||||||
|
"selected_paths",
|
||||||
|
"ignored_paths",
|
||||||
|
"enabled",
|
||||||
|
"last_run",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TaskTransferError(ValueError):
|
||||||
|
"""Fehlerhafte oder nicht lesbare Aufgaben-Transferdatei."""
|
||||||
|
|
||||||
|
|
||||||
|
def export_task(path: Path, task: SyncTask) -> None:
|
||||||
|
"""Schreibt eine einzelne Aufgabe mit allen Einstellungen als JSON."""
|
||||||
|
_write_payload(
|
||||||
|
path,
|
||||||
|
{"format": _SINGLE_TASK_FORMAT, "format_version": _FORMAT_VERSION, "task": task.to_dict()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def export_tasks(path: Path, tasks: list[SyncTask]) -> None:
|
||||||
|
"""Schreibt alle Aufgaben mit ihren Einstellungen und Pfaden als JSON."""
|
||||||
|
_write_payload(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
"format": _TASKS_FORMAT,
|
||||||
|
"format_version": _FORMAT_VERSION,
|
||||||
|
"tasks": [task.to_dict() for task in tasks],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def import_task(path: Path) -> SyncTask:
|
||||||
|
"""Lädt eine einzelne Aufgabe und vergibt eine neue lokale Kennung."""
|
||||||
|
payload = _read_payload(path, _SINGLE_TASK_FORMAT)
|
||||||
|
raw_task = payload.get("task")
|
||||||
|
task = _parse_task(raw_task)
|
||||||
|
task.id = str(uuid4())
|
||||||
|
task.last_run = None
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def import_tasks(path: Path) -> list[SyncTask]:
|
||||||
|
"""Lädt und validiert vollständig einen Export der Aufgabenliste."""
|
||||||
|
payload = _read_payload(path, _TASKS_FORMAT)
|
||||||
|
raw_tasks = payload.get("tasks")
|
||||||
|
if not isinstance(raw_tasks, list):
|
||||||
|
raise TaskTransferError("Die Aufgabenliste in der Datei ist ungültig.")
|
||||||
|
|
||||||
|
tasks = [_parse_task(raw_task) for raw_task in raw_tasks]
|
||||||
|
task_ids = [task.id for task in tasks]
|
||||||
|
if len(task_ids) != len(set(task_ids)):
|
||||||
|
raise TaskTransferError("Die Datei enthält doppelte Aufgabenkennungen.")
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
|
def _read_payload(path: Path, expected_format: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
raw: Any = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise TaskTransferError(f"Die Datei konnte nicht gelesen werden: {exc}") from exc
|
||||||
|
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise TaskTransferError("Die Datei enthält kein gültiges SyncVito-JSON-Objekt.")
|
||||||
|
if raw.get("format") != expected_format:
|
||||||
|
raise TaskTransferError("Der Dateityp passt nicht zu dieser Importaktion.")
|
||||||
|
if type(raw.get("format_version")) is not int or raw["format_version"] != _FORMAT_VERSION:
|
||||||
|
raise TaskTransferError("Diese Version des Aufgabenformats wird nicht unterstützt.")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_task(raw_task: Any) -> SyncTask:
|
||||||
|
if not isinstance(raw_task, dict):
|
||||||
|
raise TaskTransferError("Eine Aufgabe in der Datei ist kein gültiges JSON-Objekt.")
|
||||||
|
missing_fields = (_TASK_FIELDS - {"item_type"}).difference(raw_task)
|
||||||
|
if missing_fields:
|
||||||
|
fields = ", ".join(sorted(missing_fields))
|
||||||
|
raise TaskTransferError(f"In einer Aufgabe fehlen erforderliche Daten: {fields}.")
|
||||||
|
|
||||||
|
for field in ("id", "name", "source", "target", "direction", "trigger", "delete_mode", "conflict_mode"):
|
||||||
|
if not isinstance(raw_task[field], str):
|
||||||
|
raise TaskTransferError(f"Das Feld „{field}“ einer Aufgabe muss Text enthalten.")
|
||||||
|
if "item_type" in raw_task and not isinstance(raw_task["item_type"], str):
|
||||||
|
raise TaskTransferError("Der Aufgabentyp ist ungültig.")
|
||||||
|
if not raw_task["id"].strip():
|
||||||
|
raise TaskTransferError("Eine Aufgabe hat keine gültige Kennung.")
|
||||||
|
if type(raw_task["interval_minutes"]) is not int:
|
||||||
|
raise TaskTransferError("Das Intervall einer Aufgabe muss eine ganze Zahl sein.")
|
||||||
|
if type(raw_task["enabled"]) is not bool:
|
||||||
|
raise TaskTransferError("Der Aktivierungszustand einer Aufgabe ist ungültig.")
|
||||||
|
for field in ("selected_paths", "ignored_paths"):
|
||||||
|
if not isinstance(raw_task[field], list) or any(not isinstance(item, str) for item in raw_task[field]):
|
||||||
|
raise TaskTransferError(f"Die Pfadliste „{field}“ einer Aufgabe ist ungültig.")
|
||||||
|
if raw_task["last_run"] is not None and not isinstance(raw_task["last_run"], str):
|
||||||
|
raise TaskTransferError("Der letzte Lauf einer Aufgabe ist ungültig.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = SyncTask.from_dict(raw_task)
|
||||||
|
task.validate()
|
||||||
|
except (TypeError, ValueError, OSError) as exc:
|
||||||
|
raise TaskTransferError(f"Eine Aufgabe enthält ungültige Einstellungen oder Pfade: {exc}") from exc
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def _write_payload(path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
target = Path(path)
|
||||||
|
temporary_path: Path | None = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
dir=target.parent,
|
||||||
|
prefix=f".{target.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as temporary_file:
|
||||||
|
temporary_path = Path(temporary_file.name)
|
||||||
|
json.dump(payload, temporary_file, ensure_ascii=False, indent=2)
|
||||||
|
temporary_file.write("\n")
|
||||||
|
os.replace(temporary_path, target)
|
||||||
|
except (OSError, TypeError, ValueError) as exc:
|
||||||
|
raise TaskTransferError(f"Die Exportdatei konnte nicht gespeichert werden: {exc}") from exc
|
||||||
|
finally:
|
||||||
|
if temporary_path is not None:
|
||||||
|
with suppress(OSError):
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
+59
-12
@@ -116,6 +116,7 @@ def _start_installer_after_exit(
|
|||||||
arguments: list[str],
|
arguments: list[str],
|
||||||
wait_pids: Iterable[int],
|
wait_pids: Iterable[int],
|
||||||
bootstrap_log_path: Path,
|
bootstrap_log_path: Path,
|
||||||
|
application_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Startet Inno Setup erst nach dem Ende aller PyInstaller-Prozesse."""
|
"""Startet Inno Setup erst nach dem Ende aller PyInstaller-Prozesse."""
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
@@ -129,43 +130,71 @@ def _start_installer_after_exit(
|
|||||||
|
|
||||||
normalized_pids = sorted({int(pid) for pid in wait_pids if int(pid) > 0})
|
normalized_pids = sorted({int(pid) for pid in wait_pids if int(pid) > 0})
|
||||||
pid_list = ", ".join(str(pid) for pid in normalized_pids)
|
pid_list = ", ".join(str(pid) for pid in normalized_pids)
|
||||||
setup_arguments = ", ".join(_powershell_literal(argument) for argument in arguments)
|
setup_arguments = _powershell_literal(subprocess.list2cmdline(arguments))
|
||||||
|
result_path = bootstrap_log_path.parent / "update-result.json"
|
||||||
script = f"""
|
script = f"""
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$bootstrapLog = {_powershell_literal(bootstrap_log_path)}
|
$bootstrapLog = {_powershell_literal(bootstrap_log_path)}
|
||||||
|
$applicationPath = {_powershell_literal(application_path)}
|
||||||
|
$resultPath = {_powershell_literal(result_path)}
|
||||||
|
$setupExitCode = $null
|
||||||
|
function Write-UpdateResult([bool]$success, [string]$message) {{
|
||||||
|
$result = @{{ success = $success; message = $message; exit_code = $setupExitCode }} | ConvertTo-Json
|
||||||
|
[System.IO.File]::WriteAllText($resultPath, $result, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
}}
|
||||||
try {{
|
try {{
|
||||||
[System.IO.File]::WriteAllText(
|
[System.IO.File]::WriteAllText(
|
||||||
$bootstrapLog,
|
$bootstrapLog,
|
||||||
((Get-Date).ToString('o') + ' Warte auf SyncVito-Prozesse: {pid_list}' + [Environment]::NewLine)
|
((Get-Date).ToString('o') + ' Warte auf SyncVito-Prozesse: {pid_list}' + [Environment]::NewLine)
|
||||||
)
|
)
|
||||||
foreach ($processId in @({pid_list})) {{
|
$deadline = [DateTime]::UtcNow.AddMinutes(2)
|
||||||
$deadline = [DateTime]::UtcNow.AddMinutes(2)
|
$lastWaiting = ''
|
||||||
while (Get-Process -Id $processId -ErrorAction SilentlyContinue) {{
|
while ($true) {{
|
||||||
if ([DateTime]::UtcNow -ge $deadline) {{
|
$waiting = @(
|
||||||
throw "SyncVito-Prozess $processId wurde nicht innerhalb von zwei Minuten beendet."
|
foreach ($processId in @({pid_list})) {{
|
||||||
|
Get-Process -Id $processId -ErrorAction SilentlyContinue
|
||||||
}}
|
}}
|
||||||
Start-Sleep -Milliseconds 200
|
Get-Process -Name ([System.IO.Path]::GetFileNameWithoutExtension($applicationPath)) -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object {{ -not $_.Path -or $_.Path -eq $applicationPath }}
|
||||||
|
)
|
||||||
|
if ($waiting.Count -eq 0) {{ break }}
|
||||||
|
$waitingIds = ($waiting.Id | Sort-Object -Unique) -join ', '
|
||||||
|
if ($waitingIds -ne $lastWaiting) {{
|
||||||
|
[System.IO.File]::AppendAllText($bootstrapLog, 'Noch aktiv: ' + $waitingIds + [Environment]::NewLine)
|
||||||
|
$lastWaiting = $waitingIds
|
||||||
}}
|
}}
|
||||||
|
if ([DateTime]::UtcNow -ge $deadline) {{
|
||||||
|
throw "SyncVito ist noch aktiv (Prozesse: $waitingIds). Bitte alle Instanzen im Infobereich ueber Beenden schliessen und das Update erneut starten."
|
||||||
|
}}
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
}}
|
}}
|
||||||
[System.IO.File]::AppendAllText(
|
[System.IO.File]::AppendAllText(
|
||||||
$bootstrapLog,
|
$bootstrapLog,
|
||||||
((Get-Date).ToString('o') + ' Starte verifizierten Installer.' + [Environment]::NewLine)
|
((Get-Date).ToString('o') + ' Starte verifizierten Installer.' + [Environment]::NewLine)
|
||||||
)
|
)
|
||||||
$env:PYINSTALLER_RESET_ENVIRONMENT = '1'
|
$env:PYINSTALLER_RESET_ENVIRONMENT = '1'
|
||||||
$setupArguments = @({setup_arguments})
|
$setupArguments = {setup_arguments}
|
||||||
& {_powershell_literal(installer_path)} @setupArguments
|
$setup = Start-Process -FilePath {_powershell_literal(installer_path)} -ArgumentList $setupArguments -PassThru -WindowStyle Hidden
|
||||||
$setupExitCode = $LASTEXITCODE
|
# Nur auf Setup warten: -Wait wuerde auch den neu gestarteten Client abwarten.
|
||||||
|
$setup.WaitForExit()
|
||||||
|
$setupExitCode = $setup.ExitCode
|
||||||
[System.IO.File]::AppendAllText(
|
[System.IO.File]::AppendAllText(
|
||||||
$bootstrapLog,
|
$bootstrapLog,
|
||||||
((Get-Date).ToString('o') + ' Installer-Exitcode: ' + $setupExitCode + [Environment]::NewLine)
|
((Get-Date).ToString('o') + ' Installer-Exitcode: ' + $setupExitCode + [Environment]::NewLine)
|
||||||
)
|
)
|
||||||
|
if ($null -eq $setupExitCode -or $setupExitCode -ne 0) {{
|
||||||
|
throw "Installation abgebrochen (Exitcode: $setupExitCode). Details stehen im Inno-Setup-Protokoll im Update-Verzeichnis."
|
||||||
|
}}
|
||||||
|
Write-UpdateResult $true ''
|
||||||
exit $setupExitCode
|
exit $setupExitCode
|
||||||
}}
|
}}
|
||||||
catch {{
|
catch {{
|
||||||
|
$failureMessage = $_.Exception.Message
|
||||||
[System.IO.File]::AppendAllText(
|
[System.IO.File]::AppendAllText(
|
||||||
$bootstrapLog,
|
$bootstrapLog,
|
||||||
((Get-Date).ToString('o') + ' Bootstrap-Fehler: ' + $_.Exception.Message + [Environment]::NewLine)
|
((Get-Date).ToString('o') + ' Bootstrap-Fehler: ' + $_.Exception.Message + [Environment]::NewLine)
|
||||||
)
|
)
|
||||||
|
Write-UpdateResult $false $failureMessage
|
||||||
exit 1
|
exit 1
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
@@ -297,7 +326,11 @@ class GiteaUpdateService:
|
|||||||
raise UpdateInstallError("Updates können nur aus der installierten Windows-Version gestartet werden.")
|
raise UpdateInstallError("Updates können nur aus der installierten Windows-Version gestartet werden.")
|
||||||
installer = installer_path.resolve()
|
installer = installer_path.resolve()
|
||||||
expected_name = f"SyncVitoSetup-{version}.exe"
|
expected_name = f"SyncVitoSetup-{version}.exe"
|
||||||
if installer.parent != self.update_directory.resolve() or installer.name != expected_name or not installer.is_file():
|
if (
|
||||||
|
installer.parent != self.update_directory.resolve()
|
||||||
|
or installer.name != expected_name
|
||||||
|
or not installer.is_file()
|
||||||
|
):
|
||||||
raise UpdateInstallError("Der geprüfte Installer wurde nicht gefunden.")
|
raise UpdateInstallError("Der geprüfte Installer wurde nicht gefunden.")
|
||||||
|
|
||||||
safe_version = re.sub(r"[^0-9A-Za-z._-]+", "_", version).strip("._") or "unknown"
|
safe_version = re.sub(r"[^0-9A-Za-z._-]+", "_", version).strip("._") or "unknown"
|
||||||
@@ -317,10 +350,24 @@ class GiteaUpdateService:
|
|||||||
f"/LOG={setup_log}",
|
f"/LOG={setup_log}",
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
_start_installer_after_exit(installer, arguments, wait_pids, bootstrap_log)
|
_start_installer_after_exit(installer, arguments, wait_pids, bootstrap_log, current_executable)
|
||||||
except (OSError, ValueError, UpdateError) as exc:
|
except (OSError, ValueError, UpdateError) as exc:
|
||||||
raise UpdateInstallError(f"Die Installation konnte nicht gestartet werden: {exc}") from exc
|
raise UpdateInstallError(f"Die Installation konnte nicht gestartet werden: {exc}") from exc
|
||||||
|
|
||||||
|
def take_install_error(self) -> str | None:
|
||||||
|
"""Liest das Ergebnis des externen Wächters einmalig; Protokolle bleiben erhalten."""
|
||||||
|
result_path = self.update_directory / "update-result.json"
|
||||||
|
try:
|
||||||
|
result = json.loads(result_path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(result, dict) or not isinstance(result.get("success"), bool):
|
||||||
|
return None
|
||||||
|
result_path.unlink()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if result["success"]:
|
||||||
|
return None
|
||||||
|
return str(result.get("message") or "Die Installation wurde nicht abgeschlossen.")
|
||||||
|
|
||||||
def cleanup_installers(self) -> tuple[int, int]:
|
def cleanup_installers(self) -> tuple[int, int]:
|
||||||
"""Entfernt ausschließlich von SyncVito verwaltete Setup-Dateien."""
|
"""Entfernt ausschließlich von SyncVito verwaltete Setup-Dateien."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from syncvito.config_store import ConfigStore
|
||||||
|
from syncvito.coordinator import SyncCoordinator
|
||||||
|
from syncvito.models import AppConfig, SyncTask, TaskRuntimeState
|
||||||
|
from syncvito.sync_engine import BlockedFile, SyncResult
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def coordinator(tmp_path: Path):
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
task = SyncTask(name="AppData", source=str(tmp_path / "source"), target=str(tmp_path / "target"))
|
||||||
|
coordinator = SyncCoordinator(AppConfig(tasks=[task]), ConfigStore(tmp_path / "config.json"))
|
||||||
|
yield coordinator
|
||||||
|
coordinator._schedule_timer.stop()
|
||||||
|
coordinator._retry_timer.stop()
|
||||||
|
coordinator.stop()
|
||||||
|
assert application is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_many_locks_have_compact_banner_and_full_paths_in_log(coordinator: SyncCoordinator) -> None:
|
||||||
|
banners = []
|
||||||
|
logs = []
|
||||||
|
coordinator.lock_changed.connect(banners.append)
|
||||||
|
coordinator.log_created.connect(lambda *entry: logs.append(entry))
|
||||||
|
blocked = [BlockedFile(Path(f"AppData/Programm{index}/Cookies")) for index in range(300)]
|
||||||
|
task = coordinator.config.tasks[0]
|
||||||
|
|
||||||
|
coordinator._on_finished(task.id, SyncResult(copied=123, blocked=blocked))
|
||||||
|
|
||||||
|
assert len(banners[-1]) < 400
|
||||||
|
assert "123 kopiert" in banners[-1]
|
||||||
|
assert "300 Dateien gesperrt" in banners[-1]
|
||||||
|
assert "unvollständig" in banners[-1]
|
||||||
|
assert len(logs) == 301
|
||||||
|
assert str(blocked[-1].path) in logs[-1][2]
|
||||||
|
assert coordinator.task_state(task.id) is TaskRuntimeState.WAITING_FOR_RELEASE
|
||||||
|
|
||||||
|
coordinator._on_finished(task.id, SyncResult(copied=300))
|
||||||
|
assert banners[-1] == ""
|
||||||
|
assert task.id not in coordinator._retry_tasks
|
||||||
|
assert coordinator.task_state(task.id) is TaskRuntimeState.ACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_errors_are_visible_even_when_other_files_are_locked(coordinator: SyncCoordinator) -> None:
|
||||||
|
logs = []
|
||||||
|
coordinator.log_created.connect(lambda *entry: logs.append(entry))
|
||||||
|
task = coordinator.config.tasks[0]
|
||||||
|
coordinator._on_finished(task.id, SyncResult(blocked=[BlockedFile(Path("Cookies"))], errors=["Zugriff verweigert"]))
|
||||||
|
assert coordinator.task_state(task.id) is TaskRuntimeState.ERROR
|
||||||
|
assert any(level == "Fehler" and detail == "Zugriff verweigert" for _, level, detail in logs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("all_locked", [False, True])
|
||||||
|
def test_retry_can_run_after_partial_release(
|
||||||
|
coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch, all_locked: bool
|
||||||
|
) -> None:
|
||||||
|
task = coordinator.config.tasks[0]
|
||||||
|
coordinator._retry_tasks[task.id] = [Path("dauerhaft"), Path("freigegeben")]
|
||||||
|
queued = []
|
||||||
|
monkeypatch.setattr(coordinator, "enqueue", queued.append)
|
||||||
|
monkeypatch.setattr("syncvito.coordinator.is_file_locked", lambda path: all_locked or path.name == "dauerhaft")
|
||||||
|
coordinator._check_retries()
|
||||||
|
assert queued == ([] if all_locked else [task.id])
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
coordinator._retry_tasks = {"first": [], "second": []}
|
||||||
|
queued = []
|
||||||
|
monkeypatch.setattr(coordinator, "enqueue", queued.append)
|
||||||
|
coordinator._check_retries()
|
||||||
|
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(
|
||||||
|
coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
workers = []
|
||||||
|
coordinator.progress_changed.connect(lambda *entry: messages.append(entry))
|
||||||
|
|
||||||
|
def start(worker) -> None:
|
||||||
|
assert messages and messages[-1][0] == 0
|
||||||
|
assert "AppData" in messages[-1][1]
|
||||||
|
workers.append(worker)
|
||||||
|
|
||||||
|
monkeypatch.setattr(coordinator, "pool", SimpleNamespace(start=start))
|
||||||
|
coordinator.enqueue(coordinator.config.tasks[0].id)
|
||||||
|
assert len(workers) == 1
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import ctypes
|
||||||
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from ctypes import wintypes
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from syncvito.file_locks import is_file_locked
|
||||||
|
from syncvito.models import SyncTask
|
||||||
|
from syncvito.state_store import StateStore
|
||||||
|
from syncvito.sync_engine import SyncEngine
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(os.name != "nt", reason="Windows-Dateisperren")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def exclusive_lock(path: Path):
|
||||||
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
kernel32.CreateFileW.argtypes = [
|
||||||
|
wintypes.LPCWSTR,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.DWORD,
|
||||||
|
ctypes.c_void_p,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.DWORD,
|
||||||
|
wintypes.HANDLE,
|
||||||
|
]
|
||||||
|
kernel32.CreateFileW.restype = wintypes.HANDLE
|
||||||
|
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||||
|
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||||
|
handle = kernel32.CreateFileW(str(path), 0x80000000, 0, None, 3, 0x80, None)
|
||||||
|
assert handle != ctypes.c_void_p(-1).value, ctypes.get_last_error()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
assert kernel32.CloseHandle(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def test_detects_real_lock_and_release(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "gesperrt.txt"
|
||||||
|
path.write_text("Inhalt")
|
||||||
|
assert not is_file_locked(path)
|
||||||
|
with exclusive_lock(path):
|
||||||
|
assert is_file_locked(path)
|
||||||
|
assert not is_file_locked(path)
|
||||||
|
assert not is_file_locked(tmp_path / "fehlt.txt")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("locked_side", ["source", "target"])
|
||||||
|
def test_sync_preserves_backup_and_continues_past_lock(tmp_path: Path, locked_side: str) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
target.mkdir()
|
||||||
|
(source / "a.txt").write_text("neue Version")
|
||||||
|
(target / "a.txt").write_text("alt")
|
||||||
|
(source / "b.txt").write_text("lesbar")
|
||||||
|
locked_path = tmp_path / locked_side / "a.txt"
|
||||||
|
task = SyncTask(name="AppData", source=str(source), target=str(target))
|
||||||
|
engine = SyncEngine(StateStore(tmp_path / "state.json"))
|
||||||
|
|
||||||
|
with exclusive_lock(locked_path):
|
||||||
|
result = engine.synchronize(task)
|
||||||
|
assert not result.successful
|
||||||
|
assert [item.path for item in result.blocked] == [locked_path]
|
||||||
|
assert not result.errors
|
||||||
|
assert result.copied == 1
|
||||||
|
assert (target / "b.txt").read_text() == "lesbar"
|
||||||
|
|
||||||
|
assert (target / "a.txt").read_text() == "alt"
|
||||||
|
assert not list(target.glob("*.tmp"))
|
||||||
|
assert engine.synchronize(task).successful
|
||||||
|
assert (target / "a.txt").read_text() == "neue Version"
|
||||||
@@ -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
|
||||||
@@ -14,7 +14,14 @@ def test_healthy_active_tasks_are_green() -> None:
|
|||||||
|
|
||||||
def test_waiting_lock_has_priority_over_active_task() -> None:
|
def test_waiting_lock_has_priority_over_active_task() -> None:
|
||||||
states = [TaskRuntimeState.ACTIVE, TaskRuntimeState.WAITING_FOR_RELEASE]
|
states = [TaskRuntimeState.ACTIVE, TaskRuntimeState.WAITING_FOR_RELEASE]
|
||||||
assert derive_overall_status(states, globally_paused=False) == ("error", "Wartet auf Dateifreigabe")
|
assert derive_overall_status(states, globally_paused=False) == ("error", "Unvollständig – Dateien gesperrt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_waiting_task_does_not_hide_running_sync() -> None:
|
||||||
|
assert derive_overall_status([TaskRuntimeState.WAITING_FOR_RELEASE], False, running=True) == (
|
||||||
|
"error",
|
||||||
|
"Synchronisierung läuft",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_error_has_highest_priority() -> None:
|
def test_error_has_highest_priority() -> None:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
||||||
from syncvito.state_store import StateStore
|
from syncvito.state_store import StateStore
|
||||||
from syncvito.sync_engine import SyncEngine
|
from syncvito.sync_engine import SyncEngine
|
||||||
@@ -10,6 +12,103 @@ def _engine(tmp_path: Path) -> SyncEngine:
|
|||||||
return SyncEngine(StateStore(tmp_path / "state.json"))
|
return SyncEngine(StateStore(tmp_path / "state.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_reports_scan_before_copying(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from itertools import count
|
||||||
|
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "datei.txt").write_text("Inhalt")
|
||||||
|
ticks = count()
|
||||||
|
monkeypatch.setattr("syncvito.sync_engine.monotonic", lambda: float(next(ticks)))
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
def progress(value: int, detail: str) -> None:
|
||||||
|
messages.append((value, detail))
|
||||||
|
if "eingelesen" in detail or "Dateien gefunden" in detail:
|
||||||
|
assert value == 0
|
||||||
|
assert not (target / "datei.txt").exists()
|
||||||
|
|
||||||
|
result = _engine(tmp_path).synchronize(SyncTask(name="Test", source=str(source), target=str(target)), progress)
|
||||||
|
|
||||||
|
assert result.successful
|
||||||
|
assert "Ordner werden geprüft" in messages[0][1]
|
||||||
|
assert any("Quelle wird eingelesen" in detail for _, detail in messages)
|
||||||
|
assert any("Ziel wird eingelesen" in detail for _, detail in messages)
|
||||||
|
assert any("1 Dateien gefunden" in detail for _, detail in messages)
|
||||||
|
assert messages[-1][0] == 100
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="Windows-Junctions")
|
||||||
|
def test_inventory_does_not_follow_windows_junctions(tmp_path: Path) -> None:
|
||||||
|
import _winapi
|
||||||
|
|
||||||
|
source = tmp_path / "source"
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
source.mkdir()
|
||||||
|
outside.mkdir()
|
||||||
|
(outside / "extern.txt").write_text("Nicht synchronisieren")
|
||||||
|
(source / "lokal.txt").write_text("Synchronisieren")
|
||||||
|
junction = source / "verknuepfung"
|
||||||
|
_winapi.CreateJunction(str(outside), str(junction))
|
||||||
|
try:
|
||||||
|
files, directories = SyncEngine._inventory(source, [])
|
||||||
|
assert set(files) == {"lokal.txt"}
|
||||||
|
assert "verknuepfung" not in directories
|
||||||
|
finally:
|
||||||
|
junction.rmdir()
|
||||||
|
|
||||||
|
|
||||||
|
def test_inventory_prunes_unselected_subtrees(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
(source / "auswahl").mkdir(parents=True)
|
||||||
|
(source / "ignorieren" / "unterordner").mkdir(parents=True)
|
||||||
|
(source / "auswahl" / "ja.txt").write_text("ja")
|
||||||
|
visited = []
|
||||||
|
original_walk = os.walk
|
||||||
|
|
||||||
|
def walk(*args, **kwargs):
|
||||||
|
for entry in original_walk(*args, **kwargs):
|
||||||
|
visited.append(Path(entry[0]))
|
||||||
|
yield entry
|
||||||
|
|
||||||
|
monkeypatch.setattr("syncvito.sync_engine.os.walk", walk)
|
||||||
|
files, _ = SyncEngine._inventory(source, ["auswahl"])
|
||||||
|
assert set(files) == {str(Path("auswahl/ja.txt"))}
|
||||||
|
assert visited == [source, source / "auswahl"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("winerror", [None, 5])
|
||||||
|
def test_access_denied_is_not_reported_as_file_lock(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, winerror: int | None
|
||||||
|
) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "geschuetzt.txt").write_text("geschützt")
|
||||||
|
(source / "lesbar.txt").write_text("lesbar")
|
||||||
|
from syncvito import sync_engine
|
||||||
|
|
||||||
|
copy = sync_engine._atomic_copy
|
||||||
|
|
||||||
|
def copy_with_denied_file(source_path: Path, target_path: Path) -> None:
|
||||||
|
if source_path.name == "geschuetzt.txt":
|
||||||
|
error = PermissionError(13, "Zugriff verweigert", str(source_path))
|
||||||
|
if winerror is not None:
|
||||||
|
error.winerror = winerror
|
||||||
|
raise error
|
||||||
|
copy(source_path, target_path)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sync_engine, "_atomic_copy", copy_with_denied_file)
|
||||||
|
result = _engine(tmp_path).synchronize(SyncTask(name="AppData", source=str(source), target=str(target)))
|
||||||
|
|
||||||
|
assert not result.successful
|
||||||
|
assert not result.blocked
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert str(source / "geschuetzt.txt") in result.errors[0]
|
||||||
|
assert (target / "lesbar.txt").read_text() == "lesbar"
|
||||||
|
|
||||||
|
|
||||||
def test_one_way_copy_and_mirror_delete(tmp_path: Path) -> None:
|
def test_one_way_copy_and_mirror_delete(tmp_path: Path) -> None:
|
||||||
source = tmp_path / "source"
|
source = tmp_path / "source"
|
||||||
target = tmp_path / "target"
|
target = tmp_path / "target"
|
||||||
@@ -124,3 +223,125 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Führt den echten PowerShell-Wächter mit harmlosen Testprogrammen aus."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from syncvito import updater
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(os.name != "nt", reason="Windows-Prozesslebenszyklus")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_file(path: Path, timeout: float = 15) -> None:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while not path.exists():
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
pytest.fail(f"Testprozess hat keine Datei geschrieben: {path}")
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def helper_exe(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||||
|
root = tmp_path_factory.mktemp("watcher-helper")
|
||||||
|
source = root / "helper.cs"
|
||||||
|
source.write_text(
|
||||||
|
"""
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
class Helper {
|
||||||
|
static int Main(string[] args) {
|
||||||
|
File.WriteAllLines(args[0], args);
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||||
|
while (!File.Exists(args[1])) {
|
||||||
|
if (DateTime.UtcNow > deadline) return 99;
|
||||||
|
Thread.Sleep(50);
|
||||||
|
}
|
||||||
|
return Int32.Parse(args[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
compiler = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "Microsoft.NET/Framework64/v4.0.30319/csc.exe"
|
||||||
|
if not compiler.exists():
|
||||||
|
pytest.skip("Windows-.NET-Compiler für Testprogramm nicht vorhanden")
|
||||||
|
executable = root / "SyncVitoWatcherTest.exe"
|
||||||
|
subprocess.run(
|
||||||
|
[str(compiler), "/nologo", "/target:winexe", f"/out:{executable}", str(source)],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return executable
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("exit_code", [0, 5])
|
||||||
|
def test_watcher_waits_for_all_instances_and_real_setup_exit(
|
||||||
|
helper_exe: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, exit_code: int
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "Ordner mit Leerzeichen und 'Apostroph'"
|
||||||
|
root.mkdir()
|
||||||
|
installer = root / "SetupTest.exe"
|
||||||
|
shutil.copy2(helper_exe, installer)
|
||||||
|
unrelated_dir = root / "anderer Installationspfad"
|
||||||
|
unrelated_dir.mkdir()
|
||||||
|
unrelated_exe = unrelated_dir / helper_exe.name
|
||||||
|
shutil.copy2(helper_exe, unrelated_exe)
|
||||||
|
children = []
|
||||||
|
releases = []
|
||||||
|
|
||||||
|
def start_client(exe: Path, name: str) -> subprocess.Popen:
|
||||||
|
ready = root / f"{name}.ready"
|
||||||
|
release = root / f"{name}.release"
|
||||||
|
releases.append(release)
|
||||||
|
process = subprocess.Popen([str(exe), str(ready), str(release), "0"])
|
||||||
|
children.append(process)
|
||||||
|
wait_for_file(ready)
|
||||||
|
return process
|
||||||
|
|
||||||
|
def launch_watcher(executable: str, arguments: list[str]) -> None:
|
||||||
|
children.append(subprocess.Popen([executable, *arguments], creationflags=subprocess.CREATE_NO_WINDOW))
|
||||||
|
|
||||||
|
try:
|
||||||
|
parent = start_client(helper_exe, "parent")
|
||||||
|
extra = start_client(helper_exe, "extra")
|
||||||
|
unrelated = start_client(unrelated_exe, "unrelated")
|
||||||
|
monkeypatch.setattr(updater, "_start_detached", launch_watcher)
|
||||||
|
output = root / "setup.arguments"
|
||||||
|
setup_release = root / "setup.release"
|
||||||
|
releases.append(setup_release)
|
||||||
|
log = root / "bootstrap.log"
|
||||||
|
arguments = [str(output), str(setup_release), str(exit_code), f"/DIR={root}", f"/LOG={root / 'setup.log'}"]
|
||||||
|
updater._start_installer_after_exit(installer, arguments, [parent.pid], log, helper_exe)
|
||||||
|
watcher = children[-1]
|
||||||
|
wait_for_file(log)
|
||||||
|
assert not output.exists()
|
||||||
|
releases[0].touch()
|
||||||
|
parent.wait(timeout=10)
|
||||||
|
time.sleep(0.5)
|
||||||
|
assert not output.exists(), "Weitere Instanz wurde nicht abgewartet"
|
||||||
|
releases[1].touch()
|
||||||
|
extra.wait(timeout=10)
|
||||||
|
wait_for_file(output)
|
||||||
|
assert unrelated.poll() is None, "Anderer Installationspfad darf weiterlaufen"
|
||||||
|
assert output.read_text(encoding="utf-8-sig").splitlines() == arguments
|
||||||
|
assert watcher.poll() is None, "Wächter muss auf Setup warten"
|
||||||
|
assert not (root / "update-result.json").exists()
|
||||||
|
setup_release.touch()
|
||||||
|
assert watcher.wait(timeout=15) == (0 if exit_code == 0 else 1)
|
||||||
|
receipt = json.loads((root / "update-result.json").read_text(encoding="utf-8"))
|
||||||
|
assert receipt["success"] is (exit_code == 0)
|
||||||
|
assert receipt["exit_code"] == exit_code
|
||||||
|
assert f"Installer-Exitcode: {exit_code}" in log.read_text(encoding="utf-8-sig")
|
||||||
|
finally:
|
||||||
|
for release in releases:
|
||||||
|
release.touch()
|
||||||
|
for child in children:
|
||||||
|
try:
|
||||||
|
child.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
child.kill()
|
||||||
|
child.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_watcher_reports_timeout_without_starting_installer(
|
||||||
|
helper_exe: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
ready = tmp_path / "client.ready"
|
||||||
|
release = tmp_path / "client.release"
|
||||||
|
log = tmp_path / "bootstrap.log"
|
||||||
|
client = subprocess.Popen([str(helper_exe), str(ready), str(release), "0"])
|
||||||
|
watchers = []
|
||||||
|
|
||||||
|
def launch_with_short_deadline(executable: str, arguments: list[str]) -> None:
|
||||||
|
script = base64.b64decode(arguments[-1]).decode("utf-16-le")
|
||||||
|
script = script.replace("AddMinutes(2)", "AddMilliseconds(400)")
|
||||||
|
arguments[-1] = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||||
|
watchers.append(subprocess.Popen([executable, *arguments], creationflags=subprocess.CREATE_NO_WINDOW))
|
||||||
|
|
||||||
|
try:
|
||||||
|
wait_for_file(ready)
|
||||||
|
monkeypatch.setattr(updater, "_start_detached", launch_with_short_deadline)
|
||||||
|
updater._start_installer_after_exit(tmp_path / "not-started.exe", ["/VERYSILENT"], [], log, helper_exe)
|
||||||
|
assert watchers[0].wait(timeout=15) == 1
|
||||||
|
receipt = json.loads((tmp_path / "update-result.json").read_text(encoding="utf-8"))
|
||||||
|
assert not receipt["success"]
|
||||||
|
assert str(client.pid) in receipt["message"]
|
||||||
|
assert "Beenden" in receipt["message"]
|
||||||
|
assert "Starte verifizierten Installer" not in log.read_text(encoding="utf-8-sig")
|
||||||
|
assert client.poll() is None
|
||||||
|
finally:
|
||||||
|
release.touch()
|
||||||
|
client.wait(timeout=10)
|
||||||
|
for watcher in watchers:
|
||||||
|
watcher.wait(timeout=10)
|
||||||
+26
-3
@@ -1,5 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -154,15 +155,37 @@ def test_update_watcher_waits_before_installer(monkeypatch: pytest.MonkeyPatch,
|
|||||||
powershell = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe"
|
powershell = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe"
|
||||||
monkeypatch.setattr(Path, "is_file", lambda self: self == powershell)
|
monkeypatch.setattr(Path, "is_file", lambda self: self == powershell)
|
||||||
|
|
||||||
updater._start_installer_after_exit(installer, ["/VERYSILENT"], [222, 111], bootstrap_log)
|
updater._start_installer_after_exit(
|
||||||
|
installer, ["/VERYSILENT"], [222, 111], bootstrap_log, tmp_path / "SyncVito.exe"
|
||||||
|
)
|
||||||
|
|
||||||
arguments = captured["arguments"]
|
arguments = captured["arguments"]
|
||||||
assert isinstance(arguments, list)
|
assert isinstance(arguments, list)
|
||||||
encoded = arguments[arguments.index("-EncodedCommand") + 1]
|
encoded = arguments[arguments.index("-EncodedCommand") + 1]
|
||||||
script = base64.b64decode(encoded).decode("utf-16-le")
|
script = base64.b64decode(encoded).decode("utf-16-le")
|
||||||
assert "foreach ($processId in @(111, 222))" in script
|
assert "foreach ($processId in @(111, 222))" in script
|
||||||
assert script.index("while (Get-Process") < script.index(str(installer))
|
assert script.index("while ($true)") < script.index(str(installer))
|
||||||
reset_environment = "$env:PYINSTALLER_RESET_ENVIRONMENT = '1'"
|
reset_environment = "$env:PYINSTALLER_RESET_ENVIRONMENT = '1'"
|
||||||
assert reset_environment in script
|
assert reset_environment in script
|
||||||
assert script.index(reset_environment) < script.index(f"& '{installer}'")
|
assert script.index(reset_environment) < script.index("$setup = Start-Process")
|
||||||
assert "'/VERYSILENT'" in script
|
assert "'/VERYSILENT'" in script
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("success", [False, True])
|
||||||
|
def test_installer_result_is_consumed_but_logs_are_kept(tmp_path: Path, success: bool) -> None:
|
||||||
|
service = GiteaUpdateService("1.0.7", update_directory=tmp_path)
|
||||||
|
receipt = tmp_path / "update-result.json"
|
||||||
|
receipt.write_text(json.dumps({"success": success, "message": "Installation abgebrochen"}), encoding="utf-8")
|
||||||
|
log = tmp_path / "SyncVitoSetup-1.0.8.log"
|
||||||
|
log.write_text("Diagnose")
|
||||||
|
assert service.take_install_error() == (None if success else "Installation abgebrochen")
|
||||||
|
assert service.take_install_error() is None
|
||||||
|
assert log.read_text() == "Diagnose"
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_installer_result_can_be_read_later(tmp_path: Path) -> None:
|
||||||
|
service = GiteaUpdateService("1.0.7", update_directory=tmp_path)
|
||||||
|
receipt = tmp_path / "update-result.json"
|
||||||
|
receipt.write_text('{"success":', encoding="utf-8")
|
||||||
|
assert service.take_install_error() is None
|
||||||
|
assert receipt.exists()
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.0.6
|
1.0.18
|
||||||
Reference in new issue
Block a user