From bb5bfda4fed6a31a7dcbdd530b0dbbc10ed4297c Mon Sep 17 00:00:00 2001 From: Davito <1+davito@noreply.localhost> Date: Thu, 24 Sep 2026 16:13:36 +0200 Subject: [PATCH] Release SyncVito 1.0.8 --- CHANGELOG.md | 9 ++ pyproject.toml | 2 +- src/syncvito/main_window.py | 11 +++ src/syncvito/updater.py | 71 ++++++++++++--- tests/test_update_watcher.py | 161 +++++++++++++++++++++++++++++++++++ tests/test_updater.py | 29 ++++++- version.txt | 2 +- 7 files changed, 268 insertions(+), 17 deletions(-) create mode 100644 tests/test_update_watcher.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a49f56..5f00706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Änderungsprotokoll +## 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 diff --git a/pyproject.toml b/pyproject.toml index ad4d34c..a985b0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "syncvito" -version = "1.0.7" +version = "1.0.8" description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation" readme = "README.md" requires-python = ">=3.11" diff --git a/src/syncvito/main_window.py b/src/syncvito/main_window.py index 01b292c..34aa1b2 100644 --- a/src/syncvito/main_window.py +++ b/src/syncvito/main_window.py @@ -114,6 +114,7 @@ class MainWindow(QMainWindow): 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(1000, self._show_previous_update_error) cleanup_attempts = 6 if "--updated" in sys.argv else 1 QTimer.singleShot(3000, lambda: self._cleanup_update_installers(cleanup_attempts)) if "--updated" in sys.argv: @@ -666,6 +667,16 @@ class MainWindow(QMainWindow): self._append_log("Update", "Erfolg", message) 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: _removed, pending = self.updater.service.cleanup_installers() if pending and attempts_remaining > 1: diff --git a/src/syncvito/updater.py b/src/syncvito/updater.py index 1f8bca1..31e5612 100644 --- a/src/syncvito/updater.py +++ b/src/syncvito/updater.py @@ -116,6 +116,7 @@ def _start_installer_after_exit( arguments: list[str], wait_pids: Iterable[int], bootstrap_log_path: Path, + application_path: Path, ) -> None: """Startet Inno Setup erst nach dem Ende aller PyInstaller-Prozesse.""" 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}) 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""" $ErrorActionPreference = 'Stop' $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 {{ [System.IO.File]::WriteAllText( $bootstrapLog, ((Get-Date).ToString('o') + ' Warte auf SyncVito-Prozesse: {pid_list}' + [Environment]::NewLine) ) - foreach ($processId in @({pid_list})) {{ - $deadline = [DateTime]::UtcNow.AddMinutes(2) - while (Get-Process -Id $processId -ErrorAction SilentlyContinue) {{ - if ([DateTime]::UtcNow -ge $deadline) {{ - throw "SyncVito-Prozess $processId wurde nicht innerhalb von zwei Minuten beendet." + $deadline = [DateTime]::UtcNow.AddMinutes(2) + $lastWaiting = '' + while ($true) {{ + $waiting = @( + 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( $bootstrapLog, ((Get-Date).ToString('o') + ' Starte verifizierten Installer.' + [Environment]::NewLine) ) $env:PYINSTALLER_RESET_ENVIRONMENT = '1' - $setupArguments = @({setup_arguments}) - & {_powershell_literal(installer_path)} @setupArguments - $setupExitCode = $LASTEXITCODE + $setupArguments = {setup_arguments} + $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( $bootstrapLog, ((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 }} catch {{ + $failureMessage = $_.Exception.Message [System.IO.File]::AppendAllText( $bootstrapLog, ((Get-Date).ToString('o') + ' Bootstrap-Fehler: ' + $_.Exception.Message + [Environment]::NewLine) ) + Write-UpdateResult $false $failureMessage exit 1 }} """ @@ -297,7 +326,11 @@ class GiteaUpdateService: raise UpdateInstallError("Updates können nur aus der installierten Windows-Version gestartet werden.") installer = installer_path.resolve() 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.") safe_version = re.sub(r"[^0-9A-Za-z._-]+", "_", version).strip("._") or "unknown" @@ -317,10 +350,24 @@ class GiteaUpdateService: f"/LOG={setup_log}", ] 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: 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]: """Entfernt ausschließlich von SyncVito verwaltete Setup-Dateien.""" try: diff --git a/tests/test_update_watcher.py b/tests/test_update_watcher.py new file mode 100644 index 0000000..b673646 --- /dev/null +++ b/tests/test_update_watcher.py @@ -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) diff --git a/tests/test_updater.py b/tests/test_updater.py index d96eb8d..f84d59c 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -1,5 +1,6 @@ import base64 import hashlib +import json import os 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" 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"] assert isinstance(arguments, list) encoded = arguments[arguments.index("-EncodedCommand") + 1] script = base64.b64decode(encoded).decode("utf-16-le") 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(f"& '{installer}'") + assert script.index(reset_environment) < script.index("$setup = Start-Process") 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() diff --git a/version.txt b/version.txt index 238d6e8..b0f3d96 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.7 +1.0.8