Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6e32a1b18 | ||
|
|
786e3ae9b3 | ||
|
|
ccb89191b5 | ||
|
|
7098fa1c0c | ||
|
|
4efc1efa18 | ||
|
|
bb5bfda4fe | ||
|
|
a04db4ee96 | ||
|
|
3d6ab87ce3 | ||
|
|
bb2a0db609 | ||
|
|
30982e8a4d | ||
|
|
8eb8cc1d0d |
No files matched your search
@@ -1,5 +1,92 @@
|
|||||||
# Änderungsprotokoll
|
# Änderungsprotokoll
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Dateianzeige und Fortschrittsleiste besitzen nun feste Breiten und verschieben sich beim Wechsel zwischen unterschiedlich langen Dateipfaden nicht mehr.
|
||||||
|
- Lange Dateipfade werden platzsparend gekürzt; der vollständige Text bleibt als Tooltip verfügbar.
|
||||||
|
|
||||||
|
## 1.0.5 – 2026-09-24
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- SyncVito prüft bei jedem Programmstart und während des Betriebs alle 30 Minuten automatisch auf Updates.
|
||||||
|
- Verfügbare Updates werden zusätzlich zum Hinweisfenster mit einer auffälligen „UPDATE“-Kennzeichnung am Eintrag „Einstellungen“ angezeigt.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Wird eine automatische Prüfung durch eine laufende Synchronisation oder Updateaktion verhindert, wird sie nach einer Minute erneut versucht.
|
||||||
|
|
||||||
|
## 1.0.3 – 2026-09-24
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Der automatische Neustart nach einem internen Update setzt nun die geerbte PyInstaller-Laufzeitumgebung zurück und verhindert dadurch den Sicherheitsfehler beim Start der aktualisierten Anwendung.
|
||||||
|
|
||||||
|
## 1.0.2 – 2026-09-24
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Die Versionsanzeige in der Seitenleiste verwendet nun immer die zentrale Anwendungsversion.
|
||||||
|
- Unter der Versionsnummer wird jetzt „by Davito“ statt „Lokale Konfiguration“ angezeigt.
|
||||||
|
|
||||||
## 1.0.1 – 2026-09-23
|
## 1.0.1 – 2026-09-23
|
||||||
|
|
||||||
### 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." }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "syncvito"
|
name = "syncvito"
|
||||||
version = "1.0.1"
|
version = "1.0.12"
|
||||||
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"
|
||||||
|
|||||||
+175
-28
@@ -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
|
||||||
|
|
||||||
@@ -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,59 @@ 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)
|
||||||
|
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)
|
"Andere Dateien wurden weiterverarbeitet. Automatischer Wiederholungsversuch nach Freigabe; "
|
||||||
descriptions.append(f"{blocked.path.name}: {names or 'sperrender Prozess nicht ermittelbar'}")
|
"ggf. betroffene Programme schließen. Details im Protokoll."
|
||||||
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 +344,9 @@ class SyncCoordinator(QObject):
|
|||||||
def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None:
|
def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None:
|
||||||
task = self._task(task_id)
|
task = self._task(task_id)
|
||||||
name = task.name if task else "Aufgabe"
|
name = task.name if task else "Aufgabe"
|
||||||
self._retry_tasks[task_id] = []
|
self._retry_tasks.pop(task_id, None)
|
||||||
|
self._retry_attempts.pop(task_id, None)
|
||||||
|
self._retry_cooldowns.pop(task_id, None)
|
||||||
self._lock_messages.pop(task_id, None)
|
self._lock_messages.pop(task_id, None)
|
||||||
self._emit_lock_summary()
|
self._emit_lock_summary()
|
||||||
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||||
@@ -244,6 +358,9 @@ class SyncCoordinator(QObject):
|
|||||||
self.progress_changed.emit(100, "Abgeschlossen")
|
self.progress_changed.emit(100, "Abgeschlossen")
|
||||||
if not self._queue:
|
if not self._queue:
|
||||||
self.idle_changed.emit(True)
|
self.idle_changed.emit(True)
|
||||||
|
self.config.settings.pending_queue = []
|
||||||
|
with suppress(Exception):
|
||||||
|
self.config_store.save(self.config)
|
||||||
self._emit_overall_status()
|
self._emit_overall_status()
|
||||||
QTimer.singleShot(200, self._start_next)
|
QTimer.singleShot(200, self._start_next)
|
||||||
|
|
||||||
@@ -272,14 +389,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 +439,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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+191
-22
@@ -3,11 +3,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt6.QtCore import QEvent, Qt, QTimer, pyqtSignal
|
from PyQt6.QtCore import QEvent, Qt, QTimer, pyqtSignal
|
||||||
from PyQt6.QtGui import QAction, QCloseEvent, QFontDatabase
|
from PyQt6.QtGui import QAction, QCloseEvent, QFontDatabase, QResizeEvent
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QApplication,
|
QApplication,
|
||||||
@@ -48,6 +48,38 @@ from syncvito.version import __version__
|
|||||||
|
|
||||||
__all__ = ["MainWindow"]
|
__all__ = ["MainWindow"]
|
||||||
|
|
||||||
|
UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1000
|
||||||
|
UPDATE_RETRY_INTERVAL_MS = 60 * 1000
|
||||||
|
PROGRESS_TEXT_WIDTH = 300
|
||||||
|
PROGRESS_BAR_WIDTH = 450
|
||||||
|
|
||||||
|
|
||||||
|
class _ElidedLabel(QLabel):
|
||||||
|
"""Hält dynamische Texte in einer festen Breite vollständig zugänglich."""
|
||||||
|
|
||||||
|
def __init__(self, text: str = "", parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._full_text = ""
|
||||||
|
self.setText(text)
|
||||||
|
|
||||||
|
def setText(self, text: str) -> None: # noqa: N802 - Qt API
|
||||||
|
self._full_text = text
|
||||||
|
self.setToolTip(text)
|
||||||
|
self.setAccessibleName(text)
|
||||||
|
self._refresh_elided_text()
|
||||||
|
|
||||||
|
def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - Qt API
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self._refresh_elided_text()
|
||||||
|
|
||||||
|
def _refresh_elided_text(self) -> None:
|
||||||
|
visible_text = self.fontMetrics().elidedText(
|
||||||
|
self._full_text,
|
||||||
|
Qt.TextElideMode.ElideMiddle,
|
||||||
|
max(0, self.contentsRect().width()),
|
||||||
|
)
|
||||||
|
super().setText(visible_text)
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
"""Zentrale SyncVito-Benutzeroberfläche."""
|
"""Zentrale SyncVito-Benutzeroberfläche."""
|
||||||
@@ -59,9 +91,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.config_store = config_store
|
self.config_store = config_store
|
||||||
self._allow_close = False
|
self._allow_close = False
|
||||||
|
self._notified_update_version: str | None = None
|
||||||
self.setWindowTitle("SyncVito")
|
self.setWindowTitle("SyncVito")
|
||||||
self.setWindowIcon(app_icon())
|
self.setWindowIcon(app_icon())
|
||||||
self.setMinimumSize(980, 680)
|
self.setMinimumSize(1040, 680)
|
||||||
self.resize(1220, 800)
|
self.resize(1220, 800)
|
||||||
|
|
||||||
self.coordinator = SyncCoordinator(config, config_store, self)
|
self.coordinator = SyncCoordinator(config, config_store, self)
|
||||||
@@ -71,7 +104,17 @@ class MainWindow(QMainWindow):
|
|||||||
self._connect_coordinator()
|
self._connect_coordinator()
|
||||||
self._refresh_tasks()
|
self._refresh_tasks()
|
||||||
self._set_global_status(*self.coordinator.current_overall_status())
|
self._set_global_status(*self.coordinator.current_overall_status())
|
||||||
|
|
||||||
|
self.update_check_timer = QTimer(self)
|
||||||
|
self.update_check_timer.setInterval(UPDATE_CHECK_INTERVAL_MS)
|
||||||
|
self.update_check_timer.timeout.connect(self._automatic_update_check)
|
||||||
|
self.update_check_timer.start()
|
||||||
|
self.update_retry_timer = QTimer(self)
|
||||||
|
self.update_retry_timer.setSingleShot(True)
|
||||||
|
self.update_retry_timer.setInterval(UPDATE_RETRY_INTERVAL_MS)
|
||||||
|
self.update_retry_timer.timeout.connect(self._automatic_update_check)
|
||||||
QTimer.singleShot(3000, self._automatic_update_check)
|
QTimer.singleShot(3000, self._automatic_update_check)
|
||||||
|
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:
|
||||||
@@ -97,8 +140,8 @@ class MainWindow(QMainWindow):
|
|||||||
event.ignore()
|
event.ignore()
|
||||||
self.hide()
|
self.hide()
|
||||||
self.tray.showMessage(
|
self.tray.showMessage(
|
||||||
"SyncVito läuft weiter",
|
"SyncVito minimiert",
|
||||||
"Das Fenster wurde in den Infobereich verschoben.",
|
"SyncVito bleibt im Infobereich aktiv.",
|
||||||
QSystemTrayIcon.MessageIcon.Information,
|
QSystemTrayIcon.MessageIcon.Information,
|
||||||
2500,
|
2500,
|
||||||
)
|
)
|
||||||
@@ -159,9 +202,10 @@ class MainWindow(QMainWindow):
|
|||||||
layout.addWidget(button)
|
layout.addWidget(button)
|
||||||
self.nav_buttons.append(button)
|
self.nav_buttons.append(button)
|
||||||
self.nav_buttons[0].setChecked(True)
|
self.nav_buttons[0].setChecked(True)
|
||||||
|
self.settings_nav_button = self.nav_buttons[2]
|
||||||
layout.addStretch(1)
|
layout.addStretch(1)
|
||||||
|
|
||||||
version = QLabel("Version 1.0.0\nLokale Konfiguration")
|
version = QLabel(f"Version {__version__}\nby Davito")
|
||||||
version.setProperty("muted", True)
|
version.setProperty("muted", True)
|
||||||
layout.addWidget(version)
|
layout.addWidget(version)
|
||||||
return sidebar
|
return sidebar
|
||||||
@@ -191,6 +235,9 @@ class MainWindow(QMainWindow):
|
|||||||
self.sync_all_button.setProperty("primary", True)
|
self.sync_all_button.setProperty("primary", True)
|
||||||
self.sync_all_button.clicked.connect(self.coordinator.enqueue_all)
|
self.sync_all_button.clicked.connect(self.coordinator.enqueue_all)
|
||||||
layout.addWidget(self.sync_all_button, 0, Qt.AlignmentFlag.AlignVCenter)
|
layout.addWidget(self.sync_all_button, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||||
|
self.header_pause_button = QPushButton("Pausieren")
|
||||||
|
self.header_pause_button.clicked.connect(self._toggle_pause)
|
||||||
|
layout.addWidget(self.header_pause_button, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||||
return header
|
return header
|
||||||
|
|
||||||
def _build_tasks_page(self) -> QWidget:
|
def _build_tasks_page(self) -> QWidget:
|
||||||
@@ -340,7 +387,7 @@ class MainWindow(QMainWindow):
|
|||||||
version_label = QLabel(f"Installierte Version: {__version__}")
|
version_label = QLabel(f"Installierte Version: {__version__}")
|
||||||
version_label.setProperty("muted", True)
|
version_label.setProperty("muted", True)
|
||||||
update_layout.addWidget(version_label)
|
update_layout.addWidget(version_label)
|
||||||
self.update_check_box = QCheckBox("Beim Start automatisch nach Updates suchen")
|
self.update_check_box = QCheckBox("Beim Start und danach alle 30 Minuten automatisch nach Updates suchen")
|
||||||
self.update_check_box.setChecked(self.config.settings.check_updates_on_start)
|
self.update_check_box.setChecked(self.config.settings.check_updates_on_start)
|
||||||
update_layout.addWidget(self.update_check_box)
|
update_layout.addWidget(self.update_check_box)
|
||||||
update_hint = QLabel(
|
update_hint = QLabel(
|
||||||
@@ -366,15 +413,23 @@ class MainWindow(QMainWindow):
|
|||||||
dock.setFixedHeight(66)
|
dock.setFixedHeight(66)
|
||||||
layout = QHBoxLayout(dock)
|
layout = QHBoxLayout(dock)
|
||||||
layout.setContentsMargins(28, 12, 28, 12)
|
layout.setContentsMargins(28, 12, 28, 12)
|
||||||
self.progress_text = QLabel("Bereit")
|
self.progress_text = _ElidedLabel("Bereit")
|
||||||
self.progress_text.setMinimumWidth(250)
|
self.progress_text.setFixedWidth(PROGRESS_TEXT_WIDTH)
|
||||||
self.progress_text.setProperty("muted", True)
|
self.progress_text.setProperty("muted", True)
|
||||||
self.progress_bar = QProgressBar()
|
self.progress_bar = QProgressBar()
|
||||||
|
self.progress_bar.setFixedWidth(PROGRESS_BAR_WIDTH)
|
||||||
self.progress_bar.setRange(0, 100)
|
self.progress_bar.setRange(0, 100)
|
||||||
self.progress_bar.setValue(0)
|
self.progress_bar.setValue(0)
|
||||||
self.progress_bar.setFormat("%p %")
|
self.progress_bar.setFormat("%p %")
|
||||||
|
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, 1)
|
layout.addWidget(self.progress_bar)
|
||||||
|
layout.addWidget(self.dock_pause_button)
|
||||||
|
layout.addStretch(1)
|
||||||
return dock
|
return dock
|
||||||
|
|
||||||
def _build_tray(self) -> None:
|
def _build_tray(self) -> None:
|
||||||
@@ -432,6 +487,11 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _refresh_tasks(self) -> None:
|
def _refresh_tasks(self) -> None:
|
||||||
self.task_table.setRowCount(len(self.config.tasks))
|
self.task_table.setRowCount(len(self.config.tasks))
|
||||||
|
running_id = self.coordinator.running_task_id
|
||||||
|
queued_ids = self.coordinator.queued_task_ids
|
||||||
|
paused_id = self.coordinator.paused_task_id
|
||||||
|
is_globally_paused = self.config.settings.paused
|
||||||
|
|
||||||
for row, task in enumerate(self.config.tasks):
|
for row, task in enumerate(self.config.tasks):
|
||||||
name = QTableWidgetItem(task.name)
|
name = QTableWidgetItem(task.name)
|
||||||
name.setData(Qt.ItemDataRole.UserRole, task.id)
|
name.setData(Qt.ItemDataRole.UserRole, task.id)
|
||||||
@@ -443,9 +503,26 @@ class MainWindow(QMainWindow):
|
|||||||
TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.",
|
TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.",
|
||||||
}[task.trigger]
|
}[task.trigger]
|
||||||
trigger = QTableWidgetItem(trigger_text)
|
trigger = QTableWidgetItem(trigger_text)
|
||||||
status = QTableWidgetItem(self._runtime_state_text(self.coordinator.task_state(task.id)))
|
|
||||||
|
base_state = self.coordinator.task_state(task.id)
|
||||||
|
if task.id == running_id:
|
||||||
|
val = self.progress_bar.value()
|
||||||
|
status_text = f"🔄 Läuft ({val}%)"
|
||||||
|
tip = f"Wird synchronisiert ({val}%)\n{self.progress_text.toolTip()}"
|
||||||
|
elif is_globally_paused and (task.id == paused_id or task.id in queued_ids):
|
||||||
|
status_text = "⏸ Pausiert"
|
||||||
|
tip = "Aufgabe pausiert – setzt beim Klick auf Fortsetzen fort."
|
||||||
|
elif task.id in queued_ids:
|
||||||
|
pos = queued_ids.index(task.id) + 1
|
||||||
|
status_text = f"⏳ In Warteschlange (#{pos})"
|
||||||
|
tip = f"Steht an Position {pos} der Warteschlange"
|
||||||
|
else:
|
||||||
|
status_text = self._runtime_state_text(base_state)
|
||||||
|
tip = status_text
|
||||||
|
|
||||||
|
status = QTableWidgetItem(status_text)
|
||||||
for column, item in enumerate((name, pair, direction, trigger, status)):
|
for column, item in enumerate((name, pair, direction, trigger, status)):
|
||||||
item.setToolTip(item.text())
|
item.setToolTip(tip if column == 4 else item.text())
|
||||||
self.task_table.setItem(row, column, item)
|
self.task_table.setItem(row, column, item)
|
||||||
|
|
||||||
def _selected_task(self) -> SyncTask | None:
|
def _selected_task(self) -> SyncTask | None:
|
||||||
@@ -495,6 +572,8 @@ class MainWindow(QMainWindow):
|
|||||||
if task is None:
|
if task is None:
|
||||||
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||||
return
|
return
|
||||||
|
if self.config.settings.paused:
|
||||||
|
self.coordinator.set_paused(False)
|
||||||
self.coordinator.enqueue(task.id)
|
self.coordinator.enqueue(task.id)
|
||||||
|
|
||||||
def _persist_tasks(self, message: str) -> None:
|
def _persist_tasks(self, message: str) -> None:
|
||||||
@@ -511,6 +590,7 @@ class MainWindow(QMainWindow):
|
|||||||
def _save_settings(self) -> None:
|
def _save_settings(self) -> None:
|
||||||
settings = self.config.settings
|
settings = self.config.settings
|
||||||
old_autostart = settings.autostart
|
old_autostart = settings.autostart
|
||||||
|
old_update_check = settings.check_updates_on_start
|
||||||
settings.autostart = self.autostart_check.isChecked()
|
settings.autostart = self.autostart_check.isChecked()
|
||||||
settings.font_family = self.font_combo.currentText()
|
settings.font_family = self.font_combo.currentText()
|
||||||
settings.font_size = self.font_size_spin.value()
|
settings.font_size = self.font_size_spin.value()
|
||||||
@@ -528,18 +608,22 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
apply_theme(QApplication.instance(), settings)
|
apply_theme(QApplication.instance(), settings)
|
||||||
self._append_log("Einstellungen", "Erfolg", "Darstellung und Systemstart wurden übernommen.")
|
self._append_log("Einstellungen", "Erfolg", "Darstellung und Systemstart wurden übernommen.")
|
||||||
|
if settings.check_updates_on_start and not old_update_check:
|
||||||
|
QTimer.singleShot(0, self._automatic_update_check)
|
||||||
|
elif not settings.check_updates_on_start:
|
||||||
|
self.update_retry_timer.stop()
|
||||||
|
|
||||||
def _automatic_update_check(self) -> None:
|
def _automatic_update_check(self) -> None:
|
||||||
settings = self.config.settings
|
settings = self.config.settings
|
||||||
if not settings.check_updates_on_start or self.coordinator.is_running:
|
if not settings.check_updates_on_start:
|
||||||
|
self.update_retry_timer.stop()
|
||||||
return
|
return
|
||||||
if settings.last_update_check:
|
if self.coordinator.is_running or self.updater.busy:
|
||||||
try:
|
if not self.update_retry_timer.isActive():
|
||||||
last_check = datetime.fromisoformat(settings.last_update_check)
|
self.update_retry_timer.start()
|
||||||
except ValueError:
|
return
|
||||||
last_check = datetime.min
|
self.update_retry_timer.stop()
|
||||||
if datetime.now() - last_check < timedelta(hours=24):
|
self.update_check_timer.start(UPDATE_CHECK_INTERVAL_MS)
|
||||||
return
|
|
||||||
self._check_updates(manual=False)
|
self._check_updates(manual=False)
|
||||||
|
|
||||||
def _check_updates(self, manual: bool) -> None:
|
def _check_updates(self, manual: bool) -> None:
|
||||||
@@ -558,16 +642,30 @@ class MainWindow(QMainWindow):
|
|||||||
self._mark_update_checked()
|
self._mark_update_checked()
|
||||||
self._update_progress(100, f"Version {info.version} ist verfügbar")
|
self._update_progress(100, f"Version {info.version} ist verfügbar")
|
||||||
self._append_log("Update", "Hinweis", f"Version {info.version} ist verfügbar.")
|
self._append_log("Update", "Hinweis", f"Version {info.version} ist verfügbar.")
|
||||||
|
self._set_update_indicator(info.version)
|
||||||
|
if not manual and self._notified_update_version == info.version:
|
||||||
|
return
|
||||||
|
self._notified_update_version = info.version
|
||||||
dialog = UpdateDialog(__version__, info, getattr(sys, "frozen", False), self)
|
dialog = UpdateDialog(__version__, info, getattr(sys, "frozen", False), self)
|
||||||
if dialog.exec() == UpdateDialog.DialogCode.Accepted:
|
if dialog.exec() == UpdateDialog.DialogCode.Accepted:
|
||||||
self.updater.download(info)
|
self.updater.download(info)
|
||||||
|
|
||||||
def _on_no_update(self, manual: bool) -> None:
|
def _on_no_update(self, manual: bool) -> None:
|
||||||
self._mark_update_checked()
|
self._mark_update_checked()
|
||||||
self._update_progress(100, "SyncVito ist aktuell")
|
self._set_update_indicator(None)
|
||||||
|
self._notified_update_version = None
|
||||||
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")
|
||||||
@@ -579,6 +677,18 @@ class MainWindow(QMainWindow):
|
|||||||
f"{message}\n\nBitte die Netzwerkverbindung prüfen und anschließend erneut versuchen.",
|
f"{message}\n\nBitte die Netzwerkverbindung prüfen und anschließend erneut versuchen.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _set_update_indicator(self, version: str | None) -> None:
|
||||||
|
available = bool(version)
|
||||||
|
self.settings_nav_button.setText("Einstellungen • UPDATE" if available else "Einstellungen")
|
||||||
|
self.settings_nav_button.setProperty("updateAvailable", available)
|
||||||
|
description = f"SyncVito {version} ist verfügbar" if version else ""
|
||||||
|
self.settings_nav_button.setToolTip(description)
|
||||||
|
self.settings_nav_button.setAccessibleDescription(description)
|
||||||
|
style = self.settings_nav_button.style()
|
||||||
|
style.unpolish(self.settings_nav_button)
|
||||||
|
style.polish(self.settings_nav_button)
|
||||||
|
self.settings_nav_button.update()
|
||||||
|
|
||||||
def _install_update(self, path: Path, info: UpdateInfo) -> None:
|
def _install_update(self, path: Path, info: UpdateInfo) -> None:
|
||||||
try:
|
try:
|
||||||
self.updater.service.launch_installer(path, info.version)
|
self.updater.service.launch_installer(path, info.version)
|
||||||
@@ -598,6 +708,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:
|
||||||
@@ -621,17 +741,57 @@ class MainWindow(QMainWindow):
|
|||||||
self.tray_sync_action.setEnabled(update_idle and sync_idle)
|
self.tray_sync_action.setEnabled(update_idle and sync_idle)
|
||||||
self.update_button.setEnabled(update_idle and sync_idle)
|
self.update_button.setEnabled(update_idle and sync_idle)
|
||||||
self.tray_update_action.setEnabled(update_idle and sync_idle)
|
self.tray_update_action.setEnabled(update_idle and sync_idle)
|
||||||
|
self._update_pause_action()
|
||||||
|
self._refresh_tasks()
|
||||||
|
|
||||||
def _toggle_pause(self) -> None:
|
def _toggle_pause(self) -> None:
|
||||||
self.coordinator.set_paused(not self.config.settings.paused)
|
self.coordinator.set_paused(not self.config.settings.paused)
|
||||||
self._update_pause_action()
|
self._update_pause_action()
|
||||||
|
|
||||||
def _update_pause_action(self) -> None:
|
def _update_pause_action(self) -> None:
|
||||||
self.pause_action.setText("Fortsetzen" if self.config.settings.paused else "Pausieren")
|
is_paused = self.config.settings.paused
|
||||||
|
is_running = self.coordinator.is_running
|
||||||
|
has_queue = bool(self.coordinator.queued_task_ids)
|
||||||
|
|
||||||
|
if is_paused:
|
||||||
|
btn_text = "▶ Fortsetzen"
|
||||||
|
tray_text = "Fortsetzen"
|
||||||
|
btn_enabled = True
|
||||||
|
primary = True
|
||||||
|
elif is_running or has_queue:
|
||||||
|
btn_text = "⏸ Pausieren"
|
||||||
|
tray_text = "Pausieren"
|
||||||
|
btn_enabled = True
|
||||||
|
primary = False
|
||||||
|
else:
|
||||||
|
btn_text = "Pausieren"
|
||||||
|
tray_text = "Pausieren"
|
||||||
|
btn_enabled = False
|
||||||
|
primary = False
|
||||||
|
|
||||||
|
self.pause_action.setText(tray_text)
|
||||||
|
self.pause_action.setEnabled(btn_enabled)
|
||||||
|
for btn in (getattr(self, "header_pause_button", None), getattr(self, "dock_pause_button", None)):
|
||||||
|
if btn is not None:
|
||||||
|
btn.setText(btn_text)
|
||||||
|
btn.setEnabled(btn_enabled)
|
||||||
|
btn.setProperty("primary", primary)
|
||||||
|
btn.style().unpolish(btn)
|
||||||
|
btn.style().polish(btn)
|
||||||
|
|
||||||
def _update_progress(self, value: int, detail: str) -> None:
|
def _update_progress(self, value: int, detail: str) -> None:
|
||||||
self.progress_bar.setValue(value)
|
self.progress_bar.setValue(value)
|
||||||
self.progress_text.setText(detail)
|
self.progress_text.setText(detail)
|
||||||
|
running_id = self.coordinator.running_task_id
|
||||||
|
if running_id:
|
||||||
|
for row in range(self.task_table.rowCount()):
|
||||||
|
item = self.task_table.item(row, 0)
|
||||||
|
if item and item.data(Qt.ItemDataRole.UserRole) == running_id:
|
||||||
|
status_item = self.task_table.item(row, 4)
|
||||||
|
if status_item:
|
||||||
|
status_item.setText(f"🔄 Läuft ({value}%)")
|
||||||
|
status_item.setToolTip(detail)
|
||||||
|
break
|
||||||
|
|
||||||
def _append_log(self, action: str, level: str, detail: str) -> None:
|
def _append_log(self, action: str, level: str, detail: str) -> None:
|
||||||
row = self.log_table.rowCount()
|
row = self.log_table.rowCount()
|
||||||
@@ -643,6 +803,15 @@ class MainWindow(QMainWindow):
|
|||||||
self.log_table.setItem(row, column, item)
|
self.log_table.setItem(row, column, item)
|
||||||
if self.log_table.rowCount() > 1000:
|
if self.log_table.rowCount() > 1000:
|
||||||
self.log_table.removeRow(0)
|
self.log_table.removeRow(0)
|
||||||
|
self._schedule_log_scroll()
|
||||||
|
|
||||||
|
def _schedule_log_scroll(self) -> None:
|
||||||
|
if not getattr(self, "_log_scroll_pending", False):
|
||||||
|
self._log_scroll_pending = True
|
||||||
|
QTimer.singleShot(50, self._do_scroll_log)
|
||||||
|
|
||||||
|
def _do_scroll_log(self) -> None:
|
||||||
|
self._log_scroll_pending = False
|
||||||
self.log_table.scrollToBottom()
|
self.log_table.scrollToBottom()
|
||||||
|
|
||||||
def _set_global_status(self, status: str, text: str) -> None:
|
def _set_global_status(self, status: str, text: str) -> None:
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ class AppSettings:
|
|||||||
font_weight: str = "regular"
|
font_weight: str = "regular"
|
||||||
ui_scale: int = 100
|
ui_scale: int = 100
|
||||||
paused: bool = False
|
paused: bool = False
|
||||||
|
pending_queue: list[str] = field(default_factory=list)
|
||||||
check_updates_on_start: bool = True
|
check_updates_on_start: bool = True
|
||||||
last_update_check: str | None = None
|
last_update_check: str | None = None
|
||||||
|
|
||||||
@@ -148,6 +149,7 @@ class AppSettings:
|
|||||||
self.font_weight = "regular"
|
self.font_weight = "regular"
|
||||||
if not self.font_family.strip():
|
if not self.font_family.strip():
|
||||||
self.font_family = "Segoe UI Variable"
|
self.font_family = "Segoe UI Variable"
|
||||||
|
self.pending_queue = [str(item) for item in self.pending_queue if str(item).strip()]
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""Serialisiert die Einstellungen."""
|
"""Serialisiert die Einstellungen."""
|
||||||
@@ -163,6 +165,7 @@ class AppSettings:
|
|||||||
font_weight=str(data.get("font_weight", "regular")),
|
font_weight=str(data.get("font_weight", "regular")),
|
||||||
ui_scale=int(data.get("ui_scale", 100)),
|
ui_scale=int(data.get("ui_scale", 100)),
|
||||||
paused=bool(data.get("paused", False)),
|
paused=bool(data.get("paused", False)),
|
||||||
|
pending_queue=[str(item) for item in data.get("pending_queue", []) if str(item).strip()],
|
||||||
check_updates_on_start=bool(data.get("check_updates_on_start", True)),
|
check_updates_on_start=bool(data.get("check_updates_on_start", True)),
|
||||||
last_update_check=data.get("last_update_check"),
|
last_update_check=data.get("last_update_check"),
|
||||||
)
|
)
|
||||||
|
|||||||
+150
-15
@@ -4,13 +4,15 @@ 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, SyncTask
|
||||||
from syncvito.state_store import StateStore
|
from syncvito.state_store import StateStore
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ class SyncResult:
|
|||||||
deleted: int = 0
|
deleted: int = 0
|
||||||
conflicts: int = 0
|
conflicts: int = 0
|
||||||
skipped: int = 0
|
skipped: int = 0
|
||||||
|
paused: bool = False
|
||||||
blocked: list[BlockedFile] = field(default_factory=list)
|
blocked: list[BlockedFile] = field(default_factory=list)
|
||||||
errors: list[str] = field(default_factory=list)
|
errors: list[str] = field(default_factory=list)
|
||||||
inaccessible: bool = False
|
inaccessible: bool = False
|
||||||
@@ -66,7 +69,7 @@ class SyncResult:
|
|||||||
@property
|
@property
|
||||||
def successful(self) -> bool:
|
def successful(self) -> bool:
|
||||||
"""Zeigt an, ob der Lauf ohne Fehler und Sperren abgeschlossen wurde."""
|
"""Zeigt an, ob der Lauf ohne Fehler und Sperren abgeschlossen wurde."""
|
||||||
return not self.errors and not self.blocked and not self.inaccessible
|
return not self.errors and not self.blocked and not self.inaccessible and not self.paused
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -84,14 +87,33 @@ class SyncEngine:
|
|||||||
def __init__(self, state_store: StateStore | None = None) -> None:
|
def __init__(self, state_store: StateStore | None = None) -> None:
|
||||||
self.state_store = state_store or StateStore()
|
self.state_store = state_store or StateStore()
|
||||||
|
|
||||||
def synchronize(self, task: SyncTask, progress: ProgressCallback | None = None) -> SyncResult:
|
def synchronize(
|
||||||
|
self,
|
||||||
|
task: SyncTask,
|
||||||
|
progress: ProgressCallback | None = None,
|
||||||
|
cancel_check: Callable[[], bool] | None = None,
|
||||||
|
) -> SyncResult:
|
||||||
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
|
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
|
||||||
task.validate()
|
task.validate()
|
||||||
source_root = Path(task.source)
|
source_root = Path(task.source)
|
||||||
target_root = Path(task.target)
|
target_root = Path(task.target)
|
||||||
|
if cancel_check and cancel_check():
|
||||||
|
return SyncResult(paused=True)
|
||||||
|
if progress:
|
||||||
|
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, task.selected_paths, progress, "Quelle", cancel_check=cancel_check
|
||||||
|
)
|
||||||
|
if cancel_check and cancel_check():
|
||||||
|
return SyncResult(paused=True)
|
||||||
|
target_files, target_dirs = self._inventory(
|
||||||
|
target_root, task.selected_paths, progress, "Ziel", cancel_check=cancel_check
|
||||||
|
)
|
||||||
|
if cancel_check and cancel_check():
|
||||||
|
return SyncResult(paused=True)
|
||||||
|
if progress:
|
||||||
|
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,22 +134,71 @@ class SyncEngine:
|
|||||||
|
|
||||||
result = SyncResult()
|
result = SyncResult()
|
||||||
total = max(1, len(operations))
|
total = max(1, len(operations))
|
||||||
|
stages: list[str] = []
|
||||||
|
for op in operations:
|
||||||
|
is_dir = op.kind in {"Ordner anlegen", "Ordner löschen"}
|
||||||
|
stage_name = _operation_stage(op.relative, task.selected_paths, is_dir=is_dir)
|
||||||
|
if stage_name not in stages:
|
||||||
|
stages.append(stage_name)
|
||||||
|
|
||||||
|
known_locking_processes: dict[Path, tuple[LockingProcess, ...]] = {}
|
||||||
|
rm_query_count = 0
|
||||||
|
last_progress_time = 0.0
|
||||||
|
current_stage = ""
|
||||||
|
|
||||||
if progress:
|
if progress:
|
||||||
progress(0, "Änderungen werden vorbereitet …")
|
progress(0, "Änderungen werden vorbereitet …")
|
||||||
for index, operation in enumerate(operations, start=1):
|
for index, operation in enumerate(operations, start=1):
|
||||||
|
is_dir = operation.kind in {"Ordner anlegen", "Ordner löschen"}
|
||||||
|
stage = _operation_stage(operation.relative, task.selected_paths, is_dir=is_dir)
|
||||||
|
if cancel_check and cancel_check():
|
||||||
|
result.paused = True
|
||||||
|
if progress:
|
||||||
|
progress(round((index - 1) / total * 100), f"Pausiert bei: {stage} – {operation.relative}")
|
||||||
|
break
|
||||||
try:
|
try:
|
||||||
self._execute(operation, result)
|
self._execute(operation, result)
|
||||||
except 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
|
||||||
|
)
|
||||||
|
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:
|
||||||
@@ -149,15 +220,34 @@ class SyncEngine:
|
|||||||
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,
|
||||||
|
) -> 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)
|
||||||
|
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)
|
||||||
|
]
|
||||||
|
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):
|
||||||
@@ -174,6 +264,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
|
||||||
@@ -274,6 +367,9 @@ class SyncEngine:
|
|||||||
return
|
return
|
||||||
if operation.source is None:
|
if operation.source is None:
|
||||||
return
|
return
|
||||||
|
if not operation.source.exists():
|
||||||
|
result.skipped += 1
|
||||||
|
return
|
||||||
operation.target.parent.mkdir(parents=True, exist_ok=True)
|
operation.target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if operation.conflict_copy and operation.target.exists():
|
if operation.conflict_copy and operation.target.exists():
|
||||||
conflict_path = _conflict_path(operation.target)
|
conflict_path = _conflict_path(operation.target)
|
||||||
@@ -295,6 +391,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 +447,17 @@ def _is_selected(parts: tuple[str, ...], selections: tuple[tuple[str, ...], ...]
|
|||||||
if not selections:
|
if not selections:
|
||||||
return True
|
return True
|
||||||
return any(parts[: len(selection)] == selection or selection[: len(parts)] == parts for selection in selections)
|
return any(parts[: len(selection)] == selection or selection[: len(parts)] == parts for selection in selections)
|
||||||
|
|
||||||
|
|
||||||
|
def _operation_stage(relative: str, selected_paths: list[str], is_dir: bool = False) -> str:
|
||||||
|
"""Ermittelt den relevanten Hauptordner oder die gewählte Unterauswahl."""
|
||||||
|
parts = Path(relative).parts
|
||||||
|
if not parts:
|
||||||
|
return "Hauptordner"
|
||||||
|
for selected in selected_paths:
|
||||||
|
selected_parts = Path(selected).parts
|
||||||
|
if parts[: len(selected_parts)] == selected_parts:
|
||||||
|
return "/".join(selected_parts)
|
||||||
|
if len(parts) > 1:
|
||||||
|
return parts[0]
|
||||||
|
return parts[0] if is_dir else "Hauptordner"
|
||||||
@@ -52,6 +52,12 @@ def _stylesheet(scale: float) -> str:
|
|||||||
padding: {round(10 * scale)}px {round(12 * scale)}px; color: #9CACBF; }}
|
padding: {round(10 * scale)}px {round(12 * scale)}px; color: #9CACBF; }}
|
||||||
QPushButton[nav="true"]:hover {{ background: #17212E; color: #E8EDF4; }}
|
QPushButton[nav="true"]:hover {{ background: #17212E; color: #E8EDF4; }}
|
||||||
QPushButton[nav="true"]:checked {{ background: #1B2A3D; color: #FFFFFF; border-color: #2A568B; }}
|
QPushButton[nav="true"]:checked {{ background: #1B2A3D; color: #FFFFFF; border-color: #2A568B; }}
|
||||||
|
QPushButton[nav="true"][updateAvailable="true"] {{ background: #3A2412; color: #FFF0DF;
|
||||||
|
border-color: #EA580C; }}
|
||||||
|
QPushButton[nav="true"][updateAvailable="true"]:hover {{ background: #4A2D14; color: #FFFFFF;
|
||||||
|
border-color: #FF7A2F; }}
|
||||||
|
QPushButton[nav="true"][updateAvailable="true"]:focus {{ border: 2px solid #FF9A5B;
|
||||||
|
padding: {round(9 * scale)}px {round(11 * scale)}px; }}
|
||||||
QLineEdit, QComboBox, QSpinBox, QListWidget, QTreeView, QTableWidget {{
|
QLineEdit, QComboBox, QSpinBox, QListWidget, QTreeView, QTableWidget {{
|
||||||
background: #0E141D; border: 1px solid #304052; border-radius: {radius}px;
|
background: #0E141D; border: 1px solid #304052; border-radius: {radius}px;
|
||||||
padding: {round(7 * scale)}px; color: #E8EDF4;
|
padding: {round(7 * scale)}px; color: #E8EDF4;
|
||||||
|
|||||||
+60
-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,42 +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)
|
||||||
)
|
)
|
||||||
$setupArguments = @({setup_arguments})
|
$env:PYINSTALLER_RESET_ENVIRONMENT = '1'
|
||||||
& {_powershell_literal(installer_path)} @setupArguments
|
$setupArguments = {setup_arguments}
|
||||||
$setupExitCode = $LASTEXITCODE
|
$setup = Start-Process -FilePath {_powershell_literal(installer_path)} -ArgumentList $setupArguments -PassThru -WindowStyle Hidden
|
||||||
|
# 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
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
@@ -296,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"
|
||||||
@@ -316,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,177 @@
|
|||||||
|
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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from syncvito.main_window import PROGRESS_BAR_WIDTH, PROGRESS_TEXT_WIDTH, MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_widgets_keep_fixed_widths_for_long_paths() -> None:
|
||||||
|
application = QApplication.instance() or QApplication([])
|
||||||
|
owner = SimpleNamespace()
|
||||||
|
dock = MainWindow._build_progress_dock(owner)
|
||||||
|
dock.resize(900, 66)
|
||||||
|
dock.show()
|
||||||
|
application.processEvents()
|
||||||
|
bar_x = owner.progress_bar.x()
|
||||||
|
long_path = "Kopieren: " + "sehr-langer-ordner\\" * 20 + "wichtige-datei.ini"
|
||||||
|
|
||||||
|
owner.progress_text.setText(long_path)
|
||||||
|
application.processEvents()
|
||||||
|
|
||||||
|
assert owner.progress_text.width() == PROGRESS_TEXT_WIDTH
|
||||||
|
assert owner.progress_bar.width() == PROGRESS_BAR_WIDTH
|
||||||
|
assert owner.progress_bar.x() == bar_x
|
||||||
|
assert owner.progress_text.text() != long_path
|
||||||
|
assert "…" in owner.progress_text.text()
|
||||||
|
assert owner.progress_text.toolTip() == long_path
|
||||||
|
assert owner.progress_text.accessibleName() == long_path
|
||||||
|
dock.close()
|
||||||
@@ -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,73 @@ def test_bidirectional_mirrors_known_deletion(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
assert result.successful
|
assert result.successful
|
||||||
assert not (source / "entfernen.txt").exists()
|
assert not (source / "entfernen.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_progress_reporting_for_multiple_folders(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
(source / "AppData").mkdir(parents=True)
|
||||||
|
(source / "Documents").mkdir(parents=True)
|
||||||
|
(source / "AppData" / "config.json").write_text("{}", encoding="utf-8")
|
||||||
|
(source / "Documents" / "doc.txt").write_text("text", encoding="utf-8")
|
||||||
|
task = SyncTask(name="Backup", source=str(source), target=str(target))
|
||||||
|
|
||||||
|
messages: list[tuple[int, str]] = []
|
||||||
|
result = _engine(tmp_path).synchronize(task, progress=lambda v, d: messages.append((v, d)))
|
||||||
|
|
||||||
|
assert result.successful
|
||||||
|
assert any("[1/2 AppData]" in detail for _, detail in messages)
|
||||||
|
assert any("[2/2 Documents]" in detail for _, detail in messages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_and_resume_continues_where_interrupted(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "a.txt").write_text("a", encoding="utf-8")
|
||||||
|
(source / "b.txt").write_text("b", encoding="utf-8")
|
||||||
|
(source / "c.txt").write_text("c", encoding="utf-8")
|
||||||
|
task = SyncTask(name="ResumeTest", source=str(source), target=str(target))
|
||||||
|
engine = _engine(tmp_path)
|
||||||
|
|
||||||
|
# 1. Unterbrechung nach der ersten Datei
|
||||||
|
def cancel_after_one() -> bool:
|
||||||
|
return (target / "a.txt").exists()
|
||||||
|
|
||||||
|
result_paused = engine.synchronize(task, cancel_check=cancel_after_one)
|
||||||
|
assert result_paused.paused
|
||||||
|
assert not result_paused.successful
|
||||||
|
assert (target / "a.txt").exists()
|
||||||
|
assert not (target / "c.txt").exists()
|
||||||
|
|
||||||
|
# 2. Fortsetzen: Bereits kopierte Datei a.txt wird übersprungen, b und c werden kopiert
|
||||||
|
result_resumed = engine.synchronize(task)
|
||||||
|
assert result_resumed.successful
|
||||||
|
assert result_resumed.copied == 2
|
||||||
|
assert (target / "a.txt").exists()
|
||||||
|
assert (target / "b.txt").exists()
|
||||||
|
assert (target / "c.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrently_deleted_source_file_is_skipped(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
target.mkdir()
|
||||||
|
(source / "ephemeral.txt").write_text("wird gelöscht")
|
||||||
|
task = SyncTask(name="Transient", source=str(source), target=str(target))
|
||||||
|
engine = _engine(tmp_path)
|
||||||
|
|
||||||
|
# Simuliere, dass die Datei kurz vor der Ausführung gelöscht wurde
|
||||||
|
def delete_before_execute(operation, result):
|
||||||
|
if operation.source and operation.source.name == "ephemeral.txt":
|
||||||
|
operation.source.unlink(missing_ok=True)
|
||||||
|
return SyncEngine._execute(operation, result)
|
||||||
|
|
||||||
|
monkeypatch.setattr(engine, "_execute", delete_before_execute)
|
||||||
|
result = engine.synchronize(task)
|
||||||
|
|
||||||
|
assert result.successful
|
||||||
|
assert result.skipped == 1
|
||||||
|
assert not result.errors
|
||||||
|
assert not (target / "ephemeral.txt").exists()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from syncvito.main_window import UPDATE_CHECK_INTERVAL_MS, UPDATE_RETRY_INTERVAL_MS, MainWindow
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTimer:
|
||||||
|
def __init__(self, *, active: bool = False) -> None:
|
||||||
|
self.active = active
|
||||||
|
self.start_calls: list[int | None] = []
|
||||||
|
self.stop_calls = 0
|
||||||
|
|
||||||
|
def isActive(self) -> bool: # noqa: N802 - Qt API
|
||||||
|
return self.active
|
||||||
|
|
||||||
|
def start(self, interval: int | None = None) -> None:
|
||||||
|
self.active = True
|
||||||
|
self.start_calls.append(interval)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.active = False
|
||||||
|
self.stop_calls += 1
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStyle:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.unpolished = False
|
||||||
|
self.polished = False
|
||||||
|
|
||||||
|
def unpolish(self, _button: object) -> None:
|
||||||
|
self.unpolished = True
|
||||||
|
|
||||||
|
def polish(self, _button: object) -> None:
|
||||||
|
self.polished = True
|
||||||
|
|
||||||
|
|
||||||
|
class FakeButton:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.text = ""
|
||||||
|
self.properties: dict[str, object] = {}
|
||||||
|
self.tooltip = ""
|
||||||
|
self.accessible_description = ""
|
||||||
|
self.button_style = FakeStyle()
|
||||||
|
self.updated = False
|
||||||
|
|
||||||
|
def setText(self, text: str) -> None: # noqa: N802 - Qt API
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
def setProperty(self, name: str, value: object) -> None: # noqa: N802 - Qt API
|
||||||
|
self.properties[name] = value
|
||||||
|
|
||||||
|
def setToolTip(self, text: str) -> None: # noqa: N802 - Qt API
|
||||||
|
self.tooltip = text
|
||||||
|
|
||||||
|
def setAccessibleDescription(self, text: str) -> None: # noqa: N802 - Qt API
|
||||||
|
self.accessible_description = text
|
||||||
|
|
||||||
|
def style(self) -> FakeStyle:
|
||||||
|
return self.button_style
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
self.updated = True
|
||||||
|
|
||||||
|
|
||||||
|
def _window(*, sync_running: bool = False, updater_busy: bool = False) -> SimpleNamespace:
|
||||||
|
checks: list[bool] = []
|
||||||
|
return SimpleNamespace(
|
||||||
|
config=SimpleNamespace(settings=SimpleNamespace(check_updates_on_start=True)),
|
||||||
|
coordinator=SimpleNamespace(is_running=sync_running),
|
||||||
|
updater=SimpleNamespace(busy=updater_busy),
|
||||||
|
update_check_timer=FakeTimer(active=True),
|
||||||
|
update_retry_timer=FakeTimer(),
|
||||||
|
_check_updates=lambda manual: checks.append(manual),
|
||||||
|
checks=checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_automatic_update_check_runs_without_daily_throttle() -> None:
|
||||||
|
window = _window()
|
||||||
|
|
||||||
|
MainWindow._automatic_update_check(window)
|
||||||
|
|
||||||
|
assert window.checks == [False]
|
||||||
|
assert window.update_check_timer.start_calls == [UPDATE_CHECK_INTERVAL_MS]
|
||||||
|
assert window.update_retry_timer.stop_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_automatic_update_check_retries_while_sync_is_running() -> None:
|
||||||
|
window = _window(sync_running=True)
|
||||||
|
|
||||||
|
MainWindow._automatic_update_check(window)
|
||||||
|
|
||||||
|
assert window.checks == []
|
||||||
|
assert window.update_retry_timer.start_calls == [None]
|
||||||
|
assert UPDATE_RETRY_INTERVAL_MS == 60_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_periodic_update_interval_is_thirty_minutes() -> None:
|
||||||
|
assert UPDATE_CHECK_INTERVAL_MS == 30 * 60 * 1000
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_indicator_uses_text_and_accessible_description() -> None:
|
||||||
|
button = FakeButton()
|
||||||
|
window = SimpleNamespace(settings_nav_button=button)
|
||||||
|
|
||||||
|
MainWindow._set_update_indicator(window, "1.0.5")
|
||||||
|
|
||||||
|
assert button.text == "Einstellungen • UPDATE"
|
||||||
|
assert button.properties["updateAvailable"] is True
|
||||||
|
assert button.tooltip == "SyncVito 1.0.5 ist verfügbar"
|
||||||
|
assert button.accessible_description == button.tooltip
|
||||||
|
assert button.button_style.unpolished and button.button_style.polished
|
||||||
|
assert button.updated
|
||||||
@@ -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)
|
||||||
+28
-2
@@ -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,12 +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'"
|
||||||
|
assert reset_environment in script
|
||||||
|
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.1
|
1.0.12
|
||||||
Reference in new issue
Block a user