Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4efc1efa18 | ||
|
|
bb5bfda4fe |
No files matched your search
@@ -1,5 +1,23 @@
|
|||||||
# Änderungsprotokoll
|
# Änderungsprotokoll
|
||||||
|
|
||||||
|
## 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
|
## 1.0.7 – 2026-09-24
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "syncvito"
|
name = "syncvito"
|
||||||
version = "1.0.7"
|
version = "1.0.9"
|
||||||
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"
|
||||||
|
|||||||
@@ -25,12 +25,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", "Synchronisiert" if running else "Unvollständig – Dateien gesperrt"
|
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):
|
||||||
@@ -186,6 +186,7 @@ class SyncCoordinator(QObject):
|
|||||||
self._start_next()
|
self._start_next()
|
||||||
return
|
return
|
||||||
self._running_task_id = task_id
|
self._running_task_id = task_id
|
||||||
|
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.")
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.update_retry_timer.setInterval(UPDATE_RETRY_INTERVAL_MS)
|
self.update_retry_timer.setInterval(UPDATE_RETRY_INTERVAL_MS)
|
||||||
self.update_retry_timer.timeout.connect(self._automatic_update_check)
|
self.update_retry_timer.timeout.connect(self._automatic_update_check)
|
||||||
QTimer.singleShot(3000, self._automatic_update_check)
|
QTimer.singleShot(3000, self._automatic_update_check)
|
||||||
|
QTimer.singleShot(1000, self._show_previous_update_error)
|
||||||
cleanup_attempts = 6 if "--updated" in sys.argv else 1
|
cleanup_attempts = 6 if "--updated" in sys.argv else 1
|
||||||
QTimer.singleShot(3000, lambda: self._cleanup_update_installers(cleanup_attempts))
|
QTimer.singleShot(3000, lambda: self._cleanup_update_installers(cleanup_attempts))
|
||||||
if "--updated" in sys.argv:
|
if "--updated" in sys.argv:
|
||||||
@@ -666,6 +667,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:
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ 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, is_file_locked
|
from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked
|
||||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
||||||
@@ -89,9 +91,13 @@ class SyncEngine:
|
|||||||
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 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(source_root, task.selected_paths, progress, "Quelle")
|
||||||
target_files, target_dirs = self._inventory(target_root, task.selected_paths)
|
target_files, target_dirs = self._inventory(target_root, task.selected_paths, progress, "Ziel")
|
||||||
|
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:
|
||||||
@@ -157,15 +163,31 @@ 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",
|
||||||
|
) -> 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):
|
||||||
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):
|
||||||
@@ -182,6 +204,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
|
||||||
@@ -303,6 +328,12 @@ 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:
|
def _blocked_path(operation: _Operation, exc: OSError | shutil.Error) -> Path | None:
|
||||||
"""Ordnet auch Windows-Kopierfehler ohne Dateinamen der gesperrten Seite zu."""
|
"""Ordnet auch Windows-Kopierfehler ohne Dateinamen der gesperrten Seite zu."""
|
||||||
winerror = getattr(exc, "winerror", None)
|
winerror = getattr(exc, "winerror", None)
|
||||||
|
|||||||
+59
-12
@@ -116,6 +116,7 @@ def _start_installer_after_exit(
|
|||||||
arguments: list[str],
|
arguments: list[str],
|
||||||
wait_pids: Iterable[int],
|
wait_pids: Iterable[int],
|
||||||
bootstrap_log_path: Path,
|
bootstrap_log_path: Path,
|
||||||
|
application_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Startet Inno Setup erst nach dem Ende aller PyInstaller-Prozesse."""
|
"""Startet Inno Setup erst nach dem Ende aller PyInstaller-Prozesse."""
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
@@ -129,43 +130,71 @@ def _start_installer_after_exit(
|
|||||||
|
|
||||||
normalized_pids = sorted({int(pid) for pid in wait_pids if int(pid) > 0})
|
normalized_pids = sorted({int(pid) for pid in wait_pids if int(pid) > 0})
|
||||||
pid_list = ", ".join(str(pid) for pid in normalized_pids)
|
pid_list = ", ".join(str(pid) for pid in normalized_pids)
|
||||||
setup_arguments = ", ".join(_powershell_literal(argument) for argument in arguments)
|
setup_arguments = _powershell_literal(subprocess.list2cmdline(arguments))
|
||||||
|
result_path = bootstrap_log_path.parent / "update-result.json"
|
||||||
script = f"""
|
script = f"""
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$bootstrapLog = {_powershell_literal(bootstrap_log_path)}
|
$bootstrapLog = {_powershell_literal(bootstrap_log_path)}
|
||||||
|
$applicationPath = {_powershell_literal(application_path)}
|
||||||
|
$resultPath = {_powershell_literal(result_path)}
|
||||||
|
$setupExitCode = $null
|
||||||
|
function Write-UpdateResult([bool]$success, [string]$message) {{
|
||||||
|
$result = @{{ success = $success; message = $message; exit_code = $setupExitCode }} | ConvertTo-Json
|
||||||
|
[System.IO.File]::WriteAllText($resultPath, $result, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
}}
|
||||||
try {{
|
try {{
|
||||||
[System.IO.File]::WriteAllText(
|
[System.IO.File]::WriteAllText(
|
||||||
$bootstrapLog,
|
$bootstrapLog,
|
||||||
((Get-Date).ToString('o') + ' Warte auf SyncVito-Prozesse: {pid_list}' + [Environment]::NewLine)
|
((Get-Date).ToString('o') + ' Warte auf SyncVito-Prozesse: {pid_list}' + [Environment]::NewLine)
|
||||||
)
|
)
|
||||||
foreach ($processId in @({pid_list})) {{
|
$deadline = [DateTime]::UtcNow.AddMinutes(2)
|
||||||
$deadline = [DateTime]::UtcNow.AddMinutes(2)
|
$lastWaiting = ''
|
||||||
while (Get-Process -Id $processId -ErrorAction SilentlyContinue) {{
|
while ($true) {{
|
||||||
if ([DateTime]::UtcNow -ge $deadline) {{
|
$waiting = @(
|
||||||
throw "SyncVito-Prozess $processId wurde nicht innerhalb von zwei Minuten beendet."
|
foreach ($processId in @({pid_list})) {{
|
||||||
|
Get-Process -Id $processId -ErrorAction SilentlyContinue
|
||||||
}}
|
}}
|
||||||
Start-Sleep -Milliseconds 200
|
Get-Process -Name ([System.IO.Path]::GetFileNameWithoutExtension($applicationPath)) -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object {{ -not $_.Path -or $_.Path -eq $applicationPath }}
|
||||||
|
)
|
||||||
|
if ($waiting.Count -eq 0) {{ break }}
|
||||||
|
$waitingIds = ($waiting.Id | Sort-Object -Unique) -join ', '
|
||||||
|
if ($waitingIds -ne $lastWaiting) {{
|
||||||
|
[System.IO.File]::AppendAllText($bootstrapLog, 'Noch aktiv: ' + $waitingIds + [Environment]::NewLine)
|
||||||
|
$lastWaiting = $waitingIds
|
||||||
}}
|
}}
|
||||||
|
if ([DateTime]::UtcNow -ge $deadline) {{
|
||||||
|
throw "SyncVito ist noch aktiv (Prozesse: $waitingIds). Bitte alle Instanzen im Infobereich ueber Beenden schliessen und das Update erneut starten."
|
||||||
|
}}
|
||||||
|
Start-Sleep -Milliseconds 200
|
||||||
}}
|
}}
|
||||||
[System.IO.File]::AppendAllText(
|
[System.IO.File]::AppendAllText(
|
||||||
$bootstrapLog,
|
$bootstrapLog,
|
||||||
((Get-Date).ToString('o') + ' Starte verifizierten Installer.' + [Environment]::NewLine)
|
((Get-Date).ToString('o') + ' Starte verifizierten Installer.' + [Environment]::NewLine)
|
||||||
)
|
)
|
||||||
$env:PYINSTALLER_RESET_ENVIRONMENT = '1'
|
$env:PYINSTALLER_RESET_ENVIRONMENT = '1'
|
||||||
$setupArguments = @({setup_arguments})
|
$setupArguments = {setup_arguments}
|
||||||
& {_powershell_literal(installer_path)} @setupArguments
|
$setup = Start-Process -FilePath {_powershell_literal(installer_path)} -ArgumentList $setupArguments -PassThru -WindowStyle Hidden
|
||||||
$setupExitCode = $LASTEXITCODE
|
# Nur auf Setup warten: -Wait wuerde auch den neu gestarteten Client abwarten.
|
||||||
|
$setup.WaitForExit()
|
||||||
|
$setupExitCode = $setup.ExitCode
|
||||||
[System.IO.File]::AppendAllText(
|
[System.IO.File]::AppendAllText(
|
||||||
$bootstrapLog,
|
$bootstrapLog,
|
||||||
((Get-Date).ToString('o') + ' Installer-Exitcode: ' + $setupExitCode + [Environment]::NewLine)
|
((Get-Date).ToString('o') + ' Installer-Exitcode: ' + $setupExitCode + [Environment]::NewLine)
|
||||||
)
|
)
|
||||||
|
if ($null -eq $setupExitCode -or $setupExitCode -ne 0) {{
|
||||||
|
throw "Installation abgebrochen (Exitcode: $setupExitCode). Details stehen im Inno-Setup-Protokoll im Update-Verzeichnis."
|
||||||
|
}}
|
||||||
|
Write-UpdateResult $true ''
|
||||||
exit $setupExitCode
|
exit $setupExitCode
|
||||||
}}
|
}}
|
||||||
catch {{
|
catch {{
|
||||||
|
$failureMessage = $_.Exception.Message
|
||||||
[System.IO.File]::AppendAllText(
|
[System.IO.File]::AppendAllText(
|
||||||
$bootstrapLog,
|
$bootstrapLog,
|
||||||
((Get-Date).ToString('o') + ' Bootstrap-Fehler: ' + $_.Exception.Message + [Environment]::NewLine)
|
((Get-Date).ToString('o') + ' Bootstrap-Fehler: ' + $_.Exception.Message + [Environment]::NewLine)
|
||||||
)
|
)
|
||||||
|
Write-UpdateResult $false $failureMessage
|
||||||
exit 1
|
exit 1
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
@@ -297,7 +326,11 @@ class GiteaUpdateService:
|
|||||||
raise UpdateInstallError("Updates können nur aus der installierten Windows-Version gestartet werden.")
|
raise UpdateInstallError("Updates können nur aus der installierten Windows-Version gestartet werden.")
|
||||||
installer = installer_path.resolve()
|
installer = installer_path.resolve()
|
||||||
expected_name = f"SyncVitoSetup-{version}.exe"
|
expected_name = f"SyncVitoSetup-{version}.exe"
|
||||||
if installer.parent != self.update_directory.resolve() or installer.name != expected_name or not installer.is_file():
|
if (
|
||||||
|
installer.parent != self.update_directory.resolve()
|
||||||
|
or installer.name != expected_name
|
||||||
|
or not installer.is_file()
|
||||||
|
):
|
||||||
raise UpdateInstallError("Der geprüfte Installer wurde nicht gefunden.")
|
raise UpdateInstallError("Der geprüfte Installer wurde nicht gefunden.")
|
||||||
|
|
||||||
safe_version = re.sub(r"[^0-9A-Za-z._-]+", "_", version).strip("._") or "unknown"
|
safe_version = re.sub(r"[^0-9A-Za-z._-]+", "_", version).strip("._") or "unknown"
|
||||||
@@ -317,10 +350,24 @@ class GiteaUpdateService:
|
|||||||
f"/LOG={setup_log}",
|
f"/LOG={setup_log}",
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
_start_installer_after_exit(installer, arguments, wait_pids, bootstrap_log)
|
_start_installer_after_exit(installer, arguments, wait_pids, bootstrap_log, current_executable)
|
||||||
except (OSError, ValueError, UpdateError) as exc:
|
except (OSError, ValueError, UpdateError) as exc:
|
||||||
raise UpdateInstallError(f"Die Installation konnte nicht gestartet werden: {exc}") from exc
|
raise UpdateInstallError(f"Die Installation konnte nicht gestartet werden: {exc}") from exc
|
||||||
|
|
||||||
|
def take_install_error(self) -> str | None:
|
||||||
|
"""Liest das Ergebnis des externen Wächters einmalig; Protokolle bleiben erhalten."""
|
||||||
|
result_path = self.update_directory / "update-result.json"
|
||||||
|
try:
|
||||||
|
result = json.loads(result_path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(result, dict) or not isinstance(result.get("success"), bool):
|
||||||
|
return None
|
||||||
|
result_path.unlink()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if result["success"]:
|
||||||
|
return None
|
||||||
|
return str(result.get("message") or "Die Installation wurde nicht abgeschlossen.")
|
||||||
|
|
||||||
def cleanup_installers(self) -> tuple[int, int]:
|
def cleanup_installers(self) -> tuple[int, int]:
|
||||||
"""Entfernt ausschließlich von SyncVito verwaltete Setup-Dateien."""
|
"""Entfernt ausschließlich von SyncVito verwaltete Setup-Dateien."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -73,3 +73,23 @@ def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch:
|
|||||||
monkeypatch.setattr(coordinator, "enqueue", queued.append)
|
monkeypatch.setattr(coordinator, "enqueue", queued.append)
|
||||||
coordinator._check_retries()
|
coordinator._check_retries()
|
||||||
assert queued == ["first", "second"]
|
assert queued == ["first", "second"]
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -20,7 +20,7 @@ def test_waiting_lock_has_priority_over_active_task() -> None:
|
|||||||
def test_waiting_task_does_not_hide_running_sync() -> None:
|
def test_waiting_task_does_not_hide_running_sync() -> None:
|
||||||
assert derive_overall_status([TaskRuntimeState.WAITING_FOR_RELEASE], False, running=True) == (
|
assert derive_overall_status([TaskRuntimeState.WAITING_FOR_RELEASE], False, running=True) == (
|
||||||
"error",
|
"error",
|
||||||
"Synchronisiert",
|
"Synchronisierung läuft",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,72 @@ 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])
|
@pytest.mark.parametrize("winerror", [None, 5])
|
||||||
def test_access_denied_is_not_reported_as_file_lock(
|
def test_access_denied_is_not_reported_as_file_lock(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, winerror: int | None
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, winerror: int | None
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Führt den echten PowerShell-Wächter mit harmlosen Testprogrammen aus."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from syncvito import updater
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(os.name != "nt", reason="Windows-Prozesslebenszyklus")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_file(path: Path, timeout: float = 15) -> None:
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while not path.exists():
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
pytest.fail(f"Testprozess hat keine Datei geschrieben: {path}")
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def helper_exe(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||||
|
root = tmp_path_factory.mktemp("watcher-helper")
|
||||||
|
source = root / "helper.cs"
|
||||||
|
source.write_text(
|
||||||
|
"""
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
class Helper {
|
||||||
|
static int Main(string[] args) {
|
||||||
|
File.WriteAllLines(args[0], args);
|
||||||
|
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||||
|
while (!File.Exists(args[1])) {
|
||||||
|
if (DateTime.UtcNow > deadline) return 99;
|
||||||
|
Thread.Sleep(50);
|
||||||
|
}
|
||||||
|
return Int32.Parse(args[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
compiler = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "Microsoft.NET/Framework64/v4.0.30319/csc.exe"
|
||||||
|
if not compiler.exists():
|
||||||
|
pytest.skip("Windows-.NET-Compiler für Testprogramm nicht vorhanden")
|
||||||
|
executable = root / "SyncVitoWatcherTest.exe"
|
||||||
|
subprocess.run(
|
||||||
|
[str(compiler), "/nologo", "/target:winexe", f"/out:{executable}", str(source)],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
return executable
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("exit_code", [0, 5])
|
||||||
|
def test_watcher_waits_for_all_instances_and_real_setup_exit(
|
||||||
|
helper_exe: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, exit_code: int
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "Ordner mit Leerzeichen und 'Apostroph'"
|
||||||
|
root.mkdir()
|
||||||
|
installer = root / "SetupTest.exe"
|
||||||
|
shutil.copy2(helper_exe, installer)
|
||||||
|
unrelated_dir = root / "anderer Installationspfad"
|
||||||
|
unrelated_dir.mkdir()
|
||||||
|
unrelated_exe = unrelated_dir / helper_exe.name
|
||||||
|
shutil.copy2(helper_exe, unrelated_exe)
|
||||||
|
children = []
|
||||||
|
releases = []
|
||||||
|
|
||||||
|
def start_client(exe: Path, name: str) -> subprocess.Popen:
|
||||||
|
ready = root / f"{name}.ready"
|
||||||
|
release = root / f"{name}.release"
|
||||||
|
releases.append(release)
|
||||||
|
process = subprocess.Popen([str(exe), str(ready), str(release), "0"])
|
||||||
|
children.append(process)
|
||||||
|
wait_for_file(ready)
|
||||||
|
return process
|
||||||
|
|
||||||
|
def launch_watcher(executable: str, arguments: list[str]) -> None:
|
||||||
|
children.append(subprocess.Popen([executable, *arguments], creationflags=subprocess.CREATE_NO_WINDOW))
|
||||||
|
|
||||||
|
try:
|
||||||
|
parent = start_client(helper_exe, "parent")
|
||||||
|
extra = start_client(helper_exe, "extra")
|
||||||
|
unrelated = start_client(unrelated_exe, "unrelated")
|
||||||
|
monkeypatch.setattr(updater, "_start_detached", launch_watcher)
|
||||||
|
output = root / "setup.arguments"
|
||||||
|
setup_release = root / "setup.release"
|
||||||
|
releases.append(setup_release)
|
||||||
|
log = root / "bootstrap.log"
|
||||||
|
arguments = [str(output), str(setup_release), str(exit_code), f"/DIR={root}", f"/LOG={root / 'setup.log'}"]
|
||||||
|
updater._start_installer_after_exit(installer, arguments, [parent.pid], log, helper_exe)
|
||||||
|
watcher = children[-1]
|
||||||
|
wait_for_file(log)
|
||||||
|
assert not output.exists()
|
||||||
|
releases[0].touch()
|
||||||
|
parent.wait(timeout=10)
|
||||||
|
time.sleep(0.5)
|
||||||
|
assert not output.exists(), "Weitere Instanz wurde nicht abgewartet"
|
||||||
|
releases[1].touch()
|
||||||
|
extra.wait(timeout=10)
|
||||||
|
wait_for_file(output)
|
||||||
|
assert unrelated.poll() is None, "Anderer Installationspfad darf weiterlaufen"
|
||||||
|
assert output.read_text(encoding="utf-8-sig").splitlines() == arguments
|
||||||
|
assert watcher.poll() is None, "Wächter muss auf Setup warten"
|
||||||
|
assert not (root / "update-result.json").exists()
|
||||||
|
setup_release.touch()
|
||||||
|
assert watcher.wait(timeout=15) == (0 if exit_code == 0 else 1)
|
||||||
|
receipt = json.loads((root / "update-result.json").read_text(encoding="utf-8"))
|
||||||
|
assert receipt["success"] is (exit_code == 0)
|
||||||
|
assert receipt["exit_code"] == exit_code
|
||||||
|
assert f"Installer-Exitcode: {exit_code}" in log.read_text(encoding="utf-8-sig")
|
||||||
|
finally:
|
||||||
|
for release in releases:
|
||||||
|
release.touch()
|
||||||
|
for child in children:
|
||||||
|
try:
|
||||||
|
child.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
child.kill()
|
||||||
|
child.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_watcher_reports_timeout_without_starting_installer(
|
||||||
|
helper_exe: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
ready = tmp_path / "client.ready"
|
||||||
|
release = tmp_path / "client.release"
|
||||||
|
log = tmp_path / "bootstrap.log"
|
||||||
|
client = subprocess.Popen([str(helper_exe), str(ready), str(release), "0"])
|
||||||
|
watchers = []
|
||||||
|
|
||||||
|
def launch_with_short_deadline(executable: str, arguments: list[str]) -> None:
|
||||||
|
script = base64.b64decode(arguments[-1]).decode("utf-16-le")
|
||||||
|
script = script.replace("AddMinutes(2)", "AddMilliseconds(400)")
|
||||||
|
arguments[-1] = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||||
|
watchers.append(subprocess.Popen([executable, *arguments], creationflags=subprocess.CREATE_NO_WINDOW))
|
||||||
|
|
||||||
|
try:
|
||||||
|
wait_for_file(ready)
|
||||||
|
monkeypatch.setattr(updater, "_start_detached", launch_with_short_deadline)
|
||||||
|
updater._start_installer_after_exit(tmp_path / "not-started.exe", ["/VERYSILENT"], [], log, helper_exe)
|
||||||
|
assert watchers[0].wait(timeout=15) == 1
|
||||||
|
receipt = json.loads((tmp_path / "update-result.json").read_text(encoding="utf-8"))
|
||||||
|
assert not receipt["success"]
|
||||||
|
assert str(client.pid) in receipt["message"]
|
||||||
|
assert "Beenden" in receipt["message"]
|
||||||
|
assert "Starte verifizierten Installer" not in log.read_text(encoding="utf-8-sig")
|
||||||
|
assert client.poll() is None
|
||||||
|
finally:
|
||||||
|
release.touch()
|
||||||
|
client.wait(timeout=10)
|
||||||
|
for watcher in watchers:
|
||||||
|
watcher.wait(timeout=10)
|
||||||
+26
-3
@@ -1,5 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -154,15 +155,37 @@ def test_update_watcher_waits_before_installer(monkeypatch: pytest.MonkeyPatch,
|
|||||||
powershell = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe"
|
powershell = Path(os.environ.get("SYSTEMROOT", r"C:\Windows")) / "System32/WindowsPowerShell/v1.0/powershell.exe"
|
||||||
monkeypatch.setattr(Path, "is_file", lambda self: self == powershell)
|
monkeypatch.setattr(Path, "is_file", lambda self: self == powershell)
|
||||||
|
|
||||||
updater._start_installer_after_exit(installer, ["/VERYSILENT"], [222, 111], bootstrap_log)
|
updater._start_installer_after_exit(
|
||||||
|
installer, ["/VERYSILENT"], [222, 111], bootstrap_log, tmp_path / "SyncVito.exe"
|
||||||
|
)
|
||||||
|
|
||||||
arguments = captured["arguments"]
|
arguments = captured["arguments"]
|
||||||
assert isinstance(arguments, list)
|
assert isinstance(arguments, list)
|
||||||
encoded = arguments[arguments.index("-EncodedCommand") + 1]
|
encoded = arguments[arguments.index("-EncodedCommand") + 1]
|
||||||
script = base64.b64decode(encoded).decode("utf-16-le")
|
script = base64.b64decode(encoded).decode("utf-16-le")
|
||||||
assert "foreach ($processId in @(111, 222))" in script
|
assert "foreach ($processId in @(111, 222))" in script
|
||||||
assert script.index("while (Get-Process") < script.index(str(installer))
|
assert script.index("while ($true)") < script.index(str(installer))
|
||||||
reset_environment = "$env:PYINSTALLER_RESET_ENVIRONMENT = '1'"
|
reset_environment = "$env:PYINSTALLER_RESET_ENVIRONMENT = '1'"
|
||||||
assert reset_environment in script
|
assert reset_environment in script
|
||||||
assert script.index(reset_environment) < script.index(f"& '{installer}'")
|
assert script.index(reset_environment) < script.index("$setup = Start-Process")
|
||||||
assert "'/VERYSILENT'" in script
|
assert "'/VERYSILENT'" in script
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("success", [False, True])
|
||||||
|
def test_installer_result_is_consumed_but_logs_are_kept(tmp_path: Path, success: bool) -> None:
|
||||||
|
service = GiteaUpdateService("1.0.7", update_directory=tmp_path)
|
||||||
|
receipt = tmp_path / "update-result.json"
|
||||||
|
receipt.write_text(json.dumps({"success": success, "message": "Installation abgebrochen"}), encoding="utf-8")
|
||||||
|
log = tmp_path / "SyncVitoSetup-1.0.8.log"
|
||||||
|
log.write_text("Diagnose")
|
||||||
|
assert service.take_install_error() == (None if success else "Installation abgebrochen")
|
||||||
|
assert service.take_install_error() is None
|
||||||
|
assert log.read_text() == "Diagnose"
|
||||||
|
|
||||||
|
|
||||||
|
def test_incomplete_installer_result_can_be_read_later(tmp_path: Path) -> None:
|
||||||
|
service = GiteaUpdateService("1.0.7", update_directory=tmp_path)
|
||||||
|
receipt = tmp_path / "update-result.json"
|
||||||
|
receipt.write_text('{"success":', encoding="utf-8")
|
||||||
|
assert service.take_install_error() is None
|
||||||
|
assert receipt.exists()
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.0.7
|
1.0.9
|
||||||
Reference in new issue
Block a user