Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d0b1b2d38 | ||
|
|
cdcd03d7b0 |
No files matched your search
@@ -1,5 +1,22 @@
|
||||
# Änderungsprotokoll
|
||||
|
||||
## 1.0.14 – 2026-09-29
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Netzwerk- und Boot-Resilienz bei der Konfigurationsprüfung:** Beim Windows-Start wird die Aufgabenvalidierung von Quell- und Zielordnern nicht mehr durch noch nicht initialisierte Netzwerkverbindungen oder vorübergehend nicht erreichbare UNC-Pfade (`\\Server\Freigabe`) unterbrochen. Fehler bei der Pfadauflösung werden sicher abgefangen.
|
||||
- **Schutz vor fälschlichem Konfigurations-Reset:** Kurzzeitige Windows-Dateisperren (z. B. durch Hintergrund- oder Suchdienste beim Systemstart) führen nicht mehr zum irrtümlichen Umbenennen der Konfiguration in eine defekte Sicherung. Lesezugriffe auf die Konfigurationsdatei werden bei Sperren automatisch mehrfach wiederholt. Eine Sicherung als `config.defekt` erfolgt ausschließlich bei tatsächlichen Syntaxfehlern in der JSON-Datei.
|
||||
- **Fehlertolerantes Laden von Aufgaben:** Sollte eine einzelne gespeicherte Aufgabe ungültige Pfade oder Parameter enthalten, bleibt die Gesamtheit aller anderen Aufgaben unberührt. Die betroffene Aufgabe wird sicherheitsrelevanterweise deaktiviert in der Liste geladen, statt den Start zu blockieren oder andere Aufgaben zu verwerfen.
|
||||
|
||||
## 1.0.13 – 2026-09-28
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Zuverlässiger Windows-Autostart via Aufgabenplanung:** Da SyncVito mit Administratorrechten (`requireAdministrator`) läuft, blockierte der Windows Explorer beim Systemstart den Aufruf aus dem Registry-`Run`-Schlüssel. Der Autostart wird nun über eine geplante Windows-Aufgabe (`RunLevel=HighestAvailable`) bei Benutzeranmeldung ausgeführt, sodass SyncVito nach dem Hochfahren zuverlässig und ohne UAC-Dialog im Infobereich startet.
|
||||
- **Warteschleife für den Windows-Infobereich:** Falls der Windows Explorer beim Booten den Benachrichtigungsbereich noch nicht initialisiert hat, wartet SyncVito bis zu 15 Sekunden auf den Tray, statt sich sofort mit einem Fehler zu beenden.
|
||||
- **Automatische Autostart-Migration:** Bestehende Konfigurationen mit aktiviertem Autostart werden beim Programmstart automatisch auf die geplante Aufgabe umgestellt; alte Einträge im Run-Schlüssel werden bereinigt.
|
||||
- **Saubere Deinstallation:** Bei einer Deinstallation über Inno Setup wird die registrierte Windows-Aufgabe für den Autostart automatisch entfernt.
|
||||
|
||||
## 1.0.12 – 2026-09-27
|
||||
|
||||
### Behoben
|
||||
|
||||
@@ -55,3 +55,6 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{#MyAppName} starten"; Flags: nowait postinstall skipifsilent runascurrentuser
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--updated"; Flags: nowait skipifnotsilent runascurrentuser
|
||||
|
||||
[UninstallRun]
|
||||
Filename: "schtasks.exe"; Parameters: "/delete /tn ""{#MyAppName}"" /f"; Flags: runhidden
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "syncvito"
|
||||
version = "1.0.12"
|
||||
version = "1.0.14"
|
||||
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+20
-2
@@ -2,12 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QLockFile, QStandardPaths
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
|
||||
|
||||
from syncvito.autostart import set_autostart
|
||||
from syncvito.config_store import ConfigError, ConfigStore
|
||||
from syncvito.icons import app_icon
|
||||
from syncvito.main_window import MainWindow
|
||||
@@ -18,6 +21,19 @@ from syncvito.version import __version__
|
||||
__all__ = ["main"]
|
||||
|
||||
|
||||
def _wait_for_system_tray(application: QApplication, timeout_seconds: float = 15.0) -> bool:
|
||||
"""Wartet beim Windows-Start auf die Verfügbarkeit des Infobereichs."""
|
||||
if QSystemTrayIcon.isSystemTrayAvailable():
|
||||
return True
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
application.processEvents()
|
||||
time.sleep(0.25)
|
||||
if QSystemTrayIcon.isSystemTrayAvailable():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Startet SyncVito als einzelne Windows-Instanz."""
|
||||
if "--version" in sys.argv:
|
||||
@@ -33,11 +49,11 @@ def main() -> int:
|
||||
|
||||
lock_path = Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.TempLocation)) / "syncvito.lock"
|
||||
lock = QLockFile(str(lock_path))
|
||||
lock.setStaleLockTime(0)
|
||||
lock.setStaleLockTime(30000)
|
||||
if not lock.tryLock(100):
|
||||
QMessageBox.information(None, "SyncVito läuft bereits", "SyncVito ist bereits im Infobereich aktiv.")
|
||||
return 0
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
if not _wait_for_system_tray(application):
|
||||
QMessageBox.critical(None, "Infobereich nicht verfügbar", "SyncVito benötigt den Windows-Infobereich.")
|
||||
return 1
|
||||
|
||||
@@ -47,6 +63,8 @@ def main() -> int:
|
||||
except ConfigError as exc:
|
||||
QMessageBox.warning(None, "Konfiguration zurückgesetzt", str(exc))
|
||||
config = AppConfig()
|
||||
if os.name == "nt" and config.settings.autostart:
|
||||
set_autostart(True)
|
||||
apply_theme(application, config.settings)
|
||||
window = MainWindow(config, store)
|
||||
if "--minimized" not in sys.argv:
|
||||
|
||||
+140
-15
@@ -3,29 +3,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
__all__ = ["set_autostart"]
|
||||
__all__ = ["RUN_KEY_PATH", "TASK_NAME", "generate_task_xml", "set_autostart"]
|
||||
|
||||
TASK_NAME = "SyncVito"
|
||||
RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
|
||||
|
||||
def set_autostart(enabled: bool) -> None:
|
||||
"""Setzt oder entfernt SyncVito unter HKCU\\...\\Run."""
|
||||
def generate_task_xml(command_exe: Path, arguments: str = "--minimized") -> str:
|
||||
"""Erzeugt die XML-Definition für die Windows-Aufgabenplanung."""
|
||||
escaped_exe = xml_escape(str(command_exe.resolve()))
|
||||
escaped_args = xml_escape(arguments)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-16"?>\n'
|
||||
'<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">\n'
|
||||
" <RegistrationInfo>\n"
|
||||
" <Description>SyncVito Windows Autostart</Description>\n"
|
||||
" </RegistrationInfo>\n"
|
||||
" <Triggers>\n"
|
||||
" <LogonTrigger>\n"
|
||||
" <Enabled>true</Enabled>\n"
|
||||
" <Delay>PT5S</Delay>\n"
|
||||
" </LogonTrigger>\n"
|
||||
" </Triggers>\n"
|
||||
" <Principals>\n"
|
||||
' <Principal id="Author">\n'
|
||||
" <LogonType>InteractiveToken</LogonType>\n"
|
||||
" <RunLevel>HighestAvailable</RunLevel>\n"
|
||||
" </Principal>\n"
|
||||
" </Principals>\n"
|
||||
" <Settings>\n"
|
||||
" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n"
|
||||
" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n"
|
||||
" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n"
|
||||
" <AllowHardTerminate>true</AllowHardTerminate>\n"
|
||||
" <StartWhenAvailable>true</StartWhenAvailable>\n"
|
||||
" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n"
|
||||
" <IdleSettings>\n"
|
||||
" <StopOnIdleEnd>false</StopOnIdleEnd>\n"
|
||||
" <RestartOnIdle>false</RestartOnIdle>\n"
|
||||
" </IdleSettings>\n"
|
||||
" <AllowStartOnDemand>true</AllowStartOnDemand>\n"
|
||||
" <Enabled>true</Enabled>\n"
|
||||
" <Hidden>false</Hidden>\n"
|
||||
" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n"
|
||||
" <WakeToRun>false</WakeToRun>\n"
|
||||
" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n"
|
||||
" <Priority>7</Priority>\n"
|
||||
" </Settings>\n"
|
||||
' <Actions Context="Author">\n'
|
||||
" <Exec>\n"
|
||||
f" <Command>{escaped_exe}</Command>\n"
|
||||
f" <Arguments>{escaped_args}</Arguments>\n"
|
||||
" </Exec>\n"
|
||||
" </Actions>\n"
|
||||
"</Task>"
|
||||
)
|
||||
|
||||
|
||||
def _run_schtasks(args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
|
||||
return subprocess.run(
|
||||
["schtasks", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
creationflags=flags,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _delete_run_key_entry() -> None:
|
||||
if os.name != "nt":
|
||||
return
|
||||
import winreg
|
||||
|
||||
key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as key:
|
||||
if enabled:
|
||||
executable = Path(sys.executable)
|
||||
if getattr(sys, "frozen", False):
|
||||
command = f'"{executable}" --minimized'
|
||||
else:
|
||||
entry = Path(__file__).resolve().parents[2] / "main.py"
|
||||
command = f'"{executable}" "{entry}" --minimized'
|
||||
winreg.SetValueEx(key, "SyncVito", 0, winreg.REG_SZ, command)
|
||||
with (
|
||||
suppress(FileNotFoundError, OSError),
|
||||
winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key,
|
||||
):
|
||||
winreg.DeleteValue(key, TASK_NAME)
|
||||
|
||||
|
||||
def _set_run_key_entry(command: str) -> None:
|
||||
if os.name != "nt":
|
||||
return
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
|
||||
winreg.SetValueEx(key, TASK_NAME, 0, winreg.REG_SZ, command)
|
||||
|
||||
|
||||
def _register_scheduled_task(command_exe: Path, arguments: str) -> bool:
|
||||
xml_content = generate_task_xml(command_exe, arguments)
|
||||
fd, tmp_file = tempfile.mkstemp(suffix=".xml")
|
||||
tmp_path = Path(tmp_file)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-16") as f:
|
||||
f.write(xml_content)
|
||||
result = _run_schtasks(["/create", "/tn", TASK_NAME, "/xml", str(tmp_path), "/f"])
|
||||
return result.returncode == 0
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
with suppress(OSError):
|
||||
tmp_path.unlink()
|
||||
|
||||
|
||||
def _delete_scheduled_task() -> bool:
|
||||
result = _run_schtasks(["/delete", "/tn", TASK_NAME, "/f"])
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def set_autostart(enabled: bool) -> bool:
|
||||
"""Aktiviert oder entfernt SyncVito im Windows-Autostart.
|
||||
|
||||
Da SyncVito Administratorrechte (requireAdministrator) benötigt,
|
||||
ignoriert der Windows-Explorer Einträge unter HKCU\\...\\Run beim Systemstart
|
||||
vollständig, um unerwünschte UAC-Abfragen beim Logon zu verhindern.
|
||||
Für erhöhte Anwendungen muss daher ein Scheduled Task in der Windows-
|
||||
Aufgabenplanung mit 'RunLevel=HighestAvailable' verwendet werden.
|
||||
"""
|
||||
if os.name != "nt":
|
||||
return False
|
||||
|
||||
if enabled:
|
||||
if getattr(sys, "frozen", False):
|
||||
command_exe = Path(sys.executable)
|
||||
arguments = "--minimized"
|
||||
reg_command = f'"{command_exe}" --minimized'
|
||||
else:
|
||||
with suppress(FileNotFoundError):
|
||||
winreg.DeleteValue(key, "SyncVito")
|
||||
command_exe = Path(sys.executable)
|
||||
entry = Path(__file__).resolve().parents[2] / "main.py"
|
||||
arguments = f'"{entry}" --minimized'
|
||||
reg_command = f'"{command_exe}" "{entry}" --minimized'
|
||||
|
||||
if _register_scheduled_task(command_exe, arguments):
|
||||
_delete_run_key_entry()
|
||||
return True
|
||||
|
||||
# Fallback auf Run-Schlüssel (z. B. nicht-erhöhte Entwicklungsumgebung)
|
||||
_set_run_key_entry(reg_command)
|
||||
return False
|
||||
else:
|
||||
_delete_scheduled_task()
|
||||
_delete_run_key_entry()
|
||||
return True
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -33,19 +35,47 @@ class ConfigStore:
|
||||
"""Lädt die Konfiguration; beschädigte Dateien werden gesichert."""
|
||||
if not self.path.exists():
|
||||
return AppConfig()
|
||||
|
||||
content: str | None = None
|
||||
last_io_exc: OSError | None = None
|
||||
for _ in range(5):
|
||||
try:
|
||||
content = self.path.read_text(encoding="utf-8")
|
||||
break
|
||||
except OSError as exc:
|
||||
last_io_exc = exc
|
||||
time.sleep(0.1)
|
||||
|
||||
if content is None:
|
||||
raise ConfigError(
|
||||
f"Konfigurationsdatei '{self.path.name}' konnte nicht gelesen werden: {last_io_exc}"
|
||||
) from last_io_exc
|
||||
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
raw = json.loads(content)
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("Das JSON-Wurzelelement ist kein Objekt.")
|
||||
return AppConfig.from_dict(raw)
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
recovery_path = self.path.with_name(f"config.defekt-{timestamp}.json")
|
||||
try:
|
||||
self.path.replace(recovery_path)
|
||||
except OSError:
|
||||
recovery_path = self.path
|
||||
raise ConfigError(f"Die Konfiguration war nicht lesbar. Sicherung: {recovery_path}") from exc
|
||||
raise ConfigError(
|
||||
f"Die Konfiguration war syntaktisch beschädigt ({exc}). Sicherung: {recovery_path}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
return AppConfig.from_dict(raw)
|
||||
except Exception as exc:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = self.path.with_name(f"config.bak-{timestamp}.json")
|
||||
with suppress(OSError):
|
||||
shutil.copy2(self.path, backup_path)
|
||||
raise ConfigError(
|
||||
f"Die Konfigurationsdaten konnten nicht verarbeitet werden ({exc}). Sicherungskopie: {backup_path}"
|
||||
) from exc
|
||||
|
||||
def save(self, config: AppConfig) -> None:
|
||||
"""Speichert die Konfiguration atomar und UTF-8-kodiert."""
|
||||
|
||||
+30
-8
@@ -82,8 +82,8 @@ class SyncTask:
|
||||
raise ValueError("Bitte einen Namen für die Aufgabe eingeben.")
|
||||
if not self.source.strip() or not self.target.strip():
|
||||
raise ValueError("Quell- und Zielordner müssen angegeben werden.")
|
||||
source = Path(self.source).expanduser().resolve(strict=False)
|
||||
target = Path(self.target).expanduser().resolve(strict=False)
|
||||
source = _safe_resolve(Path(self.source).expanduser())
|
||||
target = _safe_resolve(Path(self.target).expanduser())
|
||||
source_norm = os.path.normcase(str(source))
|
||||
target_norm = os.path.normcase(str(target))
|
||||
if source_norm == target_norm:
|
||||
@@ -123,7 +123,10 @@ class SyncTask:
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
last_run=data.get("last_run"),
|
||||
)
|
||||
task.validate()
|
||||
try:
|
||||
task.validate()
|
||||
except ValueError:
|
||||
task.enabled = False
|
||||
return task
|
||||
|
||||
|
||||
@@ -191,12 +194,28 @@ class AppConfig:
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> AppConfig:
|
||||
"""Erzeugt eine vollständige Konfiguration aus JSON-Daten."""
|
||||
tasks: list[SyncTask] = []
|
||||
for item in data.get("tasks", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
tasks.append(SyncTask.from_dict(item))
|
||||
except Exception:
|
||||
continue
|
||||
return cls(
|
||||
tasks=[SyncTask.from_dict(item) for item in data.get("tasks", [])],
|
||||
tasks=tasks,
|
||||
settings=AppSettings.from_dict(data.get("settings", {})),
|
||||
)
|
||||
|
||||
|
||||
def _safe_resolve(path: Path) -> Path:
|
||||
"""Löst Pfade sicher auf; fängt Netzwerkfehler und unvollständige Pfade ab."""
|
||||
try:
|
||||
return path.resolve(strict=False)
|
||||
except OSError:
|
||||
return path.absolute()
|
||||
|
||||
|
||||
def _normalize_relative_path(value: str) -> str:
|
||||
normalized = value.replace("/", os.sep).replace("\\", os.sep).strip(os.sep)
|
||||
path = Path(normalized)
|
||||
@@ -207,7 +226,10 @@ def _normalize_relative_path(value: str) -> str:
|
||||
|
||||
def _is_relative_to(path: Path, base: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(base)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
return path.is_relative_to(base)
|
||||
except (ValueError, OSError, AttributeError):
|
||||
try:
|
||||
path.relative_to(base)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
@@ -0,0 +1,101 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import syncvito.autostart as autostart
|
||||
from syncvito.autostart import generate_task_xml, set_autostart
|
||||
|
||||
|
||||
def test_generate_task_xml_structure() -> None:
|
||||
cmd = Path("C:/Program Files/SyncVito/SyncVito.exe")
|
||||
args = "--minimized"
|
||||
xml = generate_task_xml(cmd, args)
|
||||
|
||||
assert '<?xml version="1.0" encoding="UTF-16"?>' in xml
|
||||
assert "<RunLevel>HighestAvailable</RunLevel>" in xml
|
||||
assert "<LogonType>InteractiveToken</LogonType>" in xml
|
||||
assert "<Delay>PT5S</Delay>" in xml
|
||||
assert "<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>" in xml
|
||||
assert "<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>" in xml
|
||||
assert "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>" in xml
|
||||
assert "<Command>C:\\Program Files\\SyncVito\\SyncVito.exe</Command>" in xml
|
||||
assert "<Arguments>--minimized</Arguments>" in xml
|
||||
|
||||
|
||||
def test_set_autostart_non_windows(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(autostart.os, "name", "posix")
|
||||
assert not set_autostart(True)
|
||||
assert not set_autostart(False)
|
||||
|
||||
|
||||
def test_set_autostart_enable_scheduled_task_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(autostart.os, "name", "nt")
|
||||
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
||||
monkeypatch.setattr(sys, "executable", r"C:\Program Files\SyncVito\SyncVito.exe")
|
||||
|
||||
mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=0))
|
||||
monkeypatch.setattr(autostart, "_run_schtasks", mock_run)
|
||||
|
||||
deleted_run_key = False
|
||||
|
||||
def mock_delete_run_key() -> None:
|
||||
nonlocal deleted_run_key
|
||||
deleted_run_key = True
|
||||
|
||||
monkeypatch.setattr(autostart, "_delete_run_key_entry", mock_delete_run_key)
|
||||
|
||||
result = set_autostart(True)
|
||||
assert result is True
|
||||
assert mock_run.called
|
||||
create_call_args = mock_run.call_args[0][0]
|
||||
assert create_call_args[0] == "/create"
|
||||
assert create_call_args[1] == "/tn"
|
||||
assert create_call_args[2] == "SyncVito"
|
||||
assert deleted_run_key is True
|
||||
|
||||
|
||||
def test_set_autostart_enable_fallback_registry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(autostart.os, "name", "nt")
|
||||
monkeypatch.setattr(sys, "frozen", True, raising=False)
|
||||
monkeypatch.setattr(sys, "executable", r"C:\Program Files\SyncVito\SyncVito.exe")
|
||||
|
||||
# schtasks fails (e.g. non-elevated prompt)
|
||||
mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=1))
|
||||
monkeypatch.setattr(autostart, "_run_schtasks", mock_run)
|
||||
|
||||
registry_command = None
|
||||
|
||||
def mock_set_run_key(cmd: str) -> None:
|
||||
nonlocal registry_command
|
||||
registry_command = cmd
|
||||
|
||||
monkeypatch.setattr(autostart, "_set_run_key_entry", mock_set_run_key)
|
||||
|
||||
result = set_autostart(True)
|
||||
assert result is False
|
||||
assert registry_command == r'"C:\Program Files\SyncVito\SyncVito.exe" --minimized'
|
||||
|
||||
|
||||
def test_set_autostart_disable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(autostart.os, "name", "nt")
|
||||
|
||||
mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=0))
|
||||
monkeypatch.setattr(autostart, "_run_schtasks", mock_run)
|
||||
|
||||
deleted_run_key = False
|
||||
|
||||
def mock_delete_run_key() -> None:
|
||||
nonlocal deleted_run_key
|
||||
deleted_run_key = True
|
||||
|
||||
monkeypatch.setattr(autostart, "_delete_run_key_entry", mock_delete_run_key)
|
||||
|
||||
result = set_autostart(False)
|
||||
assert result is True
|
||||
assert mock_run.called
|
||||
delete_call_args = mock_run.call_args[0][0]
|
||||
assert delete_call_args == ["/delete", "/tn", "SyncVito", "/f"]
|
||||
assert deleted_run_key is True
|
||||
@@ -1,6 +1,9 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from syncvito.config_store import ConfigStore
|
||||
import pytest
|
||||
|
||||
from syncvito.config_store import ConfigError, ConfigStore
|
||||
from syncvito.models import AppConfig, AppSettings, SyncDirection, SyncTask
|
||||
|
||||
|
||||
@@ -28,3 +31,64 @@ def test_config_roundtrip(tmp_path: Path) -> None:
|
||||
assert loaded.tasks[0].name == "Archiv"
|
||||
assert loaded.tasks[0].direction is SyncDirection.BIDIRECTIONAL
|
||||
assert loaded.tasks[0].selected_paths == ["Dokumente"]
|
||||
|
||||
|
||||
def test_config_corrupt_json_creates_backup(tmp_path: Path) -> None:
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text("{ unvollstaendiges json...", encoding="utf-8")
|
||||
|
||||
store = ConfigStore(path)
|
||||
with pytest.raises(ConfigError, match="syntaktisch beschädigt"):
|
||||
store.load()
|
||||
|
||||
defekt_files = list(tmp_path.glob("config.defekt-*.json"))
|
||||
assert len(defekt_files) == 1
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_config_read_locked_does_not_rename_file(tmp_path: Path) -> None:
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text('{"schema_version": 1, "tasks": [], "settings": {}}', encoding="utf-8")
|
||||
|
||||
store = ConfigStore(path)
|
||||
with (
|
||||
patch.object(Path, "read_text", side_effect=PermissionError(13, "Permission denied")),
|
||||
pytest.raises(ConfigError, match="konnte nicht gelesen werden"),
|
||||
):
|
||||
store.load()
|
||||
|
||||
# Originaldatei darf bei Lese-/Sperrfehler NICHT umbenannt/gelöscht werden!
|
||||
assert path.exists()
|
||||
assert list(tmp_path.glob("config.defekt-*.json")) == []
|
||||
|
||||
|
||||
def test_config_load_with_invalid_task_keeps_task_disabled() -> None:
|
||||
data = {
|
||||
"schema_version": 1,
|
||||
"tasks": [
|
||||
{
|
||||
"name": "Ungültig",
|
||||
"source": "C:\\Ordner",
|
||||
"target": "C:\\Ordner", # Gleiche Quelle und Ziel -> ValueError bei validate()
|
||||
"interval_minutes": 30,
|
||||
}
|
||||
],
|
||||
"settings": {},
|
||||
}
|
||||
config = AppConfig.from_dict(data)
|
||||
assert len(config.tasks) == 1
|
||||
assert config.tasks[0].name == "Ungültig"
|
||||
assert config.tasks[0].enabled is False
|
||||
|
||||
|
||||
def test_sync_task_safe_resolve_network_oserror(tmp_path: Path) -> None:
|
||||
task = SyncTask(
|
||||
name="Netzwerk-Task",
|
||||
source=str(tmp_path / "lokal"),
|
||||
target=r"\\UnerreichbarerServer\Freigabe\Ordner",
|
||||
)
|
||||
with patch.object(Path, "resolve", side_effect=OSError("Network unreachable")):
|
||||
task.validate()
|
||||
|
||||
assert task.source == str((tmp_path / "lokal").resolve())
|
||||
assert r"\\UnerreichbarerServer\Freigabe\Ordner" in task.target
|
||||
@@ -3,9 +3,17 @@ from pathlib import Path
|
||||
|
||||
def test_installer_launches_admin_application_with_elevated_setup_token() -> None:
|
||||
script = Path("installer/SyncVito.iss").read_text(encoding="utf-8")
|
||||
run_entries = [line for line in script.splitlines() if line.startswith("Filename:")]
|
||||
run_section = script.split("[Run]")[1].split("[")[0] if "[Run]" in script else ""
|
||||
run_entries = [line for line in run_section.splitlines() if line.startswith("Filename:")]
|
||||
|
||||
assert len(run_entries) == 2
|
||||
assert all("runascurrentuser" in entry for entry in run_entries)
|
||||
assert "postinstall" in run_entries[0]
|
||||
assert 'Parameters: "--updated"' in run_entries[1]
|
||||
|
||||
|
||||
def test_installer_cleans_up_scheduled_task_on_uninstall() -> None:
|
||||
script = Path("installer/SyncVito.iss").read_text(encoding="utf-8")
|
||||
uninstall_section = script.split("[UninstallRun]")[1].split("[")[0] if "[UninstallRun]" in script else ""
|
||||
assert "schtasks.exe" in uninstall_section
|
||||
assert "{#MyAppName}" in uninstall_section
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.12
|
||||
1.0.14
|
||||
Reference in new issue
Block a user