Initial SyncVito release with installer updater
This commit is contained in:
commit
b9e4c09f49
41 files changed
+4331
No files matched your search
+12
@@ -0,0 +1,12 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
build/
|
||||
dist/
|
||||
release-build/
|
||||
release-dist/
|
||||
release/
|
||||
@@ -0,0 +1,32 @@
|
||||
# Release-Workflow
|
||||
|
||||
Wenn der Benutzer „Bau den Installer und lade es auf Gitea hoch“ oder einen
|
||||
sinngleichen Auftrag gibt, ist immer der vollständige Release-Workflow
|
||||
autorisiert:
|
||||
|
||||
1. Änderungen seit dem letzten Release prüfen und einen deutschen Abschnitt
|
||||
für die nächste Patch-Version in `CHANGELOG.md` ergänzen.
|
||||
2. `build_installer.ps1` ohne `-NoVersionBump`, `-SkipGit` oder `-SkipPublish`
|
||||
starten. Das Skript erhöht die Patch-Version, führt Tests und Ruff aus,
|
||||
baut die eigenständige PyInstaller-EXE, prüft ihren Start, erstellt den
|
||||
Inno-Setup-Installer, committed und pusht den Quellstand und veröffentlicht
|
||||
den Gitea-Release.
|
||||
3. `publish_gitea_release.ps1` nicht zusätzlich starten; das Build-Skript ruft
|
||||
es selbst auf.
|
||||
4. Abschließend den veröffentlichten Release und seine drei Assets über die
|
||||
öffentliche Gitea-API prüfen.
|
||||
|
||||
Bei Fehlern keine Prüfung überspringen und keinen fehlerhaften Build
|
||||
veröffentlichen. Python, PyQt6, watchdog und alle weiteren
|
||||
Laufzeitabhängigkeiten müssen in der EXE enthalten sein; auf dem Zielsystem
|
||||
darf keine Python-Installation vorausgesetzt werden.
|
||||
|
||||
# Interner Updater
|
||||
|
||||
Der Client lädt ausschließlich `SyncVitoSetup-<Version>.exe` und die zugehörige
|
||||
SHA-256-Datei aus dem neuesten stabilen öffentlichen Gitea-Release. Nach der
|
||||
Verifikation startet ein abgekoppelter Wächter. Dieser wartet auf den
|
||||
vollständigen Abschluss des PyInstaller-Eltern- und Kindprozesses und startet
|
||||
erst danach Inno Setup. Ein direkter Austausch der laufenden EXE ist nicht
|
||||
zulässig. Bootstrap- und Inno-Setup-Protokolle bleiben für die Diagnose im
|
||||
lokalen Update-Verzeichnis erhalten.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Änderungsprotokoll
|
||||
|
||||
## 1.0.0 – 2026-09-23
|
||||
|
||||
### Neu
|
||||
|
||||
- Aufgabenbasierte Einweg- und bidirektionale Ordnersynchronisation
|
||||
- Echtzeit-, Intervall- und manuelle Auslösung
|
||||
- Konfliktkopien, Löschregeln und optionale Unterauswahl
|
||||
- Erkennung sperrender Windows-Prozesse mit automatischem Retry
|
||||
- Aufgabenbezogener Tray-Status und deutsche Dark-Mode-Oberfläche
|
||||
- Eigenständiger Windows-Installer mit eingebettetem Python und allen Laufzeitabhängigkeiten
|
||||
- Sicherer Gitea-Release-Updater für versionierte, SHA-256-geprüfte Installer
|
||||
@@ -0,0 +1,92 @@
|
||||
# SyncVito
|
||||
|
||||
SyncVito ist ein Windows-Desktopwerkzeug zum Synchronisieren lokaler Ordner mit NAS- oder anderen Dateisystempfaden. Die Oberfläche ist vollständig deutsch, läuft im Windows-Infobereich weiter und speichert Aufgaben sowie Einstellungen lokal als JSON.
|
||||
|
||||
## Funktionsumfang
|
||||
|
||||
- Beliebig viele Sync-Aufgaben mit Quell- und Zielordner
|
||||
- Einweg-Synchronisation oder bidirektionaler Abgleich
|
||||
- Manuelle, zeitgesteuerte oder durch `watchdog` ausgelöste Echtzeitläufe
|
||||
- Optionale Unterauswahl einzelner Dateien und Ordner
|
||||
- Löschungen spiegeln oder Dateien grundsätzlich behalten
|
||||
- Konfliktwahl: neuerer Zeitstempel gewinnt oder `_conflict`-Sicherung
|
||||
- Erkennung sperrender Windows-Prozesse über den Restart Manager
|
||||
- Automatischer Retry für gesperrte Dateien und nicht erreichbare NAS-Pfade
|
||||
- Eine zentrale Fortschrittsanzeige, Aktivitätsprotokoll und Status im Tray
|
||||
- Aufgabenbezogener Laufzeitstatus mit Worst-Case-Ableitung: grün bei gesunden aktiven Aufgaben, rot bei Pause/Inaktivität und Ausrufezeichen bei Fehlern, NAS-Ausfall oder wartenden Dateisperren
|
||||
- Persistente Darstellungseinstellungen und optionaler Windows-Autostart
|
||||
- UAC-Manifest mit `requireAdministrator`
|
||||
- Automatische oder manuelle Updateprüfung über öffentliche Gitea-Releases
|
||||
- SHA-256-geprüfter Installer-Download mit sicherem Start erst nach dem vollständigen Beenden
|
||||
|
||||
Projektseite: [gitea.davito.synology.me/Davito/SyncVito](https://gitea.davito.synology.me/Davito/SyncVito)
|
||||
|
||||
## Entwicklung starten
|
||||
|
||||
Voraussetzung ist Python 3.11 oder neuer unter Windows.
|
||||
|
||||
```powershell
|
||||
python -m pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
Die Konfiguration wird unter `%LOCALAPPDATA%\SyncVito\config.json` gespeichert. Der technische Abgleichzustand für bidirektionale Löschungen liegt daneben in `sync-state.json`.
|
||||
|
||||
## Tests
|
||||
|
||||
```powershell
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
## Eigenständigen Windows-Installer bauen
|
||||
|
||||
```powershell
|
||||
.\build.ps1
|
||||
```
|
||||
|
||||
Das Ergebnis liegt anschließend unter `release\SyncVitoSetup-<Version>.exe`. Der Installer enthält eine eigenständige PyInstaller-One-File-Anwendung einschließlich Python-Interpreter, PyQt6, `watchdog` und aller übrigen Laufzeitabhängigkeiten. Auf dem Zielrechner müssen weder Python noch `pip` installiert sein. Anwendung und Installer fordern die benötigten Administratorrechte über ihre Windows-Manifeste an.
|
||||
|
||||
Hinweis: Eine nicht signierte lokale EXE kann beim ersten Start einen Windows-SmartScreen-Hinweis auslösen. Für die Verteilung in einer Organisation empfiehlt sich eine reguläre Codesignatur.
|
||||
|
||||
## Updates und Releases
|
||||
|
||||
SyncVito fragt höchstens einmal innerhalb von 24 Stunden die stabilen Releases des öffentlichen Gitea-Repositories ab. Die automatische Prüfung lässt sich in den Einstellungen abschalten; eine manuelle Prüfung ist dort und im Tray-Menü jederzeit möglich.
|
||||
|
||||
Ein installierbares Release benötigt diese Anhänge:
|
||||
|
||||
- `SyncVitoSetup-<Version>.exe`
|
||||
- `SyncVitoSetup-<Version>.exe.sha256`
|
||||
- `CHANGELOG-<Version>.md`
|
||||
|
||||
Die Version wird zentral aus `version.txt` geladen. Der vollständige Workflow wird mit folgendem Auftrag ausgelöst:
|
||||
|
||||
```text
|
||||
Bau den Installer und lade es auf Gitea hoch
|
||||
```
|
||||
|
||||
Dabei wird die Patch-Version erhöht, der Änderungsabschnitt geprüft, die Tests und der EXE-Smoke-Test ausgeführt, der Installer gebaut, der Quellstand committed und gepusht und anschließend ein Gitea-Release mit verifizierten Assets angelegt. Das Gitea-Token bleibt per DPAPI lokal geschützt und wird weder in die Anwendung noch in das Repository übernommen.
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```text
|
||||
src/syncvito/
|
||||
├── app.py Anwendungsstart und Einzelinstanzschutz
|
||||
├── main_window.py Hauptfenster, Tray und UI-Zustand
|
||||
├── dialogs.py Aufgaben- und Unterauswahl-Dialoge
|
||||
├── coordinator.py Hintergrundläufe, Zeitplan und Retry
|
||||
├── sync_engine.py Dateisystem-Abgleich
|
||||
├── file_locks.py Windows-Sperrprozess-Erkennung
|
||||
├── realtime.py watchdog-Integration
|
||||
├── updater.py Gitea-Protokoll, Download und Integritätsprüfung
|
||||
├── update_controller.py Hintergrund-Worker für Updates
|
||||
├── update_dialog.py deutscher Versionsdialog
|
||||
├── config_store.py atomare JSON-Konfiguration
|
||||
├── state_store.py bidirektionaler Abgleichzustand
|
||||
├── models.py Domänenmodelle und Validierung
|
||||
├── theme.py Dark-Mode-Designsystem
|
||||
└── icons.py skalierbare Statussymbole
|
||||
```
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
Beim Modus „Löschungen spiegeln“ entfernt SyncVito Dateien auf der Gegenseite, nachdem eine Löschung gegenüber dem letzten erfolgreichen Lauf eindeutig erkannt wurde. Vor produktivem Einsatz empfiehlt sich ein Test mit einem kleinen Ordnerpaar und eine vorhandene Datensicherung. Symbolische Links werden bewusst nicht verfolgt.
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity version="1.0.0.0" processorArchitecture="*" name="SyncVito" type="win32"/>
|
||||
<description>SyncVito Datei- und NAS-Synchronisation</description>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
<supportedOS Id="{4f476546-937d-4c0f-b5bf-4e24cda8b0a6}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
from PyInstaller.utils.hooks import collect_submodules
|
||||
|
||||
hidden_imports = collect_submodules("watchdog.observers")
|
||||
|
||||
a = Analysis(
|
||||
["main.py"],
|
||||
pathex=["src"],
|
||||
binaries=[],
|
||||
datas=[("version.txt", ".")],
|
||||
hiddenimports=hidden_imports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=1,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name="SyncVito",
|
||||
icon="assets/SyncVito.ico",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=False,
|
||||
manifest="SyncVito.manifest",
|
||||
version="build/version_info.txt",
|
||||
uac_admin=True,
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
@@ -0,0 +1,3 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
& (Join-Path $Root "build_installer.ps1") -NoVersionBump -SkipPublish -SkipGit
|
||||
@@ -0,0 +1,175 @@
|
||||
param(
|
||||
[switch]$NoVersionBump,
|
||||
[switch]$SkipPublish,
|
||||
[switch]$SkipGit,
|
||||
[string]$TargetVersion = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location -LiteralPath $Root
|
||||
|
||||
function Find-Python {
|
||||
foreach ($candidate in @("python", "py")) {
|
||||
try {
|
||||
& $candidate --version 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) { return $candidate }
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
throw "Python wurde nicht gefunden."
|
||||
}
|
||||
|
||||
function Find-InnoCompiler {
|
||||
$candidates = @(
|
||||
"$env:LOCALAPPDATA\Programs\Inno Setup 7\ISCC.exe",
|
||||
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe",
|
||||
"$env:ProgramFiles\Inno Setup 7\ISCC.exe",
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 7\ISCC.exe",
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
|
||||
"$env:ProgramFiles\Inno Setup 6\ISCC.exe"
|
||||
)
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate }
|
||||
}
|
||||
throw "Inno Setup 6 oder 7 wurde nicht gefunden."
|
||||
}
|
||||
|
||||
function Write-Utf8NoBom {
|
||||
param([string]$LiteralPath, [string]$Content)
|
||||
[System.IO.File]::WriteAllText($LiteralPath, $Content, [System.Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
|
||||
function Get-Sha256Hex {
|
||||
param([string]$LiteralPath)
|
||||
return (Get-FileHash -LiteralPath $LiteralPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
$VersionPath = Join-Path $Root "version.txt"
|
||||
$PyprojectPath = Join-Path $Root "pyproject.toml"
|
||||
$ChangelogPath = Join-Path $Root "CHANGELOG.md"
|
||||
$originalVersion = [System.IO.File]::ReadAllText($VersionPath, [System.Text.Encoding]::UTF8)
|
||||
$originalPyproject = [System.IO.File]::ReadAllText($PyprojectPath, [System.Text.Encoding]::UTF8)
|
||||
$currentVersion = $originalVersion.Trim()
|
||||
if ($currentVersion -notmatch '^\d+\.\d+\.\d+$') {
|
||||
throw "Ungültige Version in version.txt: $currentVersion"
|
||||
}
|
||||
|
||||
$appVersion = $currentVersion
|
||||
if (-not [string]::IsNullOrWhiteSpace($TargetVersion)) {
|
||||
if ($NoVersionBump) { throw "TargetVersion und NoVersionBump dürfen nicht gemeinsam verwendet werden." }
|
||||
if ($TargetVersion -notmatch '^\d+\.\d+\.\d+$') { throw "Ungültige Zielversion: $TargetVersion" }
|
||||
if ([version]$TargetVersion -le [version]$currentVersion) {
|
||||
throw "Die Zielversion $TargetVersion muss neuer als $currentVersion sein."
|
||||
}
|
||||
$appVersion = $TargetVersion
|
||||
}
|
||||
elseif (-not $NoVersionBump) {
|
||||
$parts = @($currentVersion.Split('.') | ForEach-Object { [int]$_ })
|
||||
$parts[2] += 1
|
||||
$appVersion = "$($parts[0]).$($parts[1]).$($parts[2])"
|
||||
}
|
||||
|
||||
$changelog = [System.IO.File]::ReadAllText($ChangelogPath, [System.Text.Encoding]::UTF8)
|
||||
$sectionPattern = '(?ms)^##\s+' + [regex]::Escape($appVersion) + '\s+.*?(?=^##\s+\d+\.\d+\.\d+\s+|\z)'
|
||||
$sectionMatch = [regex]::Match($changelog, $sectionPattern)
|
||||
if (-not $sectionMatch.Success) {
|
||||
throw "CHANGELOG.md enthält keinen Abschnitt für Version $appVersion."
|
||||
}
|
||||
$releaseNotes = "# SyncVito – Änderungsprotokoll`r`n`r`n$($sectionMatch.Value.Trim())`r`n"
|
||||
|
||||
$versionParts = @($appVersion.Split('.') | ForEach-Object { [int]$_ })
|
||||
$fileVersion = "$($versionParts[0]).$($versionParts[1]).$($versionParts[2]).0"
|
||||
$python = Find-Python
|
||||
$iscc = Find-InnoCompiler
|
||||
$sourceCommitted = $false
|
||||
|
||||
try {
|
||||
Write-Utf8NoBom -LiteralPath $VersionPath -Content "$appVersion`n"
|
||||
$versionRegex = [regex]::new('(?m)^(version\s*=\s*")[^"]+("\s*)$')
|
||||
$updatedPyproject = $versionRegex.Replace($originalPyproject, "`${1}$appVersion`${2}", 1)
|
||||
if ($updatedPyproject -eq $originalPyproject -and $appVersion -ne $currentVersion) {
|
||||
throw "Die Projektversion in pyproject.toml konnte nicht aktualisiert werden."
|
||||
}
|
||||
Write-Utf8NoBom -LiteralPath $PyprojectPath -Content $updatedPyproject
|
||||
|
||||
$buildRoot = Join-Path $Root "build"
|
||||
$appDist = Join-Path $buildRoot "app"
|
||||
$workPath = Join-Path $buildRoot "pyinstaller"
|
||||
New-Item -ItemType Directory -Force -Path $buildRoot, $appDist | Out-Null
|
||||
|
||||
$versionInfoPath = Join-Path $buildRoot "version_info.txt"
|
||||
$versionInfo = @"
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(filevers=($($versionParts[0]), $($versionParts[1]), $($versionParts[2]), 0), prodvers=($($versionParts[0]), $($versionParts[1]), $($versionParts[2]), 0), mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0, date=(0, 0)),
|
||||
kids=[StringFileInfo([StringTable('040704B0', [StringStruct('CompanyName', 'Davito'), StringStruct('FileDescription', 'SyncVito'), StringStruct('FileVersion', '$fileVersion'), StringStruct('InternalName', 'SyncVito'), StringStruct('OriginalFilename', 'SyncVito.exe'), StringStruct('ProductName', 'SyncVito'), StringStruct('ProductVersion', '$appVersion')])]), VarFileInfo([VarStruct('Translation', [1031, 1200])])]
|
||||
)
|
||||
"@
|
||||
Write-Utf8NoBom -LiteralPath $versionInfoPath -Content $versionInfo
|
||||
|
||||
Write-Host "Tests werden ausgeführt …"
|
||||
& $python -m pytest
|
||||
if ($LASTEXITCODE -ne 0) { throw "Die Tests sind fehlgeschlagen." }
|
||||
& $python -m ruff check .
|
||||
if ($LASTEXITCODE -ne 0) { throw "Ruff hat Fehler gefunden." }
|
||||
|
||||
Write-Host "Programmsymbol wird erzeugt …"
|
||||
& $python tools\generate_icon.py
|
||||
if ($LASTEXITCODE -ne 0) { throw "Das Programmsymbol konnte nicht erzeugt werden." }
|
||||
|
||||
Write-Host "Baue SyncVito.exe mit PyInstaller …"
|
||||
& $python -m PyInstaller --noconfirm --clean --distpath $appDist --workpath $workPath SyncVito.spec
|
||||
if ($LASTEXITCODE -ne 0) { throw "Der PyInstaller-Build ist fehlgeschlagen." }
|
||||
$appExe = Join-Path $appDist "SyncVito.exe"
|
||||
if (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) { throw "SyncVito.exe wurde nicht erzeugt." }
|
||||
|
||||
Write-Host "Prüfe den gebauten Programmstart …"
|
||||
$smoke = Start-Process -FilePath $appExe -ArgumentList "--smoke-test" -PassThru -Wait -WindowStyle Hidden
|
||||
if ($smoke.ExitCode -ne 0) { throw "Der EXE-Smoke-Test ist fehlgeschlagen (Exitcode $($smoke.ExitCode))." }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $Root "release") | Out-Null
|
||||
Write-Host "Baue Windows-Installer …"
|
||||
& $iscc "/DMyAppVersion=$appVersion" "/DMyAppFileVersion=$fileVersion" "/DMyAppExeSource=$appExe" (Join-Path $Root "installer\SyncVito.iss")
|
||||
if ($LASTEXITCODE -ne 0) { throw "Der Inno-Setup-Build ist fehlgeschlagen." }
|
||||
|
||||
$installerName = "SyncVitoSetup-$appVersion.exe"
|
||||
$installerPath = Join-Path $Root "release\$installerName"
|
||||
if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) { throw "Installer wurde nicht gefunden: $installerPath" }
|
||||
$hash = Get-Sha256Hex -LiteralPath $installerPath
|
||||
$checksumPath = "$installerPath.sha256"
|
||||
Write-Utf8NoBom -LiteralPath $checksumPath -Content "$hash *$installerName`n"
|
||||
$releaseChangelogPath = Join-Path $Root "release\CHANGELOG-$appVersion.md"
|
||||
Write-Utf8NoBom -LiteralPath $releaseChangelogPath -Content $releaseNotes
|
||||
|
||||
if (-not $SkipGit) {
|
||||
git add --all
|
||||
if ($LASTEXITCODE -ne 0) { throw "Git konnte die Release-Änderungen nicht vormerken." }
|
||||
git diff --cached --quiet
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
git commit -m "Release SyncVito $appVersion"
|
||||
if ($LASTEXITCODE -ne 0) { throw "Der Release-Commit ist fehlgeschlagen." }
|
||||
}
|
||||
$sourceCommitted = $true
|
||||
git push origin main
|
||||
if ($LASTEXITCODE -ne 0) { throw "Der Quellstand konnte nicht nach Gitea gepusht werden." }
|
||||
}
|
||||
|
||||
if (-not $SkipPublish) {
|
||||
& (Join-Path $Root "publish_gitea_release.ps1") -Version $appVersion
|
||||
if ($LASTEXITCODE -ne 0) { throw "Der Gitea-Release ist fehlgeschlagen." }
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Release-Workflow abgeschlossen:"
|
||||
Write-Host " Installer: $installerPath"
|
||||
Write-Host " Prüfsumme: $checksumPath"
|
||||
Write-Host " SHA256: $hash"
|
||||
}
|
||||
catch {
|
||||
if (-not $sourceCommitted) {
|
||||
Write-Utf8NoBom -LiteralPath $VersionPath -Content $originalVersion
|
||||
Write-Utf8NoBom -LiteralPath $PyprojectPath -Content $originalPyproject
|
||||
Write-Warning "Build fehlgeschlagen; Versionsdateien wurden zurückgesetzt."
|
||||
}
|
||||
throw
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
# Design System Master File
|
||||
|
||||
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||
> If that file exists, its rules **override** this Master file.
|
||||
> If not, strictly follow the rules below.
|
||||
|
||||
---
|
||||
|
||||
**Project:** SyncVito
|
||||
**Generated:** 2026-09-23 17:52:06
|
||||
**Category:** General
|
||||
**Design Dials:** Variance 5/10 (Balanced / Modern) | Motion 3/10 (Subtle) | Density 7/10 (Standard)
|
||||
|
||||
---
|
||||
|
||||
## Global Rules
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Role | Hex | CSS Variable |
|
||||
|------|-----|--------------|
|
||||
| Primary | `#2563EB` | `--color-primary` |
|
||||
| On Primary | `#FFFFFF` | `--color-on-primary` |
|
||||
| Secondary | `#3B82F6` | `--color-secondary` |
|
||||
| On Secondary | `#000000` | `--color-on-secondary` |
|
||||
| Accent/CTA | `#EA580C` | `--color-accent` |
|
||||
| On Accent/CTA | `#000000` | `--color-on-accent` |
|
||||
| Background | `#F8FAFC` | `--color-background` |
|
||||
| Foreground | `#1E293B` | `--color-foreground` |
|
||||
| Card | `#FFFFFF` | `--color-card` |
|
||||
| Card Foreground | `#1E293B` | `--color-card-foreground` |
|
||||
| Muted | `#E9EFF8` | `--color-muted` |
|
||||
| Muted Foreground | `#475569` | `--color-muted-foreground` |
|
||||
| Border | `#E2E8F0` | `--color-border` |
|
||||
| Destructive | `#DC2626` | `--color-destructive` |
|
||||
| On Destructive | `#FFFFFF` | `--color-on-destructive` |
|
||||
| Ring | `#2563EB` | `--color-ring` |
|
||||
|
||||
**Color Notes:** Trust blue + orange CTA contrast [Accent adjusted from #F97316]
|
||||
|
||||
### Typography
|
||||
|
||||
- **Heading Font:** Inter
|
||||
- **Body Font:** Inter
|
||||
- **Mood:** dark, cinematic, technical, precision, clean, premium, developer, professional, high-end utility
|
||||
- **Google Fonts:** [Inter + Inter](https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap)
|
||||
|
||||
**CSS Import:**
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
```
|
||||
|
||||
### Spacing Variables
|
||||
|
||||
*Density: 7/10 — Standard*
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
||||
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
||||
| `--space-md` | `16px` / `1rem` | Standard padding |
|
||||
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
||||
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
||||
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
||||
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
||||
|
||||
### Shadow Depths
|
||||
|
||||
| Level | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||
|
||||
---
|
||||
|
||||
## Component Specs
|
||||
|
||||
### Buttons
|
||||
|
||||
```css
|
||||
/* Primary Button */
|
||||
.btn-primary {
|
||||
background: #EA580C;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #2563EB;
|
||||
border: 2px solid #2563EB;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: #F8FAFC;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
.input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #2563EB;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px #2563EB20;
|
||||
}
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
```css
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
**Style:** Minimalism & Swiss Style
|
||||
|
||||
**Keywords:** Clean, simple, spacious, functional, white space, high contrast, geometric, sans-serif, grid-based, essential
|
||||
|
||||
**Best For:** Enterprise apps, dashboards, documentation sites, SaaS platforms, professional tools
|
||||
|
||||
**Key Effects:** Subtle hover (200-250ms), smooth transitions, sharp shadows if any, clear type hierarchy, fast loading
|
||||
|
||||
### Page Pattern
|
||||
|
||||
**Pattern Name:** Hero + Features + CTA
|
||||
|
||||
- **Conversion Strategy:** Deep CTA placement. For CTA label text, verify at least 4.5:1 against the button fill; use 7:1 only when the product explicitly targets AAA normal-text contrast. Keep focus and component boundaries independently visible. Disable hero parallax under reduced motion and render its static final state.
|
||||
- **CTA Placement:** Hero (sticky) + Bottom
|
||||
- **Section Order:** Hero with headline/image > Value prop > Key features (3-5) > CTA section > Footer
|
||||
|
||||
---
|
||||
|
||||
## Motion
|
||||
|
||||
**Scroll Reveal** (Subtle) — Trigger: scroll (viewport enter) | Duration: 300-400ms | Easing: `power1.out`
|
||||
|
||||
```js
|
||||
gsap.from(el, { opacity: 0, y: 12, duration: 0.35, ease: 'power1.out', scrollTrigger: { trigger: el, start: 'top 90%', toggleActions: 'play none none reverse' } });
|
||||
```
|
||||
|
||||
**Framework notes:** Requires the ScrollTrigger plugin registered once via gsap.registerPlugin(ScrollTrigger); Use matchMedia('(prefers-reduced-motion: reduce)') to skip non-essential motion and render the final state immediately
|
||||
|
||||
- ✅ Keep the y offset small (8-16px) so it reads as a fade, not a slide
|
||||
- ❌ Don't reveal below-the-fold content needed for SEO/crawlers as invisible-by-default without a no-JS fallback
|
||||
- ⚡ toggleActions 'play none none reverse' avoids re-triggering on every scroll direction change
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns (Do NOT Use)
|
||||
|
||||
|
||||
### Additional Forbidden Patterns
|
||||
|
||||
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||
|
||||
---
|
||||
|
||||
## Pre-Delivery Checklist
|
||||
|
||||
Before delivering any UI code, verify:
|
||||
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] `cursor-pointer` on all clickable elements
|
||||
- [ ] Hover states with smooth transitions (150-300ms)
|
||||
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] No horizontal scroll on mobile
|
||||
@@ -0,0 +1,57 @@
|
||||
#define MyAppName "SyncVito"
|
||||
#define MyAppPublisher "Davito"
|
||||
#ifndef MyAppVersion
|
||||
#define MyAppVersion "0.0.0-dev"
|
||||
#endif
|
||||
#ifndef MyAppFileVersion
|
||||
#define MyAppFileVersion "0.0.0.0"
|
||||
#endif
|
||||
#ifndef MyAppExeSource
|
||||
#define MyAppExeSource "..\build\app\SyncVito.exe"
|
||||
#endif
|
||||
#define MyAppExeName "SyncVito.exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{7B65F923-828D-42F3-BC86-4939686879E4}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppVerName={#MyAppName} {#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
VersionInfoCompany={#MyAppPublisher}
|
||||
VersionInfoDescription={#MyAppName} Installer
|
||||
VersionInfoProductName={#MyAppName}
|
||||
VersionInfoProductVersion={#MyAppVersion}
|
||||
VersionInfoVersion={#MyAppFileVersion}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir=..\release
|
||||
OutputBaseFilename=SyncVitoSetup-{#MyAppVersion}
|
||||
SetupIconFile=..\assets\SyncVito.ico
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=admin
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
CloseApplications=yes
|
||||
CloseApplicationsFilter={#MyAppExeName}
|
||||
RestartApplications=no
|
||||
|
||||
[Languages]
|
||||
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "Desktop-Verknüpfung erstellen"; GroupDescription: "Verknüpfungen:"; Flags: checkedonce
|
||||
|
||||
[Files]
|
||||
Source: "{#MyAppExeSource}"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{#MyAppName} starten"; Flags: nowait postinstall skipifsilent
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--updated"; Flags: nowait skipifnotsilent
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Entwicklungsstart ohne vorherige Paketinstallation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "src"))
|
||||
|
||||
from syncvito.app import main # noqa: E402
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^\d+\.\d+\.\d+$")]
|
||||
[string]$Version
|
||||
)
|
||||
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
& (Join-Path $Root "publish_gitea_release.ps1") -Version $Version
|
||||
@@ -0,0 +1,201 @@
|
||||
param(
|
||||
[string]$Version = "",
|
||||
[switch]$ValidateOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Net.Http
|
||||
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$BaseUrl = "https://gitea.davito.synology.me"
|
||||
$Owner = "Davito"
|
||||
$Repository = "SyncVito"
|
||||
$LocalAppData = [Environment]::GetFolderPath("LocalApplicationData")
|
||||
$TokenPaths = @(
|
||||
(Join-Path $LocalAppData "SyncVito\developer\gitea-token.dpapi"),
|
||||
(Join-Path $LocalAppData "MusicBrain\developer\gitea-token.dpapi")
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||
$Version = (Get-Content -LiteralPath (Join-Path $Root "version.txt") -Raw -Encoding UTF8).Trim()
|
||||
}
|
||||
if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "Ungültige Release-Version: $Version" }
|
||||
|
||||
$Tag = "v$Version"
|
||||
$ReleaseDir = Join-Path $Root "release"
|
||||
$InstallerName = "SyncVitoSetup-$Version.exe"
|
||||
$ChecksumName = "$InstallerName.sha256"
|
||||
$ChangelogName = "CHANGELOG-$Version.md"
|
||||
$AssetPaths = @(
|
||||
(Join-Path $ReleaseDir $InstallerName),
|
||||
(Join-Path $ReleaseDir $ChecksumName),
|
||||
(Join-Path $ReleaseDir $ChangelogName)
|
||||
)
|
||||
foreach ($assetPath in $AssetPaths) {
|
||||
if (-not (Test-Path -LiteralPath $assetPath -PathType Leaf)) { throw "Release-Datei fehlt: $assetPath" }
|
||||
}
|
||||
|
||||
$actualHash = (Get-FileHash -LiteralPath $AssetPaths[0] -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
$checksumText = (Get-Content -LiteralPath $AssetPaths[1] -Raw -Encoding UTF8).Trim()
|
||||
$checksumPattern = '^([0-9a-fA-F]{64})\s+\*?' + [regex]::Escape($InstallerName) + '$'
|
||||
if ($checksumText -notmatch $checksumPattern -or $Matches[1].ToLowerInvariant() -ne $actualHash) {
|
||||
throw "SHA-256-Prüfung fehlgeschlagen. Der Release wird nicht hochgeladen."
|
||||
}
|
||||
|
||||
$plainToken = $null
|
||||
$tokenPointer = [IntPtr]::Zero
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) {
|
||||
$plainToken = $env:GITEA_TOKEN.Trim()
|
||||
}
|
||||
else {
|
||||
$tokenPath = $TokenPaths | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
|
||||
if (-not $tokenPath) {
|
||||
throw "Gitea-Token fehlt. Erwarte GITEA_TOKEN oder eine lokale DPAPI-Token-Datei."
|
||||
}
|
||||
try {
|
||||
$secureToken = (Get-Content -LiteralPath $tokenPath -Raw -Encoding UTF8).Trim() | ConvertTo-SecureString
|
||||
$tokenPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
|
||||
$plainToken = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPointer)
|
||||
}
|
||||
catch {
|
||||
throw "Der lokal gespeicherte Gitea-Token konnte nicht entschlüsselt werden: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
$client = $null
|
||||
|
||||
function Read-ResponseJson {
|
||||
param([System.Net.Http.HttpResponseMessage]$Response, [string]$Action)
|
||||
$text = $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
|
||||
if (-not $Response.IsSuccessStatusCode) {
|
||||
$detail = $text
|
||||
try {
|
||||
$errorPayload = $text | ConvertFrom-Json
|
||||
if ($errorPayload.message) { $detail = $errorPayload.message }
|
||||
}
|
||||
catch { }
|
||||
throw "$Action fehlgeschlagen (HTTP $([int]$Response.StatusCode)): $detail"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($text)) { return $null }
|
||||
return $text | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Send-GiteaJson {
|
||||
param([string]$Method, [string]$Path, [object]$Body, [string]$Action)
|
||||
$request = [System.Net.Http.HttpRequestMessage]::new(
|
||||
[System.Net.Http.HttpMethod]::new($Method),
|
||||
"$BaseUrl/api/v1$Path"
|
||||
)
|
||||
try {
|
||||
if ($null -ne $Body) {
|
||||
$json = $Body | ConvertTo-Json -Depth 8 -Compress
|
||||
$request.Content = [System.Net.Http.StringContent]::new(
|
||||
$json,
|
||||
[System.Text.Encoding]::UTF8,
|
||||
"application/json"
|
||||
)
|
||||
}
|
||||
$response = $client.SendAsync($request).GetAwaiter().GetResult()
|
||||
try { return Read-ResponseJson -Response $response -Action $Action }
|
||||
finally { $response.Dispose() }
|
||||
}
|
||||
finally { $request.Dispose() }
|
||||
}
|
||||
|
||||
try {
|
||||
if ([string]::IsNullOrWhiteSpace($plainToken)) { throw "Der gespeicherte Gitea-Token ist leer." }
|
||||
$client = [System.Net.Http.HttpClient]::new()
|
||||
$client.Timeout = [TimeSpan]::FromMinutes(20)
|
||||
$client.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new("token", $plainToken)
|
||||
$client.DefaultRequestHeaders.UserAgent.ParseAdd("SyncVito-Release-Publisher/$Version")
|
||||
$client.DefaultRequestHeaders.Accept.ParseAdd("application/json")
|
||||
|
||||
$repositoryInfo = Send-GiteaJson -Method "GET" -Path "/repos/$Owner/$Repository" -Body $null -Action "Repository-Zugriff prüfen"
|
||||
if (-not $repositoryInfo.permissions.push) { throw "Der Token besitzt keine Schreibrechte für $Owner/$Repository." }
|
||||
if ($ValidateOnly) {
|
||||
Write-Host "Gitea-Publisher ist bereit: $($repositoryInfo.full_name), Version $Version"
|
||||
return
|
||||
}
|
||||
|
||||
$encodedTag = [Uri]::EscapeDataString($Tag)
|
||||
$release = $null
|
||||
$lookup = [System.Net.Http.HttpRequestMessage]::new(
|
||||
[System.Net.Http.HttpMethod]::Get,
|
||||
"$BaseUrl/api/v1/repos/$Owner/$Repository/releases/tags/$encodedTag"
|
||||
)
|
||||
try {
|
||||
$lookupResponse = $client.SendAsync($lookup).GetAwaiter().GetResult()
|
||||
try {
|
||||
if ($lookupResponse.IsSuccessStatusCode) {
|
||||
$release = Read-ResponseJson -Response $lookupResponse -Action "Release lesen"
|
||||
}
|
||||
elseif ([int]$lookupResponse.StatusCode -ne 404) {
|
||||
Read-ResponseJson -Response $lookupResponse -Action "Release prüfen" | Out-Null
|
||||
}
|
||||
}
|
||||
finally { $lookupResponse.Dispose() }
|
||||
}
|
||||
finally { $lookup.Dispose() }
|
||||
|
||||
$releaseBody = (Get-Content -LiteralPath $AssetPaths[2] -Raw -Encoding UTF8).Trim()
|
||||
$metadata = @{
|
||||
tag_name = $Tag
|
||||
target_commitish = "main"
|
||||
name = "SyncVito $Version"
|
||||
body = $releaseBody
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
}
|
||||
if ($null -eq $release) {
|
||||
Write-Host "Lege Gitea-Release $Tag an …"
|
||||
$release = Send-GiteaJson -Method "POST" -Path "/repos/$Owner/$Repository/releases" -Body $metadata -Action "Release anlegen"
|
||||
}
|
||||
else {
|
||||
Write-Host "Aktualisiere Gitea-Release $Tag …"
|
||||
$release = Send-GiteaJson -Method "PATCH" -Path "/repos/$Owner/$Repository/releases/$($release.id)" -Body $metadata -Action "Release aktualisieren"
|
||||
}
|
||||
if (-not $release.id) { throw "Gitea hat keine Release-ID zurückgegeben." }
|
||||
|
||||
$localNames = @($AssetPaths | ForEach-Object { [System.IO.Path]::GetFileName($_) })
|
||||
foreach ($existing in @($release.assets | Where-Object { $_.name -in $localNames })) {
|
||||
Send-GiteaJson -Method "DELETE" -Path "/repos/$Owner/$Repository/releases/$($release.id)/assets/$($existing.id)" -Body $null -Action "Altes Asset entfernen" | Out-Null
|
||||
}
|
||||
|
||||
foreach ($assetPath in $AssetPaths) {
|
||||
$assetName = [System.IO.Path]::GetFileName($assetPath)
|
||||
Write-Host "Lade hoch: $assetName"
|
||||
$uploadUrl = "$BaseUrl/api/v1/repos/$Owner/$Repository/releases/$($release.id)/assets?name=$([Uri]::EscapeDataString($assetName))"
|
||||
$request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Post, $uploadUrl)
|
||||
$multipart = [System.Net.Http.MultipartFormDataContent]::new()
|
||||
$stream = [System.IO.File]::OpenRead($assetPath)
|
||||
$content = [System.Net.Http.StreamContent]::new($stream)
|
||||
$content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new("application/octet-stream")
|
||||
$multipart.Add($content, "attachment", $assetName)
|
||||
$request.Content = $multipart
|
||||
try {
|
||||
$response = $client.SendAsync($request).GetAwaiter().GetResult()
|
||||
try { Read-ResponseJson -Response $response -Action "Upload von $assetName" | Out-Null }
|
||||
finally { $response.Dispose() }
|
||||
}
|
||||
finally {
|
||||
$request.Dispose()
|
||||
$multipart.Dispose()
|
||||
$content.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
$published = Send-GiteaJson -Method "GET" -Path "/repos/$Owner/$Repository/releases/tags/$encodedTag" -Body $null -Action "Release verifizieren"
|
||||
$publishedNames = @($published.assets | ForEach-Object { $_.name })
|
||||
foreach ($required in $localNames) {
|
||||
if ($required -notin $publishedNames) { throw "Veröffentlichtes Asset fehlt: $required" }
|
||||
}
|
||||
Write-Host "Gitea-Release erfolgreich veröffentlicht: $($published.html_url)"
|
||||
Write-Host "SHA256: $actualHash"
|
||||
}
|
||||
finally {
|
||||
if ($client) { $client.Dispose() }
|
||||
if ($tokenPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPointer) }
|
||||
$plainToken = $null
|
||||
Remove-Variable plainToken -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=75", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "syncvito"
|
||||
version = "1.0.0"
|
||||
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"PyQt6>=6.7,<7",
|
||||
"watchdog>=5,<7",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"mypy>=1.11",
|
||||
"pyinstaller>=6.10",
|
||||
"pytest>=8.3",
|
||||
"ruff>=0.7",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
syncvito = "syncvito.app:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "W", "F", "I", "B", "C4", "UP", "SIM"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["src"]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
exclude = ["build", "dist"]
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["watchdog.*"]
|
||||
ignore_missing_imports = true
|
||||
@@ -0,0 +1,3 @@
|
||||
PyQt6>=6.7,<7
|
||||
watchdog>=5,<7
|
||||
pyinstaller>=6.10
|
||||
@@ -0,0 +1,25 @@
|
||||
"""SyncVito – zuverlässige Ordner-Synchronisation für Windows."""
|
||||
|
||||
from syncvito.models import (
|
||||
AppConfig,
|
||||
AppSettings,
|
||||
ConflictMode,
|
||||
DeleteMode,
|
||||
SyncDirection,
|
||||
SyncTask,
|
||||
TaskRuntimeState,
|
||||
TriggerMode,
|
||||
)
|
||||
from syncvito.version import __version__
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"AppConfig",
|
||||
"AppSettings",
|
||||
"ConflictMode",
|
||||
"DeleteMode",
|
||||
"SyncDirection",
|
||||
"SyncTask",
|
||||
"TaskRuntimeState",
|
||||
"TriggerMode",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Ausführung mit ``python -m syncvito``."""
|
||||
|
||||
from syncvito.app import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Anwendungsstart und Prozesslebenszyklus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QLockFile, QStandardPaths
|
||||
from PyQt6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon
|
||||
|
||||
from syncvito.config_store import ConfigError, ConfigStore
|
||||
from syncvito.icons import app_icon
|
||||
from syncvito.main_window import MainWindow
|
||||
from syncvito.models import AppConfig
|
||||
from syncvito.theme import apply_theme
|
||||
from syncvito.version import __version__
|
||||
|
||||
__all__ = ["main"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Startet SyncVito als einzelne Windows-Instanz."""
|
||||
if "--version" in sys.argv:
|
||||
print(__version__)
|
||||
return 0
|
||||
if "--smoke-test" in sys.argv:
|
||||
return 0
|
||||
application = QApplication(sys.argv)
|
||||
application.setApplicationName("SyncVito")
|
||||
application.setOrganizationName("SyncVito")
|
||||
application.setQuitOnLastWindowClosed(False)
|
||||
application.setWindowIcon(app_icon())
|
||||
|
||||
lock_path = Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.TempLocation)) / "syncvito.lock"
|
||||
lock = QLockFile(str(lock_path))
|
||||
lock.setStaleLockTime(0)
|
||||
if not lock.tryLock(100):
|
||||
QMessageBox.information(None, "SyncVito läuft bereits", "SyncVito ist bereits im Infobereich aktiv.")
|
||||
return 0
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
QMessageBox.critical(None, "Infobereich nicht verfügbar", "SyncVito benötigt den Windows-Infobereich.")
|
||||
return 1
|
||||
|
||||
store = ConfigStore()
|
||||
try:
|
||||
config = store.load()
|
||||
except ConfigError as exc:
|
||||
QMessageBox.warning(None, "Konfiguration zurückgesetzt", str(exc))
|
||||
config = AppConfig()
|
||||
apply_theme(application, config.settings)
|
||||
window = MainWindow(config, store)
|
||||
if "--minimized" not in sys.argv:
|
||||
window.show()
|
||||
exit_code = application.exec()
|
||||
window.coordinator.stop()
|
||||
lock.unlock()
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Verwaltung des benutzerspezifischen Windows-Autostarts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["set_autostart"]
|
||||
|
||||
|
||||
def set_autostart(enabled: bool) -> None:
|
||||
"""Setzt oder entfernt SyncVito unter HKCU\\...\\Run."""
|
||||
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)
|
||||
else:
|
||||
with suppress(FileNotFoundError):
|
||||
winreg.DeleteValue(key, "SyncVito")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Atomare Speicherung der Anwendungskonfiguration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from syncvito.models import AppConfig
|
||||
|
||||
__all__ = ["ConfigError", "ConfigStore", "default_data_directory"]
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Fehler beim Laden oder Speichern der Konfiguration."""
|
||||
|
||||
|
||||
def default_data_directory() -> Path:
|
||||
"""Liefert das lokale, benutzerspezifische Datenverzeichnis."""
|
||||
root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
||||
return root / "SyncVito"
|
||||
|
||||
|
||||
class ConfigStore:
|
||||
"""Lädt und speichert JSON-Daten mit atomarem Dateiaustausch."""
|
||||
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = path or default_data_directory() / "config.json"
|
||||
|
||||
def load(self) -> AppConfig:
|
||||
"""Lädt die Konfiguration; beschädigte Dateien werden gesichert."""
|
||||
if not self.path.exists():
|
||||
return AppConfig()
|
||||
try:
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
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:
|
||||
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
|
||||
|
||||
def save(self, config: AppConfig) -> None:
|
||||
"""Speichert die Konfiguration atomar und UTF-8-kodiert."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.path.with_suffix(".json.tmp")
|
||||
try:
|
||||
temporary.write_text(
|
||||
json.dumps(config.to_dict(), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, self.path)
|
||||
except OSError as exc:
|
||||
with suppress(OSError):
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise ConfigError(f"Konfiguration konnte nicht gespeichert werden: {exc}") from exc
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Serielle Hintergrundausführung, Zeitplanung und Retry-Steuerung."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QObject, QRunnable, QThreadPool, QTimer, pyqtSignal
|
||||
|
||||
from syncvito.config_store import ConfigStore
|
||||
from syncvito.file_locks import is_file_locked
|
||||
from syncvito.models import AppConfig, SyncTask, TaskRuntimeState, TriggerMode
|
||||
from syncvito.realtime import RealtimeMonitor
|
||||
from syncvito.sync_engine import SyncEngine, SyncResult, UnavailablePathError
|
||||
|
||||
__all__ = ["SyncCoordinator", "derive_overall_status"]
|
||||
|
||||
|
||||
def derive_overall_status(
|
||||
states: list[TaskRuntimeState], globally_paused: bool, running: bool = False
|
||||
) -> tuple[str, str]:
|
||||
"""Leitet den Tray-Zustand deterministisch aus allen Aufgaben ab."""
|
||||
if TaskRuntimeState.ERROR in states:
|
||||
return "error", "Fehler"
|
||||
if TaskRuntimeState.WAITING_FOR_RELEASE in states:
|
||||
return "error", "Wartet auf Dateifreigabe"
|
||||
if globally_paused:
|
||||
return "paused", "Pausiert"
|
||||
if TaskRuntimeState.ACTIVE not in states:
|
||||
return "paused", "Inaktiv"
|
||||
return "active", "Synchronisiert" if running else "Bereit"
|
||||
|
||||
|
||||
class _WorkerSignals(QObject):
|
||||
progress = pyqtSignal(int, str)
|
||||
finished = pyqtSignal(str, object)
|
||||
failed = pyqtSignal(str, str, bool)
|
||||
|
||||
|
||||
class _SyncWorker(QRunnable):
|
||||
def __init__(self, task: SyncTask, engine: SyncEngine) -> None:
|
||||
super().__init__()
|
||||
self.task = task
|
||||
self.engine = engine
|
||||
self.signals = _WorkerSignals()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
result = self.engine.synchronize(self.task, self.signals.progress.emit)
|
||||
except UnavailablePathError as exc:
|
||||
self.signals.failed.emit(self.task.id, str(exc), True)
|
||||
except Exception as exc: # noqa: BLE001 - Worker-Grenze muss UI-Absturz verhindern.
|
||||
detail = f"{exc}\n{traceback.format_exc(limit=4)}"
|
||||
self.signals.failed.emit(self.task.id, detail, False)
|
||||
else:
|
||||
self.signals.finished.emit(self.task.id, result)
|
||||
|
||||
|
||||
class _RealtimeBridge(QObject):
|
||||
changed = pyqtSignal(str)
|
||||
|
||||
|
||||
class SyncCoordinator(QObject):
|
||||
"""Koordiniert genau einen sichtbaren Synchronisationslauf zur Zeit."""
|
||||
|
||||
progress_changed = pyqtSignal(int, str)
|
||||
log_created = pyqtSignal(str, str, str)
|
||||
overall_status_changed = pyqtSignal(str, str)
|
||||
task_state_changed = pyqtSignal(str, object)
|
||||
lock_changed = pyqtSignal(str)
|
||||
idle_changed = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, config: AppConfig, config_store: ConfigStore, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.config = config
|
||||
self.config_store = config_store
|
||||
self.engine = SyncEngine()
|
||||
self.pool = QThreadPool.globalInstance()
|
||||
self.pool.setMaxThreadCount(max(2, self.pool.maxThreadCount()))
|
||||
self._queue: deque[str] = deque()
|
||||
self._running_task_id: str | None = None
|
||||
self._retry_tasks: dict[str, list[Path]] = {}
|
||||
self._lock_messages: dict[str, str] = {}
|
||||
self._task_states: dict[str, TaskRuntimeState] = {
|
||||
task.id: TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED for task in config.tasks
|
||||
}
|
||||
self._last_realtime_event: dict[str, datetime] = {}
|
||||
self._bridge = _RealtimeBridge()
|
||||
self._bridge.changed.connect(self._on_realtime_event)
|
||||
self.monitor = RealtimeMonitor(self._bridge.changed.emit)
|
||||
|
||||
self._schedule_timer = QTimer(self)
|
||||
self._schedule_timer.setInterval(30_000)
|
||||
self._schedule_timer.timeout.connect(self._check_intervals)
|
||||
self._schedule_timer.start()
|
||||
self._retry_timer = QTimer(self)
|
||||
self._retry_timer.setInterval(10_000)
|
||||
self._retry_timer.timeout.connect(self._check_retries)
|
||||
self._retry_timer.start()
|
||||
self.rebuild_monitors()
|
||||
|
||||
def task_state(self, task_id: str) -> TaskRuntimeState:
|
||||
"""Liefert den aktuellen Engine-Zustand einer Aufgabe."""
|
||||
return self._task_states.get(task_id, TaskRuntimeState.PAUSED)
|
||||
|
||||
def current_overall_status(self) -> tuple[str, str]:
|
||||
"""Liefert den aktuell abgeleiteten Tray-Zustand."""
|
||||
return derive_overall_status(list(self._task_states.values()), self.config.settings.paused, self.is_running)
|
||||
|
||||
def refresh_task_states(self) -> None:
|
||||
"""Gleicht Laufzeitzustände nach Änderungen an der Aufgabenliste ab."""
|
||||
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._retry_tasks = {task_id: paths for task_id, paths in self._retry_tasks.items() if task_id in known_ids}
|
||||
self._lock_messages = {
|
||||
task_id: message for task_id, message in self._lock_messages.items() if task_id in known_ids
|
||||
}
|
||||
for task in self.config.tasks:
|
||||
desired = TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED
|
||||
current = self._task_states.get(task.id)
|
||||
if current is None or current is TaskRuntimeState.PAUSED or not task.enabled:
|
||||
self._set_task_state(task.id, desired)
|
||||
if not task.enabled:
|
||||
self._retry_tasks.pop(task.id, None)
|
||||
self._lock_messages.pop(task.id, None)
|
||||
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._emit_overall_status()
|
||||
self._emit_lock_summary()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Zeigt an, ob momentan ein Worker aktiv ist."""
|
||||
return self._running_task_id is not None
|
||||
|
||||
def enqueue(self, task_id: str) -> None:
|
||||
"""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)
|
||||
if task is None or not task.enabled:
|
||||
return
|
||||
if task_id != self._running_task_id and task_id not in self._queue:
|
||||
self._queue.append(task_id)
|
||||
self._start_next()
|
||||
|
||||
def enqueue_all(self) -> None:
|
||||
"""Stellt alle aktiven Aufgaben zur manuellen Ausführung an."""
|
||||
for task in self.config.tasks:
|
||||
if task.enabled:
|
||||
self.enqueue(task.id)
|
||||
|
||||
def set_paused(self, paused: bool) -> None:
|
||||
"""Ändert den globalen Pausenzustand."""
|
||||
self.config.settings.paused = paused
|
||||
self.config_store.save(self.config)
|
||||
if paused:
|
||||
self.log_created.emit("Status", "Hinweis", "Alle automatischen Auslöser sind pausiert.")
|
||||
else:
|
||||
self.log_created.emit("Status", "Erfolg", "Synchronisation fortgesetzt.")
|
||||
self._start_next()
|
||||
self._emit_overall_status()
|
||||
|
||||
def rebuild_monitors(self) -> None:
|
||||
"""Aktualisiert alle Dateisystemüberwachungen."""
|
||||
errors = self.monitor.rebuild(self.config.tasks)
|
||||
for task_id, message in errors:
|
||||
self._retry_tasks[task_id] = []
|
||||
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||
self.log_created.emit("Echtzeit", "Fehler", message)
|
||||
self._emit_overall_status()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stoppt die Dateisystemüberwachung beim Beenden."""
|
||||
self.monitor.stop()
|
||||
|
||||
def _start_next(self) -> None:
|
||||
if self._running_task_id is not None or self.config.settings.paused or not self._queue:
|
||||
return
|
||||
task_id = self._queue.popleft()
|
||||
task = self._task(task_id)
|
||||
if task is None or not task.enabled:
|
||||
self._start_next()
|
||||
return
|
||||
self._running_task_id = task_id
|
||||
self.idle_changed.emit(False)
|
||||
self._emit_overall_status()
|
||||
self.log_created.emit(task.name, "Start", "Synchronisation gestartet.")
|
||||
worker = _SyncWorker(task, self.engine)
|
||||
worker.signals.progress.connect(self.progress_changed)
|
||||
worker.signals.finished.connect(self._on_finished)
|
||||
worker.signals.failed.connect(self._on_failed)
|
||||
self.pool.start(worker)
|
||||
|
||||
def _on_finished(self, task_id: str, result: SyncResult) -> None:
|
||||
task = self._task(task_id)
|
||||
if task is None:
|
||||
self._complete_current()
|
||||
return
|
||||
task.last_run = datetime.now().isoformat(timespec="seconds")
|
||||
self.config_store.save(self.config)
|
||||
if result.blocked:
|
||||
paths = [blocked.path for blocked in result.blocked]
|
||||
self._retry_tasks[task_id] = paths
|
||||
descriptions: list[str] = []
|
||||
for blocked in result.blocked:
|
||||
names = ", ".join(f"{process.name} (PID {process.pid})" for process in blocked.processes)
|
||||
descriptions.append(f"{blocked.path.name}: {names or 'sperrender Prozess nicht ermittelbar'}")
|
||||
message = "Zurückgestellt – " + "; ".join(descriptions)
|
||||
self._lock_messages[task_id] = message
|
||||
self._emit_lock_summary()
|
||||
self.log_created.emit(task.name, "Blockiert", message)
|
||||
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:
|
||||
self._retry_tasks.pop(task_id, None)
|
||||
self._lock_messages.pop(task_id, None)
|
||||
self._emit_lock_summary()
|
||||
summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien"
|
||||
self.log_created.emit(task.name, "Erfolg", summary)
|
||||
self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
|
||||
self._complete_current()
|
||||
|
||||
def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None:
|
||||
task = self._task(task_id)
|
||||
name = task.name if task else "Aufgabe"
|
||||
self._retry_tasks[task_id] = []
|
||||
self._lock_messages.pop(task_id, None)
|
||||
self._emit_lock_summary()
|
||||
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||
self.log_created.emit(name, "Fehler", detail)
|
||||
self._complete_current()
|
||||
|
||||
def _complete_current(self) -> None:
|
||||
self._running_task_id = None
|
||||
self.progress_changed.emit(100, "Abgeschlossen")
|
||||
if not self._queue:
|
||||
self.idle_changed.emit(True)
|
||||
self._emit_overall_status()
|
||||
QTimer.singleShot(200, self._start_next)
|
||||
|
||||
def _check_intervals(self) -> None:
|
||||
if self.config.settings.paused:
|
||||
return
|
||||
now = datetime.now()
|
||||
for task in self.config.tasks:
|
||||
if not task.enabled or task.trigger is not TriggerMode.INTERVAL:
|
||||
continue
|
||||
try:
|
||||
last_run = datetime.fromisoformat(task.last_run) if task.last_run else datetime.min
|
||||
except ValueError:
|
||||
last_run = datetime.min
|
||||
if now - last_run >= timedelta(minutes=task.interval_minutes):
|
||||
self.enqueue(task.id)
|
||||
|
||||
def _on_realtime_event(self, task_id: str) -> None:
|
||||
now = datetime.now()
|
||||
last = self._last_realtime_event.get(task_id)
|
||||
self._last_realtime_event[task_id] = now
|
||||
if last and now - last < timedelta(seconds=2):
|
||||
return
|
||||
QTimer.singleShot(1200, lambda: self.enqueue(task_id))
|
||||
|
||||
def _check_retries(self) -> None:
|
||||
if self.config.settings.paused or self.is_running:
|
||||
return
|
||||
for task_id, paths in list(self._retry_tasks.items()):
|
||||
if paths and any(is_file_locked(path) for path in paths):
|
||||
continue
|
||||
self.log_created.emit(
|
||||
"Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft."
|
||||
)
|
||||
self.enqueue(task_id)
|
||||
break
|
||||
|
||||
def _task(self, task_id: str) -> SyncTask | None:
|
||||
return next((task for task in self.config.tasks if task.id == task_id), None)
|
||||
|
||||
def _set_task_state(self, task_id: str, state: TaskRuntimeState) -> None:
|
||||
if self._task_states.get(task_id) == state:
|
||||
return
|
||||
self._task_states[task_id] = state
|
||||
self.task_state_changed.emit(task_id, state)
|
||||
self._emit_overall_status()
|
||||
|
||||
def _emit_overall_status(self) -> None:
|
||||
status, text = self.current_overall_status()
|
||||
self.overall_status_changed.emit(status, text)
|
||||
|
||||
def _emit_lock_summary(self) -> None:
|
||||
self.lock_changed.emit("\n".join(self._lock_messages.values()))
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Dialoge zur Aufgabenbearbeitung und Unterauswahl."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QDir
|
||||
from PyQt6.QtGui import QFileSystemModel
|
||||
from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QTabWidget,
|
||||
QTreeView,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask, TriggerMode
|
||||
|
||||
__all__ = ["TaskDialog"]
|
||||
|
||||
|
||||
class _PathField(QWidget):
|
||||
def __init__(self, title: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.edit = QLineEdit()
|
||||
self.edit.setPlaceholderText(title)
|
||||
self.button = QPushButton("Auswählen …")
|
||||
self.button.clicked.connect(self._browse)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
layout.addWidget(self.edit, 1)
|
||||
layout.addWidget(self.button)
|
||||
|
||||
def _browse(self) -> None:
|
||||
selected = QFileDialog.getExistingDirectory(self, "Ordner auswählen", self.edit.text())
|
||||
if selected:
|
||||
self.edit.setText(QDir.toNativeSeparators(selected))
|
||||
|
||||
|
||||
class _SelectionDialog(QDialog):
|
||||
def __init__(self, root: Path, selected: list[str], parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Dateien und Ordner auswählen")
|
||||
self.resize(820, 560)
|
||||
self.root = root
|
||||
self.model = QFileSystemModel(self)
|
||||
self.model.setRootPath(str(root))
|
||||
self.model.setFilter(QDir.Filter.AllEntries | QDir.Filter.NoDotAndDotDot)
|
||||
self.tree = QTreeView()
|
||||
self.tree.setModel(self.model)
|
||||
self.tree.setRootIndex(self.model.index(str(root)))
|
||||
self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self.tree.setAlternatingRowColors(True)
|
||||
self.tree.setSortingEnabled(True)
|
||||
for column in (1, 2, 3):
|
||||
self.tree.hideColumn(column)
|
||||
|
||||
self.chosen = QListWidget()
|
||||
self.chosen.addItems(selected)
|
||||
add_button = QPushButton("Markierte übernehmen")
|
||||
remove_button = QPushButton("Aus Liste entfernen")
|
||||
add_button.clicked.connect(self._add_marked)
|
||||
remove_button.clicked.connect(self._remove_chosen)
|
||||
|
||||
hint = QLabel(
|
||||
"Ohne Unterauswahl wird der gesamte Quellordner synchronisiert. "
|
||||
"Ordnerauswahlen schließen deren gesamten Inhalt ein."
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
hint.setProperty("muted", True)
|
||||
|
||||
columns = QHBoxLayout()
|
||||
tree_column = QVBoxLayout()
|
||||
tree_column.addWidget(QLabel("Quellordner"))
|
||||
tree_column.addWidget(self.tree, 1)
|
||||
tree_column.addWidget(add_button)
|
||||
chosen_column = QVBoxLayout()
|
||||
chosen_column.addWidget(QLabel("Aktive Unterauswahl"))
|
||||
chosen_column.addWidget(self.chosen, 1)
|
||||
chosen_column.addWidget(remove_button)
|
||||
columns.addLayout(tree_column, 3)
|
||||
columns.addLayout(chosen_column, 2)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.setSpacing(12)
|
||||
layout.addWidget(hint)
|
||||
layout.addLayout(columns, 1)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def selected_paths(self) -> list[str]:
|
||||
"""Liefert die gespeicherten relativen Pfade."""
|
||||
return sorted({self.chosen.item(index).text() for index in range(self.chosen.count())})
|
||||
|
||||
def _add_marked(self) -> None:
|
||||
existing = set(self.selected_paths())
|
||||
for index in self.tree.selectionModel().selectedRows(0):
|
||||
path = Path(self.model.filePath(index))
|
||||
try:
|
||||
relative = str(path.relative_to(self.root))
|
||||
except ValueError:
|
||||
continue
|
||||
if relative not in existing and relative != ".":
|
||||
self.chosen.addItem(relative)
|
||||
existing.add(relative)
|
||||
|
||||
def _remove_chosen(self) -> None:
|
||||
for item in self.chosen.selectedItems():
|
||||
self.chosen.takeItem(self.chosen.row(item))
|
||||
|
||||
|
||||
class TaskDialog(QDialog):
|
||||
"""Vollständiger Editor für eine Synchronisationsaufgabe."""
|
||||
|
||||
def __init__(self, task: SyncTask | None = None, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._original = task
|
||||
self._selected_paths = list(task.selected_paths) if task else []
|
||||
self.setWindowTitle("Aufgabe bearbeiten" if task else "Neue Aufgabe")
|
||||
self.setMinimumWidth(690)
|
||||
self.resize(720, 610)
|
||||
|
||||
self.name_edit = QLineEdit(task.name if task else "")
|
||||
self.name_edit.setPlaceholderText("z. B. Projektarchiv")
|
||||
self.source_field = _PathField("Lokalen Quellordner wählen")
|
||||
self.target_field = _PathField("NAS- oder Zielordner wählen")
|
||||
if task:
|
||||
self.source_field.edit.setText(task.source)
|
||||
self.target_field.edit.setText(task.target)
|
||||
|
||||
self.direction_combo = QComboBox()
|
||||
self.direction_combo.addItem("Einweg – Quelle → Ziel", SyncDirection.ONE_WAY)
|
||||
self.direction_combo.addItem("Bidirektional – beide Seiten", SyncDirection.BIDIRECTIONAL)
|
||||
self.trigger_combo = QComboBox()
|
||||
self.trigger_combo.addItem("Manuell", TriggerMode.MANUAL)
|
||||
self.trigger_combo.addItem("Echtzeit überwachen", TriggerMode.REALTIME)
|
||||
self.trigger_combo.addItem("Zeitgesteuert", TriggerMode.INTERVAL)
|
||||
self.interval_spin = QSpinBox()
|
||||
self.interval_spin.setRange(1, 10080)
|
||||
self.interval_spin.setSuffix(" Minuten")
|
||||
self.interval_spin.setValue(task.interval_minutes if task else 30)
|
||||
self.delete_combo = QComboBox()
|
||||
self.delete_combo.addItem("Nie automatisch löschen", DeleteMode.KEEP)
|
||||
self.delete_combo.addItem("Löschungen spiegeln", DeleteMode.MIRROR)
|
||||
self.conflict_combo = QComboBox()
|
||||
self.conflict_combo.addItem("Neuerer Zeitstempel gewinnt", ConflictMode.NEWER_WINS)
|
||||
self.conflict_combo.addItem("Unterlegene Version als _conflict-Kopie behalten", ConflictMode.CONFLICT_COPY)
|
||||
self.enabled_check = QCheckBox("Aufgabe ist aktiv")
|
||||
self.enabled_check.setChecked(task.enabled if task else True)
|
||||
|
||||
if task:
|
||||
self._set_combo(self.direction_combo, task.direction)
|
||||
self._set_combo(self.trigger_combo, task.trigger)
|
||||
self._set_combo(self.delete_combo, task.delete_mode)
|
||||
self._set_combo(self.conflict_combo, task.conflict_mode)
|
||||
|
||||
self.selection_label = QLabel()
|
||||
self.selection_label.setProperty("muted", True)
|
||||
selection_button = QPushButton("Unterauswahl festlegen …")
|
||||
selection_button.clicked.connect(self._choose_selection)
|
||||
selection_row = QHBoxLayout()
|
||||
selection_row.addWidget(self.selection_label, 1)
|
||||
selection_row.addWidget(selection_button)
|
||||
|
||||
base_page = QWidget()
|
||||
base_form = QFormLayout(base_page)
|
||||
base_form.setContentsMargins(18, 18, 18, 18)
|
||||
base_form.setHorizontalSpacing(18)
|
||||
base_form.setVerticalSpacing(14)
|
||||
base_form.addRow("Name", self.name_edit)
|
||||
base_form.addRow("Quellordner", self.source_field)
|
||||
base_form.addRow("Zielordner", self.target_field)
|
||||
base_form.addRow("Richtung", self.direction_combo)
|
||||
base_form.addRow("Auslösung", self.trigger_combo)
|
||||
base_form.addRow("Intervall", self.interval_spin)
|
||||
base_form.addRow("Umfang", selection_row)
|
||||
base_form.addRow("", self.enabled_check)
|
||||
|
||||
behavior_page = QWidget()
|
||||
behavior_layout = QFormLayout(behavior_page)
|
||||
behavior_layout.setContentsMargins(18, 18, 18, 18)
|
||||
behavior_layout.setHorizontalSpacing(18)
|
||||
behavior_layout.setVerticalSpacing(14)
|
||||
behavior_layout.addRow("Löschverhalten", self.delete_combo)
|
||||
behavior_layout.addRow("Dateikonflikte", self.conflict_combo)
|
||||
warning = QLabel(
|
||||
"Beim Spiegeln von Löschungen wird der letzte erfolgreiche Abgleich als Referenz verwendet. "
|
||||
"So werden neu hinzugefügte Dateien nicht versehentlich als Löschung interpretiert."
|
||||
)
|
||||
warning.setWordWrap(True)
|
||||
warning.setProperty("muted", True)
|
||||
behavior_layout.addRow("", warning)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.addTab(base_page, "Ordner und Zeitplan")
|
||||
tabs.addTab(behavior_page, "Konflikte und Löschungen")
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
|
||||
buttons.button(QDialogButtonBox.StandardButton.Save).setText("Speichern")
|
||||
buttons.button(QDialogButtonBox.StandardButton.Cancel).setText("Abbrechen")
|
||||
buttons.accepted.connect(self._validate_and_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(20, 20, 20, 20)
|
||||
layout.addWidget(tabs, 1)
|
||||
layout.addWidget(buttons)
|
||||
self.trigger_combo.currentIndexChanged.connect(self._update_visibility)
|
||||
self.direction_combo.currentIndexChanged.connect(self._update_visibility)
|
||||
self.source_field.edit.textChanged.connect(self._update_selection_label)
|
||||
self._update_visibility()
|
||||
self._update_selection_label()
|
||||
|
||||
def task(self) -> SyncTask:
|
||||
"""Erzeugt das validierte Modell aus den Formulardaten."""
|
||||
task = SyncTask(
|
||||
id=self._original.id if self._original else SyncTask("", "", "").id,
|
||||
name=self.name_edit.text(),
|
||||
source=self.source_field.edit.text(),
|
||||
target=self.target_field.edit.text(),
|
||||
direction=self.direction_combo.currentData(),
|
||||
trigger=self.trigger_combo.currentData(),
|
||||
delete_mode=self.delete_combo.currentData(),
|
||||
conflict_mode=self.conflict_combo.currentData(),
|
||||
interval_minutes=self.interval_spin.value(),
|
||||
selected_paths=self._selected_paths,
|
||||
enabled=self.enabled_check.isChecked(),
|
||||
last_run=self._original.last_run if self._original else None,
|
||||
)
|
||||
task.validate()
|
||||
return task
|
||||
|
||||
def _choose_selection(self) -> None:
|
||||
root = Path(self.source_field.edit.text())
|
||||
if not root.is_dir():
|
||||
QMessageBox.warning(self, "Quellordner fehlt", "Bitte zuerst einen erreichbaren Quellordner auswählen.")
|
||||
return
|
||||
dialog = _SelectionDialog(root, self._selected_paths, self)
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self._selected_paths = dialog.selected_paths()
|
||||
self._update_selection_label()
|
||||
|
||||
def _validate_and_accept(self) -> None:
|
||||
try:
|
||||
self.task()
|
||||
except ValueError as exc:
|
||||
QMessageBox.warning(self, "Eingaben prüfen", str(exc))
|
||||
return
|
||||
self.accept()
|
||||
|
||||
def _update_visibility(self) -> None:
|
||||
self.interval_spin.setEnabled(self.trigger_combo.currentData() is TriggerMode.INTERVAL)
|
||||
self.conflict_combo.setEnabled(self.direction_combo.currentData() is SyncDirection.BIDIRECTIONAL)
|
||||
|
||||
def _update_selection_label(self) -> None:
|
||||
if self._selected_paths:
|
||||
self.selection_label.setText(f"{len(self._selected_paths)} Einträge ausgewählt")
|
||||
else:
|
||||
self.selection_label.setText("Gesamter Quellordner")
|
||||
|
||||
@staticmethod
|
||||
def _set_combo(combo: QComboBox, value: object) -> None:
|
||||
index = combo.findData(value)
|
||||
if index >= 0:
|
||||
combo.setCurrentIndex(index)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Ermittlung von Prozessen, die Dateien unter Windows sperren."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
from ctypes import wintypes
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["LockingProcess", "find_locking_processes", "is_file_locked"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LockingProcess:
|
||||
"""Ein vom Windows Restart Manager gemeldeter Prozess."""
|
||||
|
||||
pid: int
|
||||
name: str
|
||||
|
||||
|
||||
def is_file_locked(path: Path) -> bool:
|
||||
"""Prüft eine vorhandene Datei durch exklusives Öffnen ohne Schreibzugriff."""
|
||||
if not path.exists() or os.name != "nt":
|
||||
return False
|
||||
handle = ctypes.windll.kernel32.CreateFileW(
|
||||
str(path),
|
||||
0x80000000,
|
||||
0,
|
||||
None,
|
||||
3,
|
||||
0x80,
|
||||
None,
|
||||
)
|
||||
invalid_handle = ctypes.c_void_p(-1).value
|
||||
if handle == invalid_handle:
|
||||
return ctypes.get_last_error() in {5, 32, 33}
|
||||
ctypes.windll.kernel32.CloseHandle(handle)
|
||||
return False
|
||||
|
||||
|
||||
def find_locking_processes(path: Path) -> list[LockingProcess]:
|
||||
"""Liefert sperrende Prozesse über die Windows-Restart-Manager-API."""
|
||||
if os.name != "nt" or not path.exists():
|
||||
return []
|
||||
try:
|
||||
return _restart_manager_processes(path)
|
||||
except (OSError, AttributeError, ctypes.ArgumentError):
|
||||
return []
|
||||
|
||||
|
||||
def _restart_manager_processes(path: Path) -> list[LockingProcess]:
|
||||
rstrtmgr = ctypes.WinDLL("Rstrtmgr")
|
||||
session_handle = wintypes.DWORD()
|
||||
session_key = ctypes.create_unicode_buffer(33)
|
||||
if rstrtmgr.RmStartSession(ctypes.byref(session_handle), 0, session_key) != 0:
|
||||
return []
|
||||
try:
|
||||
resources = (wintypes.LPCWSTR * 1)(str(path))
|
||||
if rstrtmgr.RmRegisterResources(session_handle, 1, resources, 0, None, 0, None) != 0:
|
||||
return []
|
||||
needed = wintypes.UINT(0)
|
||||
count = wintypes.UINT(0)
|
||||
reboot_reasons = wintypes.DWORD(0)
|
||||
result = rstrtmgr.RmGetList(
|
||||
session_handle,
|
||||
ctypes.byref(needed),
|
||||
ctypes.byref(count),
|
||||
None,
|
||||
ctypes.byref(reboot_reasons),
|
||||
)
|
||||
if result not in {0, 234} or needed.value == 0:
|
||||
return []
|
||||
process_info_type = _rm_process_info_type()
|
||||
process_array = (process_info_type * needed.value)()
|
||||
count.value = needed.value
|
||||
result = rstrtmgr.RmGetList(
|
||||
session_handle,
|
||||
ctypes.byref(needed),
|
||||
ctypes.byref(count),
|
||||
process_array,
|
||||
ctypes.byref(reboot_reasons),
|
||||
)
|
||||
if result != 0:
|
||||
return []
|
||||
return [
|
||||
LockingProcess(
|
||||
pid=int(item.Process.dwProcessId), name=item.strAppName or f"Prozess {item.Process.dwProcessId}"
|
||||
)
|
||||
for item in process_array[: count.value]
|
||||
]
|
||||
finally:
|
||||
rstrtmgr.RmEndSession(session_handle)
|
||||
|
||||
|
||||
def _rm_process_info_type() -> type[ctypes.Structure]:
|
||||
class FileTime(ctypes.Structure):
|
||||
_fields_ = [("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD)]
|
||||
|
||||
class UniqueProcess(ctypes.Structure):
|
||||
_fields_ = [("dwProcessId", wintypes.DWORD), ("ProcessStartTime", FileTime)]
|
||||
|
||||
class ProcessInfo(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("Process", UniqueProcess),
|
||||
("strAppName", wintypes.WCHAR * 256),
|
||||
("strServiceShortName", wintypes.WCHAR * 64),
|
||||
("ApplicationType", wintypes.UINT),
|
||||
("AppStatus", wintypes.ULONG),
|
||||
("TSSessionId", wintypes.DWORD),
|
||||
("bRestartable", wintypes.BOOL),
|
||||
]
|
||||
|
||||
return ProcessInfo
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Skalierbare, programmatisch gezeichnete Status- und App-Symbole."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QPointF, QRectF, Qt
|
||||
from PyQt6.QtGui import QColor, QFont, QIcon, QPainter, QPainterPath, QPen, QPixmap
|
||||
|
||||
__all__ = ["app_icon", "status_icon"]
|
||||
|
||||
|
||||
def app_icon(size: int = 64) -> QIcon:
|
||||
"""Erzeugt das SyncVito-Symbol ohne plattformabhängige Emoji-Glyphen."""
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#2878FF"))
|
||||
painter.drawRoundedRect(QRectF(2, 2, size - 4, size - 4), size * 0.22, size * 0.22)
|
||||
pen = QPen(QColor("#FFFFFF"), max(2, size * 0.07), Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
path = QPainterPath(QPointF(size * 0.25, size * 0.38))
|
||||
path.cubicTo(size * 0.38, size * 0.18, size * 0.68, size * 0.20, size * 0.74, size * 0.39)
|
||||
path.lineTo(size * 0.67, size * 0.34)
|
||||
painter.drawPath(path)
|
||||
path = QPainterPath(QPointF(size * 0.75, size * 0.62))
|
||||
path.cubicTo(size * 0.62, size * 0.82, size * 0.32, size * 0.80, size * 0.26, size * 0.61)
|
||||
path.lineTo(size * 0.33, size * 0.66)
|
||||
painter.drawPath(path)
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def status_icon(status: str, size: int = 64) -> QIcon:
|
||||
"""Erzeugt ein farbiges Tray-Symbol für aktiv, pausiert oder Fehler."""
|
||||
colors = {"active": "#35C77A", "paused": "#E0525D", "error": "#F0B84B"}
|
||||
pixmap = app_icon(size).pixmap(size, size)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
diameter = size * 0.34
|
||||
badge = QRectF(size - diameter - 1, size - diameter - 1, diameter, diameter)
|
||||
painter.setPen(QPen(QColor("#0B0F14"), max(2, size * 0.04)))
|
||||
painter.setBrush(QColor(colors.get(status, colors["active"])))
|
||||
painter.drawEllipse(badge)
|
||||
if status == "error":
|
||||
painter.setPen(QColor("#111820"))
|
||||
font = QFont("Segoe UI", max(8, round(size * 0.20)), QFont.Weight.Bold)
|
||||
painter.setFont(font)
|
||||
painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, "!")
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
@@ -0,0 +1,670 @@
|
||||
"""Hauptfenster mit Aufgaben-, Protokoll- und Einstellungsansicht."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtCore import QEvent, Qt, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QAction, QCloseEvent, QFontDatabase
|
||||
from PyQt6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QStackedWidget,
|
||||
QSystemTrayIcon,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from syncvito.autostart import set_autostart
|
||||
from syncvito.config_store import ConfigError, ConfigStore
|
||||
from syncvito.coordinator import SyncCoordinator
|
||||
from syncvito.dialogs import TaskDialog
|
||||
from syncvito.icons import app_icon, status_icon
|
||||
from syncvito.models import AppConfig, SyncDirection, SyncTask, TaskRuntimeState, TriggerMode
|
||||
from syncvito.state_store import StateStore
|
||||
from syncvito.theme import apply_theme
|
||||
from syncvito.update_controller import UpdateController
|
||||
from syncvito.update_dialog import UpdateDialog
|
||||
from syncvito.updater import UpdateInfo
|
||||
from syncvito.version import __version__
|
||||
|
||||
__all__ = ["MainWindow"]
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Zentrale SyncVito-Benutzeroberfläche."""
|
||||
|
||||
quit_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, config: AppConfig, config_store: ConfigStore) -> None:
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.config_store = config_store
|
||||
self._allow_close = False
|
||||
self.setWindowTitle("SyncVito")
|
||||
self.setWindowIcon(app_icon())
|
||||
self.setMinimumSize(980, 680)
|
||||
self.resize(1220, 800)
|
||||
|
||||
self.coordinator = SyncCoordinator(config, config_store, self)
|
||||
self.updater = UpdateController(__version__, self)
|
||||
self._build_ui()
|
||||
self._build_tray()
|
||||
self._connect_coordinator()
|
||||
self._refresh_tasks()
|
||||
self._set_global_status(*self.coordinator.current_overall_status())
|
||||
QTimer.singleShot(3000, self._automatic_update_check)
|
||||
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:
|
||||
QTimer.singleShot(900, self._show_update_completed)
|
||||
|
||||
def show_from_tray(self) -> None:
|
||||
"""Stellt das Fenster aus dem Tray wieder her."""
|
||||
self.showNormal()
|
||||
self.activateWindow()
|
||||
self.raise_()
|
||||
|
||||
def request_quit(self) -> None:
|
||||
"""Beendet Observer und Anwendung explizit über das Tray-Menü."""
|
||||
self._allow_close = True
|
||||
self.coordinator.stop()
|
||||
self.tray.hide()
|
||||
QApplication.instance().quit()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt API
|
||||
if self._allow_close:
|
||||
event.accept()
|
||||
return
|
||||
event.ignore()
|
||||
self.hide()
|
||||
self.tray.showMessage(
|
||||
"SyncVito läuft weiter",
|
||||
"Das Fenster wurde in den Infobereich verschoben.",
|
||||
QSystemTrayIcon.MessageIcon.Information,
|
||||
2500,
|
||||
)
|
||||
|
||||
def changeEvent(self, event: QEvent) -> None: # noqa: N802 - Qt API
|
||||
super().changeEvent(event)
|
||||
if event.type() == QEvent.Type.WindowStateChange and self.isMinimized():
|
||||
QApplication.processEvents()
|
||||
self.hide()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
root = QWidget()
|
||||
self.setCentralWidget(root)
|
||||
root_layout = QHBoxLayout(root)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.setSpacing(0)
|
||||
root_layout.addWidget(self._build_sidebar())
|
||||
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout(right)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.setSpacing(0)
|
||||
right_layout.addWidget(self._build_header())
|
||||
self.stack = QStackedWidget()
|
||||
self.tasks_page = self._build_tasks_page()
|
||||
self.log_page = self._build_log_page()
|
||||
self.settings_page = self._build_settings_page()
|
||||
self.stack.addWidget(self.tasks_page)
|
||||
self.stack.addWidget(self.log_page)
|
||||
self.stack.addWidget(self.settings_page)
|
||||
right_layout.addWidget(self.stack, 1)
|
||||
right_layout.addWidget(self._build_progress_dock())
|
||||
root_layout.addWidget(right, 1)
|
||||
|
||||
def _build_sidebar(self) -> QWidget:
|
||||
sidebar = QWidget()
|
||||
sidebar.setObjectName("Sidebar")
|
||||
sidebar.setFixedWidth(226)
|
||||
layout = QVBoxLayout(sidebar)
|
||||
layout.setContentsMargins(18, 22, 18, 18)
|
||||
layout.setSpacing(8)
|
||||
|
||||
eyebrow = QLabel("DATEISYNCHRONISATION")
|
||||
eyebrow.setObjectName("Eyebrow")
|
||||
brand = QLabel("SyncVito")
|
||||
brand.setObjectName("Brand")
|
||||
layout.addWidget(eyebrow)
|
||||
layout.addWidget(brand)
|
||||
layout.addSpacing(28)
|
||||
|
||||
self.nav_buttons: list[QPushButton] = []
|
||||
for title, index in (("Aufgaben", 0), ("Protokoll", 1), ("Einstellungen", 2)):
|
||||
button = QPushButton(title)
|
||||
button.setCheckable(True)
|
||||
button.setProperty("nav", True)
|
||||
button.setMinimumHeight(42)
|
||||
button.clicked.connect(lambda checked=False, page=index: self._select_page(page))
|
||||
layout.addWidget(button)
|
||||
self.nav_buttons.append(button)
|
||||
self.nav_buttons[0].setChecked(True)
|
||||
layout.addStretch(1)
|
||||
|
||||
version = QLabel("Version 1.0.0\nLokale Konfiguration")
|
||||
version.setProperty("muted", True)
|
||||
layout.addWidget(version)
|
||||
return sidebar
|
||||
|
||||
def _build_header(self) -> QWidget:
|
||||
header = QWidget()
|
||||
header.setObjectName("Header")
|
||||
header.setFixedHeight(82)
|
||||
layout = QHBoxLayout(header)
|
||||
layout.setContentsMargins(28, 0, 28, 0)
|
||||
title_area = QVBoxLayout()
|
||||
title_area.setSpacing(2)
|
||||
self.page_title = QLabel("Synchronisationsaufgaben")
|
||||
self.page_title.setObjectName("PageTitle")
|
||||
self.page_subtitle = QLabel("Lokale Ordner und NAS-Ziele zuverlässig im gleichen Stand halten")
|
||||
self.page_subtitle.setObjectName("PageSubtitle")
|
||||
title_area.addWidget(self.page_title)
|
||||
title_area.addWidget(self.page_subtitle)
|
||||
layout.addLayout(title_area)
|
||||
layout.addStretch(1)
|
||||
self.status_label = QLabel("Bereit")
|
||||
self.status_label.setObjectName("StatusPill")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
layout.addWidget(self.status_label, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
self.sync_all_button = QPushButton("Alle jetzt synchronisieren")
|
||||
self.sync_all_button.setProperty("primary", True)
|
||||
self.sync_all_button.clicked.connect(self.coordinator.enqueue_all)
|
||||
layout.addWidget(self.sync_all_button, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
return header
|
||||
|
||||
def _build_tasks_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(28, 24, 28, 24)
|
||||
layout.setSpacing(14)
|
||||
toolbar = QHBoxLayout()
|
||||
add_button = QPushButton("Neue Aufgabe")
|
||||
add_button.setProperty("primary", True)
|
||||
edit_button = QPushButton("Bearbeiten")
|
||||
delete_button = QPushButton("Löschen")
|
||||
delete_button.setProperty("danger", True)
|
||||
self.sync_selected_button = QPushButton("Auswahl synchronisieren")
|
||||
add_button.clicked.connect(self._add_task)
|
||||
edit_button.clicked.connect(self._edit_task)
|
||||
delete_button.clicked.connect(self._delete_task)
|
||||
self.sync_selected_button.clicked.connect(self._sync_selected)
|
||||
toolbar.addWidget(add_button)
|
||||
toolbar.addWidget(edit_button)
|
||||
toolbar.addWidget(delete_button)
|
||||
toolbar.addStretch(1)
|
||||
toolbar.addWidget(self.sync_selected_button)
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
self.task_table = QTableWidget(0, 5)
|
||||
self.task_table.setHorizontalHeaderLabels(["Aufgabe", "Ordnerpaar", "Richtung", "Auslösung", "Status"])
|
||||
self.task_table.setAlternatingRowColors(True)
|
||||
self.task_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.task_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.task_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.task_table.verticalHeader().setVisible(False)
|
||||
self.task_table.verticalHeader().setDefaultSectionSize(58)
|
||||
header = self.task_table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.task_table.doubleClicked.connect(self._edit_task)
|
||||
layout.addWidget(self.task_table, 1)
|
||||
|
||||
self.lock_banner = QLabel()
|
||||
self.lock_banner.setObjectName("LockBanner")
|
||||
self.lock_banner.setWordWrap(True)
|
||||
self.lock_banner.hide()
|
||||
layout.addWidget(self.lock_banner)
|
||||
return page
|
||||
|
||||
def _build_log_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setContentsMargins(28, 24, 28, 24)
|
||||
layout.setSpacing(12)
|
||||
self.log_table = QTableWidget(0, 4)
|
||||
self.log_table.setHorizontalHeaderLabels(["Zeitpunkt", "Aktion", "Ergebnis", "Details"])
|
||||
self.log_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.log_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.log_table.setAlternatingRowColors(True)
|
||||
self.log_table.verticalHeader().setVisible(False)
|
||||
header = self.log_table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
|
||||
clear_button = QPushButton("Ansicht leeren")
|
||||
clear_button.clicked.connect(lambda: self.log_table.setRowCount(0))
|
||||
button_row = QHBoxLayout()
|
||||
button_row.addStretch(1)
|
||||
button_row.addWidget(clear_button)
|
||||
layout.addLayout(button_row)
|
||||
layout.addWidget(self.log_table, 1)
|
||||
return page
|
||||
|
||||
def _build_settings_page(self) -> QWidget:
|
||||
page = QWidget()
|
||||
outer = QVBoxLayout(page)
|
||||
outer.setContentsMargins(28, 24, 28, 24)
|
||||
outer.setSpacing(16)
|
||||
card = QFrame()
|
||||
card.setProperty("card", True)
|
||||
layout = QVBoxLayout(card)
|
||||
layout.setContentsMargins(22, 22, 22, 22)
|
||||
layout.setSpacing(16)
|
||||
heading = QLabel("Darstellung und Systemstart")
|
||||
heading.setObjectName("PageTitle")
|
||||
layout.addWidget(heading)
|
||||
|
||||
self.autostart_check = QCheckBox("SyncVito mit Windows starten")
|
||||
self.autostart_check.setChecked(self.config.settings.autostart)
|
||||
layout.addWidget(self.autostart_check)
|
||||
layout.addSpacing(6)
|
||||
|
||||
font_row = QHBoxLayout()
|
||||
font_row.addWidget(QLabel("Schriftart"))
|
||||
self.font_combo = QComboBox()
|
||||
self.font_combo.addItems(QFontDatabase.families())
|
||||
font_index = self.font_combo.findText(self.config.settings.font_family)
|
||||
self.font_combo.setCurrentIndex(max(0, font_index))
|
||||
font_row.addWidget(self.font_combo, 1)
|
||||
font_row.addWidget(QLabel("Größe"))
|
||||
self.font_size_spin = QSpinBox()
|
||||
self.font_size_spin.setRange(8, 18)
|
||||
self.font_size_spin.setValue(self.config.settings.font_size)
|
||||
font_row.addWidget(self.font_size_spin)
|
||||
font_row.addWidget(QLabel("Schnitt"))
|
||||
self.font_weight_combo = QComboBox()
|
||||
self.font_weight_combo.addItem("Regulär", "regular")
|
||||
self.font_weight_combo.addItem("Fett", "bold")
|
||||
self.font_weight_combo.setCurrentIndex(1 if self.config.settings.font_weight == "bold" else 0)
|
||||
font_row.addWidget(self.font_weight_combo)
|
||||
layout.addLayout(font_row)
|
||||
|
||||
scale_row = QHBoxLayout()
|
||||
scale_row.addWidget(QLabel("UI-Skalierung"))
|
||||
self.scale_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.scale_slider.setRange(80, 150)
|
||||
self.scale_slider.setTickInterval(10)
|
||||
self.scale_slider.setValue(self.config.settings.ui_scale)
|
||||
self.scale_value = QLabel(f"{self.config.settings.ui_scale} %")
|
||||
self.scale_value.setMinimumWidth(52)
|
||||
self.scale_slider.valueChanged.connect(lambda value: self.scale_value.setText(f"{value} %"))
|
||||
scale_row.addWidget(self.scale_slider, 1)
|
||||
scale_row.addWidget(self.scale_value)
|
||||
layout.addLayout(scale_row)
|
||||
|
||||
hint = QLabel("Änderungen an Schrift und Skalierung werden sofort angewendet und lokal gespeichert.")
|
||||
hint.setProperty("muted", True)
|
||||
layout.addWidget(hint)
|
||||
save_button = QPushButton("Einstellungen übernehmen")
|
||||
save_button.setProperty("primary", True)
|
||||
save_button.clicked.connect(self._save_settings)
|
||||
action_row = QHBoxLayout()
|
||||
action_row.addStretch(1)
|
||||
action_row.addWidget(save_button)
|
||||
layout.addLayout(action_row)
|
||||
outer.addWidget(card)
|
||||
|
||||
update_card = QFrame()
|
||||
update_card.setProperty("card", True)
|
||||
update_layout = QVBoxLayout(update_card)
|
||||
update_layout.setContentsMargins(22, 22, 22, 22)
|
||||
update_layout.setSpacing(12)
|
||||
update_heading = QLabel("Programmupdates")
|
||||
update_heading.setObjectName("PageTitle")
|
||||
update_layout.addWidget(update_heading)
|
||||
version_label = QLabel(f"Installierte Version: {__version__}")
|
||||
version_label.setProperty("muted", True)
|
||||
update_layout.addWidget(version_label)
|
||||
self.update_check_box = QCheckBox("Beim Start automatisch nach Updates suchen")
|
||||
self.update_check_box.setChecked(self.config.settings.check_updates_on_start)
|
||||
update_layout.addWidget(self.update_check_box)
|
||||
update_hint = QLabel(
|
||||
"Updates werden ausschließlich aus den öffentlichen Releases des SyncVito-Gitea-Repositories geladen "
|
||||
"und vor der Installation per SHA-256 geprüft."
|
||||
)
|
||||
update_hint.setWordWrap(True)
|
||||
update_hint.setProperty("muted", True)
|
||||
update_layout.addWidget(update_hint)
|
||||
self.update_button = QPushButton("Nach Updates suchen")
|
||||
self.update_button.clicked.connect(lambda: self._check_updates(manual=True))
|
||||
update_actions = QHBoxLayout()
|
||||
update_actions.addStretch(1)
|
||||
update_actions.addWidget(self.update_button)
|
||||
update_layout.addLayout(update_actions)
|
||||
outer.addWidget(update_card)
|
||||
outer.addStretch(1)
|
||||
return page
|
||||
|
||||
def _build_progress_dock(self) -> QWidget:
|
||||
dock = QWidget()
|
||||
dock.setObjectName("ProgressDock")
|
||||
dock.setFixedHeight(66)
|
||||
layout = QHBoxLayout(dock)
|
||||
layout.setContentsMargins(28, 12, 28, 12)
|
||||
self.progress_text = QLabel("Bereit")
|
||||
self.progress_text.setMinimumWidth(250)
|
||||
self.progress_text.setProperty("muted", True)
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_bar.setFormat("%p %")
|
||||
layout.addWidget(self.progress_text)
|
||||
layout.addWidget(self.progress_bar, 1)
|
||||
return dock
|
||||
|
||||
def _build_tray(self) -> None:
|
||||
self.tray = QSystemTrayIcon(status_icon("active"), self)
|
||||
self.tray.setToolTip("SyncVito – Bereit")
|
||||
menu = QMenu()
|
||||
open_action = QAction("Öffnen", self)
|
||||
self.tray_sync_action = QAction("Sync jetzt", self)
|
||||
self.tray_update_action = QAction("Nach Updates suchen", self)
|
||||
self.pause_action = QAction("Pausieren", self)
|
||||
quit_action = QAction("Beenden", self)
|
||||
open_action.triggered.connect(self.show_from_tray)
|
||||
self.tray_sync_action.triggered.connect(self.coordinator.enqueue_all)
|
||||
self.tray_update_action.triggered.connect(lambda: self._check_updates(manual=True))
|
||||
self.pause_action.triggered.connect(self._toggle_pause)
|
||||
quit_action.triggered.connect(self.request_quit)
|
||||
menu.addAction(open_action)
|
||||
menu.addAction(self.tray_sync_action)
|
||||
menu.addAction(self.pause_action)
|
||||
menu.addAction(self.tray_update_action)
|
||||
menu.addSeparator()
|
||||
menu.addAction(quit_action)
|
||||
self.tray.setContextMenu(menu)
|
||||
self.tray.activated.connect(
|
||||
lambda reason: self.show_from_tray() if reason == QSystemTrayIcon.ActivationReason.DoubleClick else None
|
||||
)
|
||||
self.tray.show()
|
||||
self._update_pause_action()
|
||||
|
||||
def _connect_coordinator(self) -> None:
|
||||
self.coordinator.progress_changed.connect(self._update_progress)
|
||||
self.coordinator.log_created.connect(self._append_log)
|
||||
self.coordinator.overall_status_changed.connect(self._set_global_status)
|
||||
self.coordinator.task_state_changed.connect(self._set_task_state)
|
||||
self.coordinator.lock_changed.connect(self._set_lock_message)
|
||||
self.coordinator.idle_changed.connect(lambda _idle: self._refresh_action_availability())
|
||||
self.updater.update_available.connect(self._on_update_available)
|
||||
self.updater.no_update.connect(self._on_no_update)
|
||||
self.updater.error.connect(self._on_update_error)
|
||||
self.updater.progress.connect(self._update_progress)
|
||||
self.updater.install_ready.connect(self._install_update)
|
||||
self.updater.busy_changed.connect(lambda _busy: self._refresh_action_availability())
|
||||
|
||||
def _select_page(self, index: int) -> None:
|
||||
titles = [
|
||||
("Synchronisationsaufgaben", "Lokale Ordner und NAS-Ziele zuverlässig im gleichen Stand halten"),
|
||||
("Aktivitätsprotokoll", "Erfolge, Konflikte und Fehler transparent nachvollziehen"),
|
||||
("Einstellungen", "Darstellung und Windows-Integration anpassen"),
|
||||
]
|
||||
self.stack.setCurrentIndex(index)
|
||||
self.page_title.setText(titles[index][0])
|
||||
self.page_subtitle.setText(titles[index][1])
|
||||
for button_index, button in enumerate(self.nav_buttons):
|
||||
button.setChecked(button_index == index)
|
||||
|
||||
def _refresh_tasks(self) -> None:
|
||||
self.task_table.setRowCount(len(self.config.tasks))
|
||||
for row, task in enumerate(self.config.tasks):
|
||||
name = QTableWidgetItem(task.name)
|
||||
name.setData(Qt.ItemDataRole.UserRole, task.id)
|
||||
pair = QTableWidgetItem(f"{task.source}\n→ {task.target}")
|
||||
direction = QTableWidgetItem("Einweg" if task.direction is SyncDirection.ONE_WAY else "Bidirektional")
|
||||
trigger_text = {
|
||||
TriggerMode.MANUAL: "Manuell",
|
||||
TriggerMode.REALTIME: "Echtzeit",
|
||||
TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.",
|
||||
}[task.trigger]
|
||||
trigger = QTableWidgetItem(trigger_text)
|
||||
status = QTableWidgetItem(self._runtime_state_text(self.coordinator.task_state(task.id)))
|
||||
for column, item in enumerate((name, pair, direction, trigger, status)):
|
||||
item.setToolTip(item.text())
|
||||
self.task_table.setItem(row, column, item)
|
||||
|
||||
def _selected_task(self) -> SyncTask | None:
|
||||
row = self.task_table.currentRow()
|
||||
if row < 0:
|
||||
return None
|
||||
item = self.task_table.item(row, 0)
|
||||
task_id = item.data(Qt.ItemDataRole.UserRole)
|
||||
return next((task for task in self.config.tasks if task.id == task_id), None)
|
||||
|
||||
def _add_task(self) -> None:
|
||||
dialog = TaskDialog(parent=self)
|
||||
if dialog.exec() == TaskDialog.DialogCode.Accepted:
|
||||
self.config.tasks.append(dialog.task())
|
||||
self._persist_tasks("Aufgabe angelegt")
|
||||
|
||||
def _edit_task(self) -> None:
|
||||
task = self._selected_task()
|
||||
if task is None:
|
||||
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||
return
|
||||
dialog = TaskDialog(task, self)
|
||||
if dialog.exec() == TaskDialog.DialogCode.Accepted:
|
||||
updated = dialog.task()
|
||||
index = self.config.tasks.index(task)
|
||||
self.config.tasks[index] = updated
|
||||
self._persist_tasks("Aufgabe aktualisiert")
|
||||
|
||||
def _delete_task(self) -> None:
|
||||
task = self._selected_task()
|
||||
if task is None:
|
||||
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||
return
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"Aufgabe löschen",
|
||||
f"Soll „{task.name}“ wirklich gelöscht werden? Dateien werden dabei nicht entfernt.",
|
||||
)
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self.config.tasks.remove(task)
|
||||
StateStore().remove(task.id)
|
||||
self._persist_tasks("Aufgabe gelöscht")
|
||||
|
||||
def _sync_selected(self) -> None:
|
||||
task = self._selected_task()
|
||||
if task is None:
|
||||
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||
return
|
||||
self.coordinator.enqueue(task.id)
|
||||
|
||||
def _persist_tasks(self, message: str) -> None:
|
||||
try:
|
||||
self.config_store.save(self.config)
|
||||
self.coordinator.refresh_task_states()
|
||||
self.coordinator.rebuild_monitors()
|
||||
except ConfigError as exc:
|
||||
QMessageBox.critical(self, "Speicherfehler", str(exc))
|
||||
return
|
||||
self._refresh_tasks()
|
||||
self._append_log("Aufgaben", "Erfolg", message)
|
||||
|
||||
def _save_settings(self) -> None:
|
||||
settings = self.config.settings
|
||||
old_autostart = settings.autostart
|
||||
settings.autostart = self.autostart_check.isChecked()
|
||||
settings.font_family = self.font_combo.currentText()
|
||||
settings.font_size = self.font_size_spin.value()
|
||||
settings.font_weight = self.font_weight_combo.currentData()
|
||||
settings.ui_scale = self.scale_slider.value()
|
||||
settings.check_updates_on_start = self.update_check_box.isChecked()
|
||||
settings.validate()
|
||||
try:
|
||||
if old_autostart != settings.autostart:
|
||||
set_autostart(settings.autostart)
|
||||
self.config_store.save(self.config)
|
||||
except (ConfigError, OSError) as exc:
|
||||
QMessageBox.critical(self, "Einstellungen konnten nicht gespeichert werden", str(exc))
|
||||
settings.autostart = old_autostart
|
||||
return
|
||||
apply_theme(QApplication.instance(), settings)
|
||||
self._append_log("Einstellungen", "Erfolg", "Darstellung und Systemstart wurden übernommen.")
|
||||
|
||||
def _automatic_update_check(self) -> None:
|
||||
settings = self.config.settings
|
||||
if not settings.check_updates_on_start or self.coordinator.is_running:
|
||||
return
|
||||
if settings.last_update_check:
|
||||
try:
|
||||
last_check = datetime.fromisoformat(settings.last_update_check)
|
||||
except ValueError:
|
||||
last_check = datetime.min
|
||||
if datetime.now() - last_check < timedelta(hours=24):
|
||||
return
|
||||
self._check_updates(manual=False)
|
||||
|
||||
def _check_updates(self, manual: bool) -> None:
|
||||
if self.coordinator.is_running:
|
||||
if manual:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Synchronisation läuft",
|
||||
"Bitte die laufende Synchronisation abschließen lassen und die Updateprüfung erneut starten.",
|
||||
)
|
||||
return
|
||||
self._update_progress(0, "Update wird geprüft …")
|
||||
self.updater.check(manual)
|
||||
|
||||
def _on_update_available(self, info: UpdateInfo, manual: bool) -> None:
|
||||
self._mark_update_checked()
|
||||
self._update_progress(100, f"Version {info.version} ist verfügbar")
|
||||
self._append_log("Update", "Hinweis", f"Version {info.version} ist verfügbar.")
|
||||
dialog = UpdateDialog(__version__, info, getattr(sys, "frozen", False), self)
|
||||
if dialog.exec() == UpdateDialog.DialogCode.Accepted:
|
||||
self.updater.download(info)
|
||||
|
||||
def _on_no_update(self, manual: bool) -> None:
|
||||
self._mark_update_checked()
|
||||
self._update_progress(100, "SyncVito ist aktuell")
|
||||
self._append_log("Update", "Erfolg", "Die installierte Version ist aktuell.")
|
||||
if manual:
|
||||
QMessageBox.information(self, "Keine Updates", "Du verwendest bereits die aktuelle SyncVito-Version.")
|
||||
|
||||
def _on_update_error(self, message: str, manual: bool) -> None:
|
||||
self._update_progress(0, "Updateprüfung fehlgeschlagen")
|
||||
self._append_log("Update", "Fehler", message)
|
||||
if manual:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Update nicht möglich",
|
||||
f"{message}\n\nBitte die Netzwerkverbindung prüfen und anschließend erneut versuchen.",
|
||||
)
|
||||
|
||||
def _install_update(self, path: Path, info: UpdateInfo) -> None:
|
||||
try:
|
||||
self.updater.service.launch_installer(path, info.version)
|
||||
except Exception as exc: # noqa: BLE001 - UI-Grenze verhindert einen Anwendungsabbruch.
|
||||
self._on_update_error(str(exc), True)
|
||||
return
|
||||
self._append_log("Update", "Erfolg", f"Version {info.version} wird installiert.")
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Update wird installiert",
|
||||
"SyncVito wird jetzt geschlossen, aktualisiert und automatisch neu gestartet.",
|
||||
)
|
||||
self.request_quit()
|
||||
|
||||
def _show_update_completed(self) -> None:
|
||||
message = f"Update abgeschlossen – SyncVito {__version__} wurde erfolgreich gestartet."
|
||||
self._append_log("Update", "Erfolg", message)
|
||||
self.tray.showMessage("SyncVito aktualisiert", message, QSystemTrayIcon.MessageIcon.Information, 5000)
|
||||
|
||||
def _cleanup_update_installers(self, attempts_remaining: int = 1) -> None:
|
||||
_removed, pending = self.updater.service.cleanup_installers()
|
||||
if pending and attempts_remaining > 1:
|
||||
QTimer.singleShot(
|
||||
3000,
|
||||
lambda: self._cleanup_update_installers(attempts_remaining - 1),
|
||||
)
|
||||
|
||||
def _mark_update_checked(self) -> None:
|
||||
self.config.settings.last_update_check = datetime.now().isoformat(timespec="seconds")
|
||||
try:
|
||||
self.config_store.save(self.config)
|
||||
except ConfigError as exc:
|
||||
self._append_log("Update", "Fehler", str(exc))
|
||||
|
||||
def _refresh_action_availability(self) -> None:
|
||||
update_idle = not self.updater.busy
|
||||
sync_idle = not self.coordinator.is_running
|
||||
self.sync_all_button.setEnabled(update_idle and sync_idle)
|
||||
self.sync_selected_button.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.tray_update_action.setEnabled(update_idle and sync_idle)
|
||||
|
||||
def _toggle_pause(self) -> None:
|
||||
self.coordinator.set_paused(not self.config.settings.paused)
|
||||
self._update_pause_action()
|
||||
|
||||
def _update_pause_action(self) -> None:
|
||||
self.pause_action.setText("Fortsetzen" if self.config.settings.paused else "Pausieren")
|
||||
|
||||
def _update_progress(self, value: int, detail: str) -> None:
|
||||
self.progress_bar.setValue(value)
|
||||
self.progress_text.setText(detail)
|
||||
|
||||
def _append_log(self, action: str, level: str, detail: str) -> None:
|
||||
row = self.log_table.rowCount()
|
||||
self.log_table.insertRow(row)
|
||||
values = (datetime.now().strftime("%d.%m.%Y %H:%M:%S"), action, level, detail)
|
||||
for column, value in enumerate(values):
|
||||
item = QTableWidgetItem(value)
|
||||
item.setToolTip(value)
|
||||
self.log_table.setItem(row, column, item)
|
||||
if self.log_table.rowCount() > 1000:
|
||||
self.log_table.removeRow(0)
|
||||
self.log_table.scrollToBottom()
|
||||
|
||||
def _set_global_status(self, status: str, text: str) -> None:
|
||||
colors = {"active": "#35C77A", "paused": "#E0525D", "error": "#F0B84B"}
|
||||
self.status_label.setText(text)
|
||||
self.status_label.setStyleSheet(f"color: {colors.get(status, '#E8EDF4')};")
|
||||
self.tray.setIcon(status_icon(status))
|
||||
self.tray.setToolTip(f"SyncVito – {text}")
|
||||
self._update_pause_action()
|
||||
|
||||
def _set_task_state(self, task_id: str, state: TaskRuntimeState) -> None:
|
||||
self._refresh_tasks()
|
||||
|
||||
def _set_lock_message(self, message: str) -> None:
|
||||
self.lock_banner.setText(message)
|
||||
self.lock_banner.setVisible(bool(message))
|
||||
|
||||
@staticmethod
|
||||
def _runtime_state_text(state: TaskRuntimeState) -> str:
|
||||
return {
|
||||
TaskRuntimeState.ACTIVE: "Aktiv",
|
||||
TaskRuntimeState.PAUSED: "Pausiert",
|
||||
TaskRuntimeState.WAITING_FOR_RELEASE: "Wartet auf Freigabe",
|
||||
TaskRuntimeState.ERROR: "Fehler",
|
||||
}[state]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Domänenmodelle und validierte Konfigurationswerte."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
__all__ = [
|
||||
"AppConfig",
|
||||
"AppSettings",
|
||||
"ConflictMode",
|
||||
"DeleteMode",
|
||||
"SyncDirection",
|
||||
"SyncTask",
|
||||
"TaskRuntimeState",
|
||||
"TriggerMode",
|
||||
]
|
||||
|
||||
|
||||
class SyncDirection(StrEnum):
|
||||
"""Unterstützte Synchronisationsrichtungen."""
|
||||
|
||||
ONE_WAY = "one_way"
|
||||
BIDIRECTIONAL = "bidirectional"
|
||||
|
||||
|
||||
class TriggerMode(StrEnum):
|
||||
"""Unterstützte Auslöser für eine Aufgabe."""
|
||||
|
||||
REALTIME = "realtime"
|
||||
INTERVAL = "interval"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class DeleteMode(StrEnum):
|
||||
"""Verhalten bei erkannten Löschungen."""
|
||||
|
||||
MIRROR = "mirror"
|
||||
KEEP = "keep"
|
||||
|
||||
|
||||
class ConflictMode(StrEnum):
|
||||
"""Verhalten bei beidseitig geänderten Dateien."""
|
||||
|
||||
NEWER_WINS = "newer_wins"
|
||||
CONFLICT_COPY = "conflict_copy"
|
||||
|
||||
|
||||
class TaskRuntimeState(StrEnum):
|
||||
"""Nicht persistierter, tatsächlicher Laufzeitzustand einer Aufgabe."""
|
||||
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
WAITING_FOR_RELEASE = "waiting_for_release"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SyncTask:
|
||||
"""Persistierbare Konfiguration eines Ordnerpaars."""
|
||||
|
||||
name: str
|
||||
source: str
|
||||
target: str
|
||||
id: str = field(default_factory=lambda: str(uuid4()))
|
||||
direction: SyncDirection = SyncDirection.ONE_WAY
|
||||
trigger: TriggerMode = TriggerMode.MANUAL
|
||||
delete_mode: DeleteMode = DeleteMode.KEEP
|
||||
conflict_mode: ConflictMode = ConflictMode.NEWER_WINS
|
||||
interval_minutes: int = 30
|
||||
selected_paths: list[str] = field(default_factory=list)
|
||||
enabled: bool = True
|
||||
last_run: str | None = None
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validiert sicherheitsrelevante Aufgabeneigenschaften frühzeitig."""
|
||||
if not self.name.strip():
|
||||
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_norm = os.path.normcase(str(source))
|
||||
target_norm = os.path.normcase(str(target))
|
||||
if source_norm == target_norm:
|
||||
raise ValueError("Quell- und Zielordner dürfen nicht identisch sein.")
|
||||
if _is_relative_to(source, target) or _is_relative_to(target, source):
|
||||
raise ValueError("Quell- und Zielordner dürfen nicht ineinander liegen.")
|
||||
if not 1 <= self.interval_minutes <= 10080:
|
||||
raise ValueError("Das Intervall muss zwischen 1 Minute und 7 Tagen liegen.")
|
||||
self.name = self.name.strip()
|
||||
self.source = str(source)
|
||||
self.target = str(target)
|
||||
self.selected_paths = sorted({_normalize_relative_path(item) for item in self.selected_paths if item.strip()})
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialisiert die Aufgabe in ein JSON-kompatibles Dictionary."""
|
||||
data = asdict(self)
|
||||
data["direction"] = self.direction.value
|
||||
data["trigger"] = self.trigger.value
|
||||
data["delete_mode"] = self.delete_mode.value
|
||||
data["conflict_mode"] = self.conflict_mode.value
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> SyncTask:
|
||||
"""Erzeugt eine Aufgabe aus persistierten Daten."""
|
||||
task = cls(
|
||||
id=str(data.get("id") or uuid4()),
|
||||
name=str(data.get("name", "Unbenannte Aufgabe")),
|
||||
source=str(data.get("source", "")),
|
||||
target=str(data.get("target", "")),
|
||||
direction=SyncDirection(data.get("direction", SyncDirection.ONE_WAY.value)),
|
||||
trigger=TriggerMode(data.get("trigger", TriggerMode.MANUAL.value)),
|
||||
delete_mode=DeleteMode(data.get("delete_mode", DeleteMode.KEEP.value)),
|
||||
conflict_mode=ConflictMode(data.get("conflict_mode", ConflictMode.NEWER_WINS.value)),
|
||||
interval_minutes=int(data.get("interval_minutes", 30)),
|
||||
selected_paths=[str(item) for item in data.get("selected_paths", [])],
|
||||
enabled=bool(data.get("enabled", True)),
|
||||
last_run=data.get("last_run"),
|
||||
)
|
||||
task.validate()
|
||||
return task
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppSettings:
|
||||
"""Globale, persistierbare Anwendungseinstellungen."""
|
||||
|
||||
autostart: bool = False
|
||||
font_family: str = "Segoe UI Variable"
|
||||
font_size: int = 10
|
||||
font_weight: str = "regular"
|
||||
ui_scale: int = 100
|
||||
paused: bool = False
|
||||
check_updates_on_start: bool = True
|
||||
last_update_check: str | None = None
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Begrenzt Darstellungswerte auf brauchbare Bereiche."""
|
||||
self.font_size = max(8, min(18, int(self.font_size)))
|
||||
self.ui_scale = max(80, min(150, int(self.ui_scale)))
|
||||
if self.font_weight not in {"regular", "bold"}:
|
||||
self.font_weight = "regular"
|
||||
if not self.font_family.strip():
|
||||
self.font_family = "Segoe UI Variable"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialisiert die Einstellungen."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> AppSettings:
|
||||
"""Erzeugt Einstellungen aus einem Dictionary."""
|
||||
settings = cls(
|
||||
autostart=bool(data.get("autostart", False)),
|
||||
font_family=str(data.get("font_family", "Segoe UI Variable")),
|
||||
font_size=int(data.get("font_size", 10)),
|
||||
font_weight=str(data.get("font_weight", "regular")),
|
||||
ui_scale=int(data.get("ui_scale", 100)),
|
||||
paused=bool(data.get("paused", False)),
|
||||
check_updates_on_start=bool(data.get("check_updates_on_start", True)),
|
||||
last_update_check=data.get("last_update_check"),
|
||||
)
|
||||
settings.validate()
|
||||
return settings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppConfig:
|
||||
"""Wurzelobjekt der lokalen JSON-Konfiguration."""
|
||||
|
||||
tasks: list[SyncTask] = field(default_factory=list)
|
||||
settings: AppSettings = field(default_factory=AppSettings)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialisiert die vollständige Konfiguration."""
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"tasks": [task.to_dict() for task in self.tasks],
|
||||
"settings": self.settings.to_dict(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> AppConfig:
|
||||
"""Erzeugt eine vollständige Konfiguration aus JSON-Daten."""
|
||||
return cls(
|
||||
tasks=[SyncTask.from_dict(item) for item in data.get("tasks", [])],
|
||||
settings=AppSettings.from_dict(data.get("settings", {})),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_relative_path(value: str) -> str:
|
||||
normalized = value.replace("/", os.sep).replace("\\", os.sep).strip(os.sep)
|
||||
path = Path(normalized)
|
||||
if path.is_absolute() or ".." in path.parts:
|
||||
raise ValueError(f"Ungültige Unterauswahl: {value}")
|
||||
return str(path)
|
||||
|
||||
|
||||
def _is_relative_to(path: Path, base: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(base)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Watchdog-Adapter für Echtzeit-Aufgaben."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from watchdog.events import FileSystemEvent, FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from syncvito.models import SyncDirection, SyncTask, TriggerMode
|
||||
|
||||
__all__ = ["RealtimeMonitor"]
|
||||
|
||||
|
||||
class _TaskEventHandler(FileSystemEventHandler):
|
||||
def __init__(self, task_id: str, callback: Callable[[str], None]) -> None:
|
||||
self.task_id = task_id
|
||||
self.callback = callback
|
||||
|
||||
def on_any_event(self, event: FileSystemEvent) -> None:
|
||||
if not event.is_directory or event.event_type in {"created", "deleted", "moved", "modified"}:
|
||||
self.callback(self.task_id)
|
||||
|
||||
|
||||
class RealtimeMonitor:
|
||||
"""Verwaltet einen Watchdog-Observer für alle Echtzeit-Aufgaben."""
|
||||
|
||||
def __init__(self, callback: Callable[[str], None]) -> None:
|
||||
self.callback = callback
|
||||
self._observer: Observer | None = None
|
||||
|
||||
def rebuild(self, tasks: list[SyncTask]) -> list[tuple[str, str]]:
|
||||
"""Startet die Überwachung passend zur aktuellen Aufgabenliste neu."""
|
||||
self.stop()
|
||||
observer = Observer()
|
||||
errors: list[tuple[str, str]] = []
|
||||
scheduled = False
|
||||
for task in tasks:
|
||||
if not task.enabled or task.trigger is not TriggerMode.REALTIME:
|
||||
continue
|
||||
handler = _TaskEventHandler(task.id, self.callback)
|
||||
paths = [Path(task.source)]
|
||||
if task.direction is SyncDirection.BIDIRECTIONAL:
|
||||
paths.append(Path(task.target))
|
||||
for path in paths:
|
||||
if not path.is_dir():
|
||||
errors.append((task.id, f"Echtzeitüberwachung nicht möglich: {path}"))
|
||||
continue
|
||||
try:
|
||||
observer.schedule(handler, str(path), recursive=True)
|
||||
scheduled = True
|
||||
except OSError as exc:
|
||||
errors.append((task.id, f"Überwachung fehlgeschlagen ({path}): {exc}"))
|
||||
if scheduled:
|
||||
observer.start()
|
||||
self._observer = observer
|
||||
return errors
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Beendet den laufenden Observer sauber."""
|
||||
if self._observer is not None:
|
||||
self._observer.stop()
|
||||
self._observer.join(timeout=3)
|
||||
self._observer = None
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Persistenter Abgleichzustand für sichere bidirektionale Löschungen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from syncvito.config_store import default_data_directory
|
||||
|
||||
__all__ = ["StateStore"]
|
||||
|
||||
|
||||
class StateStore:
|
||||
"""Speichert Dateisignaturen getrennt von der Benutzerkonfiguration."""
|
||||
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = path or default_data_directory() / "sync-state.json"
|
||||
self._data: dict[str, dict[str, dict[str, str | None]]] = {}
|
||||
self._load()
|
||||
|
||||
def get(self, task_id: str) -> dict[str, dict[str, str | None]]:
|
||||
"""Liefert den zuletzt erfolgreichen Zustand einer Aufgabe."""
|
||||
return dict(self._data.get(task_id, {}))
|
||||
|
||||
def set(self, task_id: str, state: dict[str, dict[str, str | None]]) -> None:
|
||||
"""Aktualisiert und persistiert den Zustand einer Aufgabe."""
|
||||
self._data[task_id] = state
|
||||
self._save()
|
||||
|
||||
def remove(self, task_id: str) -> None:
|
||||
"""Entfernt den Zustand einer gelöschten Aufgabe."""
|
||||
self._data.pop(task_id, None)
|
||||
self._save()
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.path.exists():
|
||||
return
|
||||
try:
|
||||
raw: Any = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if isinstance(raw, dict):
|
||||
self._data = raw
|
||||
except (OSError, json.JSONDecodeError):
|
||||
self._data = {}
|
||||
|
||||
def _save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.path.with_suffix(".json.tmp")
|
||||
temporary.write_text(json.dumps(self._data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(temporary, self.path)
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Dateisystem-Synchronisation ohne Abhängigkeit von der Benutzeroberfläche."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from syncvito.file_locks import LockingProcess, find_locking_processes
|
||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
||||
from syncvito.state_store import StateStore
|
||||
|
||||
__all__ = [
|
||||
"BlockedFile",
|
||||
"ProgressCallback",
|
||||
"SyncEngine",
|
||||
"SyncResult",
|
||||
"UnavailablePathError",
|
||||
]
|
||||
|
||||
ProgressCallback = Callable[[int, str], None]
|
||||
|
||||
|
||||
class UnavailablePathError(RuntimeError):
|
||||
"""Ein für die Synchronisation benötigter Pfad ist nicht erreichbar."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FileRecord:
|
||||
"""Kompakte Dateisystemmetadaten für Vergleiche."""
|
||||
|
||||
path: Path
|
||||
size: int
|
||||
mtime_ns: int
|
||||
|
||||
@property
|
||||
def signature(self) -> str:
|
||||
"""Liefert eine persistierbare Signatur."""
|
||||
return f"{self.size}:{self.mtime_ns}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BlockedFile:
|
||||
"""Eine zurückgestellte Datei einschließlich bekannter Sperrprozesse."""
|
||||
|
||||
path: Path
|
||||
processes: tuple[LockingProcess, ...] = ()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SyncResult:
|
||||
"""Zusammenfassung eines vollständigen Aufgabenlaufs."""
|
||||
|
||||
copied: int = 0
|
||||
deleted: int = 0
|
||||
conflicts: int = 0
|
||||
skipped: int = 0
|
||||
blocked: list[BlockedFile] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
inaccessible: bool = False
|
||||
|
||||
@property
|
||||
def successful(self) -> bool:
|
||||
"""Zeigt an, ob der Lauf ohne Fehler und Sperren abgeschlossen wurde."""
|
||||
return not self.errors and not self.blocked and not self.inaccessible
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Operation:
|
||||
kind: str
|
||||
source: Path | None
|
||||
target: Path
|
||||
relative: str
|
||||
conflict_copy: bool = False
|
||||
|
||||
|
||||
class SyncEngine:
|
||||
"""Plant und verarbeitet Einweg- sowie bidirektionale Aufgaben."""
|
||||
|
||||
def __init__(self, state_store: StateStore | None = None) -> None:
|
||||
self.state_store = state_store or StateStore()
|
||||
|
||||
def synchronize(self, task: SyncTask, progress: ProgressCallback | None = None) -> SyncResult:
|
||||
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
|
||||
task.validate()
|
||||
source_root = Path(task.source)
|
||||
target_root = Path(task.target)
|
||||
self._validate_roots(source_root, target_root)
|
||||
source_files, source_dirs = self._inventory(source_root, task.selected_paths)
|
||||
target_files, target_dirs = self._inventory(target_root, task.selected_paths)
|
||||
previous = self.state_store.get(task.id)
|
||||
|
||||
if task.direction is SyncDirection.ONE_WAY:
|
||||
operations = self._plan_one_way(
|
||||
task, source_root, target_root, source_files, target_files, source_dirs, target_dirs
|
||||
)
|
||||
else:
|
||||
operations = self._plan_bidirectional(
|
||||
task,
|
||||
source_root,
|
||||
target_root,
|
||||
source_files,
|
||||
target_files,
|
||||
source_dirs,
|
||||
target_dirs,
|
||||
previous,
|
||||
)
|
||||
|
||||
result = SyncResult()
|
||||
total = max(1, len(operations))
|
||||
if progress:
|
||||
progress(0, "Änderungen werden vorbereitet …")
|
||||
for index, operation in enumerate(operations, start=1):
|
||||
try:
|
||||
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:
|
||||
result.errors.append(f"{operation.target}: {exc}")
|
||||
if progress:
|
||||
detail = f"{operation.kind}: {operation.relative}"
|
||||
progress(round(index / total * 100), detail)
|
||||
|
||||
if not operations and progress:
|
||||
progress(100, "Keine Änderungen gefunden")
|
||||
|
||||
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
|
||||
refreshed_source, _ = self._inventory(source_root, task.selected_paths)
|
||||
refreshed_target, _ = self._inventory(target_root, task.selected_paths)
|
||||
self.state_store.set(task.id, self._state_from_inventories(refreshed_source, refreshed_target))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _validate_roots(source_root: Path, target_root: Path) -> None:
|
||||
if not source_root.is_dir():
|
||||
raise UnavailablePathError(f"Quellordner nicht erreichbar: {source_root}")
|
||||
if not target_root.exists():
|
||||
try:
|
||||
target_root.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise UnavailablePathError(f"Zielordner nicht erreichbar: {target_root}") from exc
|
||||
if not target_root.is_dir():
|
||||
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
|
||||
|
||||
@staticmethod
|
||||
def _inventory(root: Path, selected_paths: list[str]) -> tuple[dict[str, FileRecord], set[str]]:
|
||||
files: dict[str, FileRecord] = {}
|
||||
directories: set[str] = set()
|
||||
selections = tuple(Path(item).parts for item in selected_paths)
|
||||
try:
|
||||
for current, dir_names, file_names in os.walk(root):
|
||||
current_path = Path(current)
|
||||
relative_dir = current_path.relative_to(root)
|
||||
dir_names[:] = [name for name in dir_names if not (current_path / name).is_symlink()]
|
||||
for directory_name in dir_names:
|
||||
relative = relative_dir / directory_name
|
||||
if _is_selected(relative.parts, selections):
|
||||
directories.add(str(relative))
|
||||
for file_name in file_names:
|
||||
path = current_path / file_name
|
||||
if path.is_symlink():
|
||||
continue
|
||||
relative = path.relative_to(root)
|
||||
if not _is_selected(relative.parts, selections):
|
||||
continue
|
||||
try:
|
||||
file_stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
files[str(relative)] = FileRecord(path, file_stat.st_size, file_stat.st_mtime_ns)
|
||||
except OSError as exc:
|
||||
raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc
|
||||
return files, directories
|
||||
|
||||
@staticmethod
|
||||
def _plan_one_way(
|
||||
task: SyncTask,
|
||||
source_root: Path,
|
||||
target_root: Path,
|
||||
source_files: dict[str, FileRecord],
|
||||
target_files: dict[str, FileRecord],
|
||||
source_dirs: set[str],
|
||||
target_dirs: set[str],
|
||||
) -> list[_Operation]:
|
||||
operations: list[_Operation] = []
|
||||
for relative in sorted(source_dirs - target_dirs):
|
||||
operations.append(_Operation("Ordner anlegen", None, target_root / relative, relative))
|
||||
for relative, source in sorted(source_files.items()):
|
||||
target = target_files.get(relative)
|
||||
if target is None or target.signature != source.signature:
|
||||
operations.append(_Operation("Kopieren", source.path, target_root / relative, relative))
|
||||
if task.delete_mode is DeleteMode.MIRROR:
|
||||
for relative in sorted(target_files.keys() - source_files.keys()):
|
||||
operations.append(_Operation("Löschen", None, target_root / relative, relative))
|
||||
for relative in sorted(target_dirs - source_dirs, key=lambda item: len(Path(item).parts), reverse=True):
|
||||
operations.append(_Operation("Ordner löschen", None, target_root / relative, relative))
|
||||
return operations
|
||||
|
||||
@staticmethod
|
||||
def _plan_bidirectional(
|
||||
task: SyncTask,
|
||||
source_root: Path,
|
||||
target_root: Path,
|
||||
source_files: dict[str, FileRecord],
|
||||
target_files: dict[str, FileRecord],
|
||||
source_dirs: set[str],
|
||||
target_dirs: set[str],
|
||||
previous: dict[str, dict[str, str | None]],
|
||||
) -> list[_Operation]:
|
||||
operations = [
|
||||
_Operation("Ordner anlegen", None, target_root / relative, relative)
|
||||
for relative in sorted(source_dirs - target_dirs)
|
||||
]
|
||||
operations.extend(
|
||||
_Operation("Ordner anlegen", None, source_root / relative, relative)
|
||||
for relative in sorted(target_dirs - source_dirs)
|
||||
)
|
||||
all_paths = sorted(source_files.keys() | target_files.keys() | previous.keys())
|
||||
for relative in all_paths:
|
||||
source = source_files.get(relative)
|
||||
target = target_files.get(relative)
|
||||
old = previous.get(relative, {})
|
||||
old_source = old.get("source")
|
||||
old_target = old.get("target")
|
||||
if source and target:
|
||||
if source.signature == target.signature:
|
||||
continue
|
||||
winner, destination = _newer_pair(source, target)
|
||||
conflict = task.conflict_mode is ConflictMode.CONFLICT_COPY
|
||||
operations.append(
|
||||
_Operation(
|
||||
"Konflikt lösen" if conflict else "Aktualisieren",
|
||||
winner.path,
|
||||
destination.path,
|
||||
relative,
|
||||
conflict,
|
||||
)
|
||||
)
|
||||
elif source:
|
||||
target_was_deleted = old_target is not None and source.signature == old_source
|
||||
if task.delete_mode is DeleteMode.MIRROR and target_was_deleted:
|
||||
operations.append(_Operation("Löschung spiegeln", None, source.path, relative))
|
||||
else:
|
||||
operations.append(_Operation("Kopieren", source.path, target_root / relative, relative))
|
||||
elif target:
|
||||
source_was_deleted = old_source is not None and target.signature == old_target
|
||||
if task.delete_mode is DeleteMode.MIRROR and source_was_deleted:
|
||||
operations.append(_Operation("Löschung spiegeln", None, target.path, relative))
|
||||
else:
|
||||
operations.append(_Operation("Kopieren", target.path, source_root / relative, relative))
|
||||
return operations
|
||||
|
||||
@staticmethod
|
||||
def _execute(operation: _Operation, result: SyncResult) -> None:
|
||||
if operation.kind == "Ordner anlegen":
|
||||
operation.target.mkdir(parents=True, exist_ok=True)
|
||||
return
|
||||
if operation.kind in {"Löschen", "Löschung spiegeln"}:
|
||||
operation.target.unlink(missing_ok=True)
|
||||
result.deleted += 1
|
||||
return
|
||||
if operation.kind == "Ordner löschen":
|
||||
try:
|
||||
operation.target.rmdir()
|
||||
result.deleted += 1
|
||||
except OSError:
|
||||
result.skipped += 1
|
||||
return
|
||||
if operation.source is None:
|
||||
return
|
||||
operation.target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if operation.conflict_copy and operation.target.exists():
|
||||
conflict_path = _conflict_path(operation.target)
|
||||
shutil.copy2(operation.target, conflict_path)
|
||||
result.conflicts += 1
|
||||
_atomic_copy(operation.source, operation.target)
|
||||
result.copied += 1
|
||||
|
||||
@staticmethod
|
||||
def _state_from_inventories(
|
||||
source_files: dict[str, FileRecord], target_files: dict[str, FileRecord]
|
||||
) -> dict[str, dict[str, str | None]]:
|
||||
return {
|
||||
relative: {
|
||||
"source": source_files[relative].signature if relative in source_files else None,
|
||||
"target": target_files[relative].signature if relative in target_files else None,
|
||||
}
|
||||
for relative in source_files.keys() | target_files.keys()
|
||||
}
|
||||
|
||||
|
||||
def _atomic_copy(source: Path, target: Path) -> None:
|
||||
temporary = target.with_name(f".{target.name}.syncvito.tmp")
|
||||
try:
|
||||
shutil.copy2(source, temporary)
|
||||
os.replace(temporary, target)
|
||||
except BaseException:
|
||||
with suppress(OSError):
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _conflict_path(path: Path) -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
candidate = path.with_name(f"{path.stem}_conflict_{timestamp}{path.suffix}")
|
||||
counter = 2
|
||||
while candidate.exists():
|
||||
candidate = path.with_name(f"{path.stem}_conflict_{timestamp}_{counter}{path.suffix}")
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _newer_pair(left: FileRecord, right: FileRecord) -> tuple[FileRecord, FileRecord]:
|
||||
if left.mtime_ns >= right.mtime_ns:
|
||||
return left, right
|
||||
return right, left
|
||||
|
||||
|
||||
def _is_selected(parts: tuple[str, ...], selections: tuple[tuple[str, ...], ...]) -> bool:
|
||||
if not selections:
|
||||
return True
|
||||
return any(parts[: len(selection)] == selection or selection[: len(parts)] == parts for selection in selections)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Zentrales dunkles Designsystem der Qt-Oberfläche."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtGui import QFont
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from syncvito.models import AppSettings
|
||||
|
||||
__all__ = ["apply_theme"]
|
||||
|
||||
|
||||
def apply_theme(application: QApplication, settings: AppSettings) -> None:
|
||||
"""Wendet Typografie und semantische Farbtoken global an."""
|
||||
scale = settings.ui_scale / 100
|
||||
font = QFont(settings.font_family, max(8, round(settings.font_size * scale)))
|
||||
font.setWeight(QFont.Weight.Bold if settings.font_weight == "bold" else QFont.Weight.Normal)
|
||||
application.setFont(font)
|
||||
application.setStyleSheet(_stylesheet(scale))
|
||||
|
||||
|
||||
def _stylesheet(scale: float) -> str:
|
||||
radius = round(8 * scale)
|
||||
padding = round(8 * scale)
|
||||
return f"""
|
||||
* {{ outline: none; }}
|
||||
QMainWindow, QDialog {{ background: #0B0F14; color: #E8EDF4; }}
|
||||
QWidget {{ color: #E8EDF4; selection-background-color: #2878FF; selection-color: #FFFFFF; }}
|
||||
QWidget#Sidebar {{ background: #10161F; border-right: 1px solid #263241; }}
|
||||
QWidget#Header {{ background: #0B0F14; border-bottom: 1px solid #202B38; }}
|
||||
QWidget#ProgressDock {{ background: #10161F; border-top: 1px solid #263241; }}
|
||||
QFrame[card="true"] {{ background: #121923; border: 1px solid #263241; border-radius: {radius}px; }}
|
||||
QLabel#Brand {{ color: #FFFFFF; font-size: {round(18 * scale)}px; font-weight: 700; }}
|
||||
QLabel#Eyebrow {{ color: #6F8299; font-size: {round(10 * scale)}px; font-weight: 600; }}
|
||||
QLabel#PageTitle {{ color: #F4F7FB; font-size: {round(23 * scale)}px; font-weight: 700; }}
|
||||
QLabel#PageSubtitle, QLabel[muted="true"] {{ color: #9CACBF; }}
|
||||
QLabel#StatusPill {{ background: #162131; border: 1px solid #304158; border-radius: {round(13 * scale)}px;
|
||||
min-height: {round(18 * scale)}px; max-height: {round(18 * scale)}px;
|
||||
padding: {round(3 * scale)}px {round(10 * scale)}px; font-weight: 600; }}
|
||||
QLabel#LockBanner {{ background: #261C10; color: #FFD18A; border: 1px solid #6B4C21;
|
||||
border-radius: {radius}px; padding: {padding}px {round(12 * scale)}px; }}
|
||||
QPushButton {{ background: #192332; border: 1px solid #34455B; border-radius: {radius}px;
|
||||
padding: {padding}px {round(14 * scale)}px; min-height: {round(20 * scale)}px; font-weight: 600; }}
|
||||
QPushButton:hover {{ background: #223044; border-color: #506781; }}
|
||||
QPushButton:pressed {{ background: #131B27; }}
|
||||
QPushButton:focus {{ border: 2px solid #5B9CFF; padding: {padding - 1}px {round(13 * scale)}px; }}
|
||||
QPushButton:disabled {{ color: #627188; background: #121922; border-color: #283342; }}
|
||||
QPushButton[primary="true"] {{ background: #2878FF; border-color: #448AFF; color: #FFFFFF; }}
|
||||
QPushButton[primary="true"]:hover {{ background: #3C86FF; }}
|
||||
QPushButton[danger="true"] {{ color: #FFB6B6; border-color: #653A42; background: #24171B; }}
|
||||
QPushButton[nav="true"] {{ text-align: left; border-color: transparent; background: transparent;
|
||||
padding: {round(10 * scale)}px {round(12 * scale)}px; color: #9CACBF; }}
|
||||
QPushButton[nav="true"]:hover {{ background: #17212E; color: #E8EDF4; }}
|
||||
QPushButton[nav="true"]:checked {{ background: #1B2A3D; color: #FFFFFF; border-color: #2A568B; }}
|
||||
QLineEdit, QComboBox, QSpinBox, QListWidget, QTreeView, QTableWidget {{
|
||||
background: #0E141D; border: 1px solid #304052; border-radius: {radius}px;
|
||||
padding: {round(7 * scale)}px; color: #E8EDF4;
|
||||
}}
|
||||
QTextBrowser {{ background: #0E141D; border: 1px solid #304052; border-radius: {radius}px;
|
||||
padding: {round(8 * scale)}px; color: #DDE5EF; }}
|
||||
QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QListWidget:focus, QTreeView:focus, QTableWidget:focus {{
|
||||
border: 2px solid #5B9CFF;
|
||||
}}
|
||||
QComboBox::drop-down {{ border: none; width: {round(26 * scale)}px; }}
|
||||
QComboBox QAbstractItemView {{ background: #151E2A; border: 1px solid #34455B; selection-background-color: #2878FF; }}
|
||||
QHeaderView::section {{ background: #131B26; color: #91A2B7; border: none; border-bottom: 1px solid #2A3747;
|
||||
padding: {round(9 * scale)}px; font-weight: 600; }}
|
||||
QTableWidget {{ gridline-color: #202B38; alternate-background-color: #101721; }}
|
||||
QTableWidget::item {{ padding: {round(7 * scale)}px; border-bottom: 1px solid #202B38; }}
|
||||
QTableWidget::item:selected {{ background: #1E4E86; }}
|
||||
QProgressBar {{ background: #182230; border: 1px solid #304052; border-radius: {round(7 * scale)}px;
|
||||
text-align: center; color: #FFFFFF; min-height: {round(18 * scale)}px; font-weight: 700; }}
|
||||
QProgressBar::chunk {{ background: #2878FF; border-radius: {round(6 * scale)}px; }}
|
||||
QTabWidget::pane {{ border: 1px solid #263241; border-radius: {radius}px; top: -1px; }}
|
||||
QTabBar::tab {{ background: #111923; padding: {padding}px {round(14 * scale)}px; color: #96A7BA; }}
|
||||
QTabBar::tab:selected {{ background: #1A2635; color: #FFFFFF; }}
|
||||
QCheckBox {{ spacing: {round(8 * scale)}px; }}
|
||||
QCheckBox::indicator {{ width: {round(16 * scale)}px; height: {round(16 * scale)}px;
|
||||
border: 1px solid #50647C; border-radius: {round(4 * scale)}px; background: #0D131B; }}
|
||||
QCheckBox::indicator:checked {{ background: #2878FF; border-color: #72A8FF; }}
|
||||
QScrollBar:vertical {{ background: #0E141D; width: {round(11 * scale)}px; margin: 0; }}
|
||||
QScrollBar::handle:vertical {{ background: #35465A; border-radius: {round(5 * scale)}px; min-height: 24px; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
QToolTip {{ background: #1B2635; color: #FFFFFF; border: 1px solid #4B6078; padding: 5px; }}
|
||||
"""
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Qt-Worker für nicht blockierende Updateprüfungen und Downloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal
|
||||
|
||||
from syncvito.updater import GiteaUpdateService, UpdateError, UpdateInfo
|
||||
|
||||
__all__ = ["UpdateController"]
|
||||
|
||||
|
||||
class _CheckSignals(QObject):
|
||||
finished = pyqtSignal(object, bool)
|
||||
failed = pyqtSignal(str, bool)
|
||||
|
||||
|
||||
class _CheckWorker(QRunnable):
|
||||
def __init__(self, service: GiteaUpdateService, manual: bool) -> None:
|
||||
super().__init__()
|
||||
self.service = service
|
||||
self.manual = manual
|
||||
self.signals = _CheckSignals()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
info = self.service.check()
|
||||
except UpdateError as exc:
|
||||
self.signals.failed.emit(str(exc), self.manual)
|
||||
else:
|
||||
self.signals.finished.emit(info, self.manual)
|
||||
|
||||
|
||||
class _DownloadSignals(QObject):
|
||||
progress = pyqtSignal(int, str)
|
||||
finished = pyqtSignal(object, object)
|
||||
failed = pyqtSignal(str, bool)
|
||||
|
||||
|
||||
class _DownloadWorker(QRunnable):
|
||||
def __init__(self, service: GiteaUpdateService, info: UpdateInfo) -> None:
|
||||
super().__init__()
|
||||
self.service = service
|
||||
self.info = info
|
||||
self.signals = _DownloadSignals()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
path = self.service.download(self.info, self.signals.progress.emit)
|
||||
except UpdateError as exc:
|
||||
self.signals.failed.emit(str(exc), True)
|
||||
else:
|
||||
self.signals.finished.emit(path, self.info)
|
||||
|
||||
|
||||
class UpdateController(QObject):
|
||||
"""Serialisiert Updateaktionen und überträgt Ergebnisse in den UI-Thread."""
|
||||
|
||||
update_available = pyqtSignal(object, bool)
|
||||
no_update = pyqtSignal(bool)
|
||||
error = pyqtSignal(str, bool)
|
||||
progress = pyqtSignal(int, str)
|
||||
install_ready = pyqtSignal(object, object)
|
||||
busy_changed = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, current_version: str, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.service = GiteaUpdateService(current_version)
|
||||
self.pool = QThreadPool.globalInstance()
|
||||
self._busy = False
|
||||
|
||||
@property
|
||||
def busy(self) -> bool:
|
||||
"""Zeigt an, ob eine Updateaktion läuft."""
|
||||
return self._busy
|
||||
|
||||
def check(self, manual: bool = False) -> None:
|
||||
"""Startet eine Hintergrundprüfung, sofern keine Aktion läuft."""
|
||||
if self._busy:
|
||||
return
|
||||
self._set_busy(True)
|
||||
worker = _CheckWorker(self.service, manual)
|
||||
worker.signals.finished.connect(self._check_finished)
|
||||
worker.signals.failed.connect(self._failed)
|
||||
self.pool.start(worker)
|
||||
|
||||
def download(self, info: UpdateInfo) -> None:
|
||||
"""Lädt ein zuvor validiertes Release im Hintergrund."""
|
||||
if self._busy:
|
||||
return
|
||||
self._set_busy(True)
|
||||
worker = _DownloadWorker(self.service, info)
|
||||
worker.signals.progress.connect(self.progress)
|
||||
worker.signals.finished.connect(self._download_finished)
|
||||
worker.signals.failed.connect(self._failed)
|
||||
self.pool.start(worker)
|
||||
|
||||
def _check_finished(self, info: UpdateInfo | None, manual: bool) -> None:
|
||||
self._set_busy(False)
|
||||
if info is None:
|
||||
self.no_update.emit(manual)
|
||||
else:
|
||||
self.update_available.emit(info, manual)
|
||||
|
||||
def _download_finished(self, path: object, info: object) -> None:
|
||||
self._set_busy(False)
|
||||
self.install_ready.emit(path, info)
|
||||
|
||||
def _failed(self, message: str, manual: bool) -> None:
|
||||
self._set_busy(False)
|
||||
self.error.emit(message, manual)
|
||||
|
||||
def _set_busy(self, busy: bool) -> None:
|
||||
self._busy = busy
|
||||
self.busy_changed.emit(busy)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Kompakter Dialog für verfügbare Programmupdates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from syncvito.updater import UpdateInfo
|
||||
|
||||
__all__ = ["UpdateDialog"]
|
||||
|
||||
|
||||
class UpdateDialog(QDialog):
|
||||
"""Zeigt Versionsdaten und fordert eine explizite Installation an."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_version: str,
|
||||
update: UpdateInfo,
|
||||
install_supported: bool,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("SyncVito-Update")
|
||||
self.setMinimumWidth(590)
|
||||
self.resize(640, 470)
|
||||
|
||||
eyebrow = QLabel("NEUE VERSION VERFÜGBAR")
|
||||
eyebrow.setObjectName("Eyebrow")
|
||||
title = QLabel(update.title)
|
||||
title.setObjectName("PageTitle")
|
||||
version_text = QLabel(f"Installiert: {current_version} → Verfügbar: {update.version}")
|
||||
version_text.setProperty("muted", True)
|
||||
|
||||
notes_frame = QFrame()
|
||||
notes_frame.setProperty("card", True)
|
||||
notes_layout = QVBoxLayout(notes_frame)
|
||||
notes_layout.setContentsMargins(16, 16, 16, 16)
|
||||
notes_layout.addWidget(QLabel("Versionshinweise"))
|
||||
notes = QTextBrowser()
|
||||
notes.setPlainText(update.notes)
|
||||
notes.setOpenExternalLinks(False)
|
||||
notes_layout.addWidget(notes, 1)
|
||||
|
||||
notice = QLabel(
|
||||
"SyncVito lädt den neuen Windows-Installer herunter, prüft seine SHA-256-Summe und startet "
|
||||
"ihn erst, nachdem die Anwendung vollständig beendet wurde. "
|
||||
"Aufgaben und Einstellungen bleiben erhalten."
|
||||
if install_supported
|
||||
else "Die Updateinstallation ist nur in der gebauten Windows-EXE verfügbar."
|
||||
)
|
||||
notice.setWordWrap(True)
|
||||
notice.setProperty("muted", True)
|
||||
|
||||
buttons = QDialogButtonBox()
|
||||
later_button = buttons.addButton("Später", QDialogButtonBox.ButtonRole.RejectRole)
|
||||
install_text = "Herunterladen und installieren" if install_supported else "Schließen"
|
||||
install_button = buttons.addButton(install_text, QDialogButtonBox.ButtonRole.AcceptRole)
|
||||
install_button.setProperty("primary", True)
|
||||
later_button.clicked.connect(self.reject)
|
||||
install_button.clicked.connect(self.accept if install_supported else self.reject)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(22, 22, 22, 22)
|
||||
layout.setSpacing(12)
|
||||
header = QHBoxLayout()
|
||||
header_text = QVBoxLayout()
|
||||
header_text.addWidget(eyebrow)
|
||||
header_text.addWidget(title)
|
||||
header_text.addWidget(version_text)
|
||||
header.addLayout(header_text)
|
||||
header.addStretch(1)
|
||||
layout.addLayout(header)
|
||||
layout.addWidget(notes_frame, 1)
|
||||
layout.addWidget(notice)
|
||||
layout.addWidget(buttons)
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Sicherer Gitea-Updater für den installierten SyncVito-Client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from syncvito.config_store import default_data_directory
|
||||
|
||||
__all__ = [
|
||||
"GiteaUpdateService",
|
||||
"UpdateError",
|
||||
"UpdateFormatError",
|
||||
"UpdateInfo",
|
||||
"UpdateInstallError",
|
||||
"UpdateIntegrityError",
|
||||
"UpdateNetworkError",
|
||||
"parse_version",
|
||||
]
|
||||
|
||||
GITEA_BASE_URL = "https://gitea.davito.synology.me"
|
||||
GITEA_OWNER = "Davito"
|
||||
GITEA_REPOSITORY = "SyncVito"
|
||||
MAX_RELEASE_BYTES = 2 * 1024 * 1024
|
||||
MAX_CHECKSUM_BYTES = 1024 * 1024
|
||||
MAX_INSTALLER_BYTES = 1024 * 1024 * 1024
|
||||
USER_AGENT = "SyncVito-AutoUpdater"
|
||||
ProgressCallback = Callable[[int, str], None]
|
||||
_VERSION_PATTERN = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
|
||||
_SHA256_PATTERN = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
_MANAGED_INSTALLER_PATTERN = re.compile(
|
||||
r"^SyncVitoSetup-\d+\.\d+\.\d+\.exe(?:\.part)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class UpdateError(RuntimeError):
|
||||
"""Basisklasse für erwartete Updatefehler."""
|
||||
|
||||
|
||||
class UpdateNetworkError(UpdateError):
|
||||
"""Gitea oder ein Release-Artefakt war nicht erreichbar."""
|
||||
|
||||
|
||||
class UpdateFormatError(UpdateError):
|
||||
"""Release-Metadaten waren unvollständig oder ungültig."""
|
||||
|
||||
|
||||
class UpdateIntegrityError(UpdateError):
|
||||
"""Die heruntergeladene Datei bestand die Integritätsprüfung nicht."""
|
||||
|
||||
|
||||
class UpdateInstallError(UpdateError):
|
||||
"""Der externe Installer-Wächter konnte nicht gestartet werden."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UpdateInfo:
|
||||
"""Validierte Metadaten eines installierbaren Gitea-Releases."""
|
||||
|
||||
version: str
|
||||
title: str
|
||||
notes: str
|
||||
release_url: str
|
||||
installer_name: str
|
||||
download_url: str
|
||||
checksum_url: str
|
||||
size: int
|
||||
|
||||
|
||||
def parse_version(value: str) -> tuple[int, int, int]:
|
||||
"""Konvertiert stabile SemVer-Tags wie ``v1.2.3`` in Vergleichswerte."""
|
||||
match = _VERSION_PATTERN.fullmatch(str(value or "").strip())
|
||||
if match is None:
|
||||
raise ValueError(f"Ungültige stabile Versionsnummer: {value}")
|
||||
return tuple(int(part) for part in match.groups()) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _powershell_literal(value: str | Path) -> str:
|
||||
return "'" + str(value).replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _start_detached(executable: str, arguments: list[str]) -> None:
|
||||
"""Startet einen Prozess unabhängig vom Lebenszyklus der Anwendung."""
|
||||
if os.name == "nt":
|
||||
os.startfile(
|
||||
executable,
|
||||
operation="open",
|
||||
arguments=subprocess.list2cmdline(arguments),
|
||||
show_cmd=0,
|
||||
)
|
||||
return
|
||||
subprocess.Popen(
|
||||
[executable, *arguments],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
|
||||
def _start_installer_after_exit(
|
||||
installer_path: Path,
|
||||
arguments: list[str],
|
||||
wait_pids: Iterable[int],
|
||||
bootstrap_log_path: Path,
|
||||
) -> None:
|
||||
"""Startet Inno Setup erst nach dem Ende aller PyInstaller-Prozesse."""
|
||||
if os.name != "nt":
|
||||
_start_detached(str(installer_path), arguments)
|
||||
return
|
||||
|
||||
system_root = os.environ.get("SYSTEMROOT", r"C:\Windows")
|
||||
powershell_path = Path(system_root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
||||
if not powershell_path.is_file():
|
||||
raise UpdateInstallError(f"Windows PowerShell wurde nicht gefunden: {powershell_path}")
|
||||
|
||||
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)
|
||||
script = f"""
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$bootstrapLog = {_powershell_literal(bootstrap_log_path)}
|
||||
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."
|
||||
}}
|
||||
Start-Sleep -Milliseconds 200
|
||||
}}
|
||||
}}
|
||||
[System.IO.File]::AppendAllText(
|
||||
$bootstrapLog,
|
||||
((Get-Date).ToString('o') + ' Starte verifizierten Installer.' + [Environment]::NewLine)
|
||||
)
|
||||
$setupArguments = @({setup_arguments})
|
||||
& {_powershell_literal(installer_path)} @setupArguments
|
||||
$setupExitCode = $LASTEXITCODE
|
||||
[System.IO.File]::AppendAllText(
|
||||
$bootstrapLog,
|
||||
((Get-Date).ToString('o') + ' Installer-Exitcode: ' + $setupExitCode + [Environment]::NewLine)
|
||||
)
|
||||
exit $setupExitCode
|
||||
}}
|
||||
catch {{
|
||||
[System.IO.File]::AppendAllText(
|
||||
$bootstrapLog,
|
||||
((Get-Date).ToString('o') + ' Bootstrap-Fehler: ' + $_.Exception.Message + [Environment]::NewLine)
|
||||
)
|
||||
exit 1
|
||||
}}
|
||||
"""
|
||||
encoded_script = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||
_start_detached(
|
||||
str(powershell_path),
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-EncodedCommand",
|
||||
encoded_script,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class _TrustedRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
def __init__(self, trusted_host: str) -> None:
|
||||
super().__init__()
|
||||
self.trusted_host = trusted_host
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
||||
_validate_update_url(newurl, self.trusted_host)
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def _validate_update_url(url: str, trusted_host: str) -> str:
|
||||
parsed = urllib.parse.urlparse(str(url or ""))
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise UpdateFormatError("Gitea lieferte eine ungültige Downloadadresse.") from exc
|
||||
if (
|
||||
parsed.scheme.lower() != "https"
|
||||
or parsed.hostname != trusted_host
|
||||
or port not in (None, 443)
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
raise UpdateFormatError("Gitea lieferte eine nicht vertrauenswürdige Downloadadresse.")
|
||||
return parsed.geturl()
|
||||
|
||||
|
||||
class GiteaUpdateService:
|
||||
"""Kapselt das öffentliche Installer-Updateprotokoll für SyncVito."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
current_version: str,
|
||||
base_url: str = GITEA_BASE_URL,
|
||||
owner: str = GITEA_OWNER,
|
||||
repository: str = GITEA_REPOSITORY,
|
||||
update_directory: Path | None = None,
|
||||
) -> None:
|
||||
self.current_version = current_version
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.owner = owner
|
||||
self.repository = repository
|
||||
self.update_directory = update_directory or default_data_directory() / "updates"
|
||||
parsed_base = urllib.parse.urlparse(self.base_url)
|
||||
if parsed_base.scheme.lower() != "https" or not parsed_base.hostname:
|
||||
raise ValueError("Die Updatequelle muss eine gültige HTTPS-Adresse sein.")
|
||||
self.trusted_host = parsed_base.hostname
|
||||
parse_version(current_version)
|
||||
|
||||
def check(self) -> UpdateInfo | None:
|
||||
"""Liefert den neuesten stabilen Installer, sofern er neuer ist."""
|
||||
endpoint = f"{self.base_url}/api/v1/repos/{self.owner}/{self.repository}/releases/latest"
|
||||
try:
|
||||
payload = self._read_json(endpoint)
|
||||
except UpdateNetworkError as exc:
|
||||
if "HTTP 404" in str(exc):
|
||||
return None
|
||||
raise
|
||||
if not isinstance(payload, dict) or payload.get("draft") or payload.get("prerelease"):
|
||||
raise UpdateFormatError("Gitea lieferte keinen stabilen Release.")
|
||||
try:
|
||||
version_tuple = parse_version(str(payload.get("tag_name", "")))
|
||||
except ValueError as exc:
|
||||
raise UpdateFormatError("Der neueste Gitea-Release besitzt keine gültige Version.") from exc
|
||||
if version_tuple <= parse_version(self.current_version):
|
||||
return None
|
||||
version = ".".join(str(part) for part in version_tuple)
|
||||
return self._parse_release(payload, version)
|
||||
|
||||
def download(self, info: UpdateInfo, progress: ProgressCallback | None = None) -> Path:
|
||||
"""Lädt den Installer und veröffentlicht ihn erst nach SHA-256-Prüfung."""
|
||||
self.update_directory.mkdir(parents=True, exist_ok=True)
|
||||
expected_hash = self._read_checksum(info.checksum_url, info.installer_name)
|
||||
destination = self.update_directory / info.installer_name
|
||||
temporary = destination.with_suffix(".exe.part")
|
||||
digest = hashlib.sha256()
|
||||
received = 0
|
||||
try:
|
||||
with self._request(info.download_url, timeout=30) as response, temporary.open("wb") as output:
|
||||
announced = int(response.headers.get("Content-Length", info.size or 0))
|
||||
if announced > MAX_INSTALLER_BYTES:
|
||||
raise UpdateFormatError("Der angebotene Installer ist unerwartet groß.")
|
||||
while chunk := response.read(1024 * 1024):
|
||||
received += len(chunk)
|
||||
if received > MAX_INSTALLER_BYTES:
|
||||
raise UpdateFormatError("Der geladene Installer ist unerwartet groß.")
|
||||
output.write(chunk)
|
||||
digest.update(chunk)
|
||||
if progress and announced > 0:
|
||||
progress(min(99, round(received / announced * 100)), "Installer wird heruntergeladen …")
|
||||
if received <= 0:
|
||||
raise UpdateFormatError("Der heruntergeladene Installer ist leer.")
|
||||
if digest.hexdigest().lower() != expected_hash:
|
||||
raise UpdateIntegrityError(
|
||||
"Die SHA-256-Prüfung des Installers ist fehlgeschlagen; die Datei wird nicht ausgeführt."
|
||||
)
|
||||
os.replace(temporary, destination)
|
||||
except UpdateError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
except (OSError, TimeoutError, urllib.error.URLError, ValueError) as exc:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise UpdateNetworkError(f"Der Installer konnte nicht heruntergeladen werden: {exc}") from exc
|
||||
if progress:
|
||||
progress(100, "Installer geprüft und bereit")
|
||||
self._remove_old_downloads(destination)
|
||||
return destination
|
||||
|
||||
def launch_installer(self, installer_path: Path, version: str) -> None:
|
||||
"""Startet einen Wächter, der nach dem App-Ende Inno Setup ausführt."""
|
||||
if os.name != "nt" or not getattr(sys, "frozen", False):
|
||||
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():
|
||||
raise UpdateInstallError("Der geprüfte Installer wurde nicht gefunden.")
|
||||
|
||||
safe_version = re.sub(r"[^0-9A-Za-z._-]+", "_", version).strip("._") or "unknown"
|
||||
setup_log = installer.with_name(f"SyncVitoSetup-{safe_version}.log")
|
||||
bootstrap_log = installer.with_name(f"SyncVitoUpdater-{safe_version}-bootstrap.log")
|
||||
current_executable = Path(sys.executable).resolve()
|
||||
wait_pids = [os.getpid()]
|
||||
parent_pid = os.getppid()
|
||||
if parent_pid > 0 and parent_pid not in wait_pids:
|
||||
wait_pids.append(parent_pid)
|
||||
arguments = [
|
||||
"/VERYSILENT",
|
||||
"/SUPPRESSMSGBOXES",
|
||||
"/NORESTART",
|
||||
"/CLOSEAPPLICATIONS",
|
||||
f"/DIR={current_executable.parent}",
|
||||
f"/LOG={setup_log}",
|
||||
]
|
||||
try:
|
||||
_start_installer_after_exit(installer, arguments, wait_pids, bootstrap_log)
|
||||
except (OSError, ValueError, UpdateError) as exc:
|
||||
raise UpdateInstallError(f"Die Installation konnte nicht gestartet werden: {exc}") from exc
|
||||
|
||||
def cleanup_installers(self) -> tuple[int, int]:
|
||||
"""Entfernt ausschließlich von SyncVito verwaltete Setup-Dateien."""
|
||||
try:
|
||||
candidates = tuple(self.update_directory.resolve().iterdir())
|
||||
except FileNotFoundError:
|
||||
return 0, 0
|
||||
except OSError:
|
||||
return 0, 1
|
||||
removed = 0
|
||||
pending = 0
|
||||
for candidate in candidates:
|
||||
if not _MANAGED_INSTALLER_PATTERN.fullmatch(candidate.name) or candidate.is_dir():
|
||||
continue
|
||||
try:
|
||||
candidate.unlink()
|
||||
except OSError:
|
||||
pending += 1
|
||||
else:
|
||||
removed += 1
|
||||
return removed, pending
|
||||
|
||||
def _parse_release(self, release: dict[str, Any], version: str) -> UpdateInfo:
|
||||
assets = release.get("assets")
|
||||
if not isinstance(assets, list):
|
||||
raise UpdateFormatError(f"Release {version} enthält keine Dateianhänge.")
|
||||
by_name = {str(asset.get("name")): asset for asset in assets if isinstance(asset, dict)}
|
||||
installer_name = f"SyncVitoSetup-{version}.exe"
|
||||
checksum_name = f"{installer_name}.sha256"
|
||||
installer = by_name.get(installer_name)
|
||||
checksum = by_name.get(checksum_name)
|
||||
if installer is None or checksum is None:
|
||||
raise UpdateFormatError(f"Release {version} benötigt {installer_name} und {checksum_name}.")
|
||||
download_url = _validate_update_url(str(installer.get("browser_download_url", "")), self.trusted_host)
|
||||
checksum_url = _validate_update_url(str(checksum.get("browser_download_url", "")), self.trusted_host)
|
||||
return UpdateInfo(
|
||||
version=version,
|
||||
title=str(release.get("name") or f"SyncVito {version}"),
|
||||
notes=str(release.get("body") or "Keine Versionshinweise vorhanden."),
|
||||
release_url=str(release.get("html_url") or self.base_url),
|
||||
installer_name=installer_name,
|
||||
download_url=download_url,
|
||||
checksum_url=checksum_url,
|
||||
size=max(0, int(installer.get("size") or 0)),
|
||||
)
|
||||
|
||||
def _request(self, url: str, timeout: int): # type: ignore[no-untyped-def]
|
||||
request = urllib.request.Request(
|
||||
_validate_update_url(url, self.trusted_host),
|
||||
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
||||
)
|
||||
opener = urllib.request.build_opener(_TrustedRedirectHandler(self.trusted_host))
|
||||
try:
|
||||
response = opener.open(request, timeout=timeout)
|
||||
_validate_update_url(response.geturl(), self.trusted_host)
|
||||
return response
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise UpdateNetworkError(f"Gitea antwortete mit HTTP {exc.code}.") from exc
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
raise UpdateNetworkError(f"Der Gitea-Server ist nicht erreichbar: {exc}") from exc
|
||||
|
||||
def _read_json(self, url: str) -> Any:
|
||||
try:
|
||||
with self._request(url, timeout=12) as response:
|
||||
payload = response.read(MAX_RELEASE_BYTES + 1)
|
||||
if len(payload) > MAX_RELEASE_BYTES:
|
||||
raise UpdateFormatError("Die Release-Antwort ist unerwartet groß.")
|
||||
return json.loads(payload)
|
||||
except UpdateError:
|
||||
raise
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, TypeError) as exc:
|
||||
raise UpdateFormatError("Gitea hat ungültige Release-Daten geliefert.") from exc
|
||||
|
||||
def _read_checksum(self, url: str, filename: str) -> str:
|
||||
try:
|
||||
with self._request(url, timeout=12) as response:
|
||||
payload = response.read(MAX_CHECKSUM_BYTES + 1)
|
||||
if len(payload) > MAX_CHECKSUM_BYTES:
|
||||
raise UpdateFormatError("Die Prüfsummendatei ist unerwartet groß.")
|
||||
content = payload.decode("utf-8")
|
||||
except UpdateError:
|
||||
raise
|
||||
except UnicodeDecodeError as exc:
|
||||
raise UpdateFormatError("Die Update-Prüfsumme ist ungültig.") from exc
|
||||
fallback = ""
|
||||
for raw_line in content.splitlines():
|
||||
parts = raw_line.strip().replace(" ", " ").split()
|
||||
if not parts or not _SHA256_PATTERN.fullmatch(parts[0]):
|
||||
continue
|
||||
digest = parts[0].lower()
|
||||
if len(parts) == 1:
|
||||
fallback = digest
|
||||
elif " ".join(parts[1:]).lstrip("*").strip().casefold() == filename.casefold():
|
||||
return digest
|
||||
if fallback:
|
||||
return fallback
|
||||
raise UpdateFormatError(f"Keine SHA-256-Prüfsumme für {filename} gefunden.")
|
||||
|
||||
def _remove_old_downloads(self, keep: Path) -> None:
|
||||
for candidate in self.update_directory.glob("SyncVitoSetup-*.exe"):
|
||||
if candidate != keep:
|
||||
try:
|
||||
candidate.unlink()
|
||||
except OSError:
|
||||
continue
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Zentrale, aus ``version.txt`` geladene Anwendungsversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
candidates: list[Path] = []
|
||||
bundle_directory = getattr(sys, "_MEIPASS", None)
|
||||
if bundle_directory:
|
||||
candidates.append(Path(bundle_directory) / "version.txt")
|
||||
candidates.append(Path(__file__).resolve().parents[2] / "version.txt")
|
||||
for candidate in candidates:
|
||||
try:
|
||||
value = candidate.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
continue
|
||||
if value:
|
||||
return value
|
||||
raise RuntimeError("version.txt fehlt oder ist leer.")
|
||||
|
||||
|
||||
__version__ = _read_version()
|
||||
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
|
||||
from syncvito.config_store import ConfigStore
|
||||
from syncvito.models import AppConfig, AppSettings, SyncDirection, SyncTask
|
||||
|
||||
|
||||
def test_config_roundtrip(tmp_path: Path) -> None:
|
||||
path = tmp_path / "config.json"
|
||||
config = AppConfig(
|
||||
tasks=[
|
||||
SyncTask(
|
||||
name="Archiv",
|
||||
source=str(tmp_path / "quelle"),
|
||||
target=str(tmp_path / "ziel"),
|
||||
direction=SyncDirection.BIDIRECTIONAL,
|
||||
selected_paths=["Dokumente"],
|
||||
)
|
||||
],
|
||||
settings=AppSettings(font_size=12, ui_scale=110, check_updates_on_start=False),
|
||||
)
|
||||
ConfigStore(path).save(config)
|
||||
|
||||
loaded = ConfigStore(path).load()
|
||||
|
||||
assert loaded.settings.font_size == 12
|
||||
assert loaded.settings.ui_scale == 110
|
||||
assert loaded.settings.check_updates_on_start is False
|
||||
assert loaded.tasks[0].name == "Archiv"
|
||||
assert loaded.tasks[0].direction is SyncDirection.BIDIRECTIONAL
|
||||
assert loaded.tasks[0].selected_paths == ["Dokumente"]
|
||||
@@ -0,0 +1,26 @@
|
||||
from syncvito.coordinator import derive_overall_status
|
||||
from syncvito.models import TaskRuntimeState
|
||||
|
||||
|
||||
def test_no_active_task_is_red() -> None:
|
||||
assert derive_overall_status([], globally_paused=False) == ("paused", "Inaktiv")
|
||||
assert derive_overall_status([TaskRuntimeState.PAUSED], globally_paused=False) == ("paused", "Inaktiv")
|
||||
|
||||
|
||||
def test_healthy_active_tasks_are_green() -> None:
|
||||
states = [TaskRuntimeState.ACTIVE, TaskRuntimeState.PAUSED]
|
||||
assert derive_overall_status(states, globally_paused=False) == ("active", "Bereit")
|
||||
|
||||
|
||||
def test_waiting_lock_has_priority_over_active_task() -> None:
|
||||
states = [TaskRuntimeState.ACTIVE, TaskRuntimeState.WAITING_FOR_RELEASE]
|
||||
assert derive_overall_status(states, globally_paused=False) == ("error", "Wartet auf Dateifreigabe")
|
||||
|
||||
|
||||
def test_error_has_highest_priority() -> None:
|
||||
states = [TaskRuntimeState.WAITING_FOR_RELEASE, TaskRuntimeState.ERROR]
|
||||
assert derive_overall_status(states, globally_paused=False) == ("error", "Fehler")
|
||||
|
||||
|
||||
def test_global_pause_is_red_without_errors() -> None:
|
||||
assert derive_overall_status([TaskRuntimeState.ACTIVE], globally_paused=True) == ("paused", "Pausiert")
|
||||
@@ -0,0 +1,126 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
||||
from syncvito.state_store import StateStore
|
||||
from syncvito.sync_engine import SyncEngine
|
||||
|
||||
|
||||
def _engine(tmp_path: Path) -> SyncEngine:
|
||||
return SyncEngine(StateStore(tmp_path / "state.json"))
|
||||
|
||||
|
||||
def test_one_way_copy_and_mirror_delete(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(source / "neu.txt").write_text("aktuell", encoding="utf-8")
|
||||
(target / "alt.txt").write_text("veraltet", encoding="utf-8")
|
||||
task = SyncTask(
|
||||
name="Einweg",
|
||||
source=str(source),
|
||||
target=str(target),
|
||||
delete_mode=DeleteMode.MIRROR,
|
||||
)
|
||||
|
||||
result = _engine(tmp_path).synchronize(task)
|
||||
|
||||
assert result.successful
|
||||
assert (target / "neu.txt").read_text(encoding="utf-8") == "aktuell"
|
||||
assert not (target / "alt.txt").exists()
|
||||
|
||||
|
||||
def test_selection_limits_synced_files(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "auswahl").mkdir(parents=True)
|
||||
target.mkdir()
|
||||
(source / "auswahl" / "ja.txt").write_text("ja", encoding="utf-8")
|
||||
(source / "nein.txt").write_text("nein", encoding="utf-8")
|
||||
task = SyncTask(
|
||||
name="Auswahl",
|
||||
source=str(source),
|
||||
target=str(target),
|
||||
selected_paths=["auswahl"],
|
||||
)
|
||||
|
||||
_engine(tmp_path).synchronize(task)
|
||||
|
||||
assert (target / "auswahl" / "ja.txt").exists()
|
||||
assert not (target / "nein.txt").exists()
|
||||
|
||||
|
||||
def test_bidirectional_newer_file_wins(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
left = source / "datei.txt"
|
||||
right = target / "datei.txt"
|
||||
left.write_text("links", encoding="utf-8")
|
||||
right.write_text("rechts", encoding="utf-8")
|
||||
os.utime(left, ns=(2_000_000_000, 2_000_000_000))
|
||||
os.utime(right, ns=(1_000_000_000, 1_000_000_000))
|
||||
task = SyncTask(
|
||||
name="Beide",
|
||||
source=str(source),
|
||||
target=str(target),
|
||||
direction=SyncDirection.BIDIRECTIONAL,
|
||||
)
|
||||
|
||||
result = _engine(tmp_path).synchronize(task)
|
||||
|
||||
assert result.successful
|
||||
assert right.read_text(encoding="utf-8") == "links"
|
||||
|
||||
|
||||
def test_conflict_copy_preserves_replaced_version(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
left = source / "plan.txt"
|
||||
right = target / "plan.txt"
|
||||
left.write_text("neu", encoding="utf-8")
|
||||
right.write_text("alt", encoding="utf-8")
|
||||
os.utime(left, ns=(3_000_000_000, 3_000_000_000))
|
||||
os.utime(right, ns=(1_000_000_000, 1_000_000_000))
|
||||
task = SyncTask(
|
||||
name="Konflikt",
|
||||
source=str(source),
|
||||
target=str(target),
|
||||
direction=SyncDirection.BIDIRECTIONAL,
|
||||
conflict_mode=ConflictMode.CONFLICT_COPY,
|
||||
)
|
||||
|
||||
result = _engine(tmp_path).synchronize(task)
|
||||
|
||||
conflict_files = list(target.glob("plan_conflict_*.txt"))
|
||||
assert result.successful
|
||||
assert result.conflicts == 1
|
||||
assert right.read_text(encoding="utf-8") == "neu"
|
||||
assert conflict_files[0].read_text(encoding="utf-8") == "alt"
|
||||
|
||||
|
||||
def test_bidirectional_mirrors_known_deletion(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(source / "entfernen.txt").write_text("inhalt", encoding="utf-8")
|
||||
task = SyncTask(
|
||||
name="Löschung",
|
||||
source=str(source),
|
||||
target=str(target),
|
||||
direction=SyncDirection.BIDIRECTIONAL,
|
||||
delete_mode=DeleteMode.MIRROR,
|
||||
)
|
||||
engine = _engine(tmp_path)
|
||||
engine.synchronize(task)
|
||||
(target / "entfernen.txt").unlink()
|
||||
|
||||
result = engine.synchronize(task)
|
||||
|
||||
assert result.successful
|
||||
assert not (source / "entfernen.txt").exists()
|
||||
@@ -0,0 +1,165 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import syncvito.updater as updater
|
||||
from syncvito.updater import GiteaUpdateService, UpdateFormatError, UpdateInfo, parse_version
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: bytes, url: str = "https://gitea.example/asset") -> None:
|
||||
self.payload = payload
|
||||
self.offset = 0
|
||||
self.headers = {"Content-Length": str(len(payload))}
|
||||
self.url = url
|
||||
|
||||
def __enter__(self) -> "FakeResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> bool:
|
||||
return False
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if size < 0:
|
||||
size = len(self.payload) - self.offset
|
||||
chunk = self.payload[self.offset : self.offset + size]
|
||||
self.offset += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self.url
|
||||
|
||||
|
||||
def _release(version: str, *, prerelease: bool = False) -> dict[str, object]:
|
||||
installer = f"SyncVitoSetup-{version}.exe"
|
||||
return {
|
||||
"tag_name": f"v{version}",
|
||||
"name": f"SyncVito {version}",
|
||||
"body": "Änderungen",
|
||||
"html_url": f"https://gitea.example/releases/v{version}",
|
||||
"draft": False,
|
||||
"prerelease": prerelease,
|
||||
"assets": [
|
||||
{
|
||||
"name": installer,
|
||||
"browser_download_url": f"https://gitea.example/download/{installer}",
|
||||
"size": 1234,
|
||||
},
|
||||
{
|
||||
"name": f"{installer}.sha256",
|
||||
"browser_download_url": f"https://gitea.example/download/{installer}.sha256",
|
||||
"size": 96,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _info(version: str = "1.1.0") -> UpdateInfo:
|
||||
installer = f"SyncVitoSetup-{version}.exe"
|
||||
return UpdateInfo(
|
||||
version=version,
|
||||
title=f"SyncVito {version}",
|
||||
notes="Änderungen",
|
||||
release_url=f"https://gitea.example/releases/v{version}",
|
||||
installer_name=installer,
|
||||
download_url=f"https://gitea.example/download/{installer}",
|
||||
checksum_url=f"https://gitea.example/download/{installer}.sha256",
|
||||
size=0,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_version_accepts_stable_tags() -> None:
|
||||
assert parse_version("v1.2.3") == (1, 2, 3)
|
||||
assert parse_version("10.20.30") == (10, 20, 30)
|
||||
|
||||
|
||||
def test_parse_version_rejects_prerelease() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
parse_version("1.2.3-beta")
|
||||
|
||||
|
||||
def test_check_reads_latest_installer_release(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||
monkeypatch.setattr(service, "_read_json", lambda _url: _release("1.3.0"))
|
||||
|
||||
update = service.check()
|
||||
|
||||
assert update is not None
|
||||
assert update.version == "1.3.0"
|
||||
assert update.installer_name == "SyncVitoSetup-1.3.0.exe"
|
||||
|
||||
|
||||
def test_check_returns_none_for_current_version(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
service = GiteaUpdateService("1.3.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||
monkeypatch.setattr(service, "_read_json", lambda _url: _release("1.3.0"))
|
||||
|
||||
assert service.check() is None
|
||||
|
||||
|
||||
def test_release_requires_matching_installer(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||
release = _release("1.1.0")
|
||||
release["assets"] = []
|
||||
monkeypatch.setattr(service, "_read_json", lambda _url: release)
|
||||
|
||||
with pytest.raises(UpdateFormatError, match="SyncVitoSetup-1.1.0.exe"):
|
||||
service.check()
|
||||
|
||||
|
||||
def test_download_verifies_checksum(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||
payload = b"MZ-syncvito-installer"
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
responses = iter(
|
||||
[
|
||||
FakeResponse(f"{digest} *SyncVitoSetup-1.1.0.exe\n".encode()),
|
||||
FakeResponse(payload),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(service, "_request", lambda *_args, **_kwargs: next(responses))
|
||||
progress: list[tuple[int, str]] = []
|
||||
|
||||
result = service.download(_info(), lambda value, text: progress.append((value, text)))
|
||||
|
||||
assert result.read_bytes() == payload
|
||||
assert progress[-1][0] == 100
|
||||
|
||||
|
||||
def test_cleanup_removes_only_managed_installers(tmp_path: Path) -> None:
|
||||
service = GiteaUpdateService("1.0.0", base_url="https://gitea.example", update_directory=tmp_path)
|
||||
managed = [tmp_path / "SyncVitoSetup-1.0.1.exe", tmp_path / "SyncVitoSetup-1.0.2.exe.part"]
|
||||
preserved = [tmp_path / "anderes-setup.exe", tmp_path / "SyncVitoSetup-neu.exe"]
|
||||
for path in managed + preserved:
|
||||
path.write_bytes(b"test")
|
||||
|
||||
assert service.cleanup_installers() == (2, 0)
|
||||
assert all(not path.exists() for path in managed)
|
||||
assert all(path.exists() for path in preserved)
|
||||
|
||||
|
||||
def test_update_watcher_waits_before_installer(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
installer = tmp_path / "SyncVitoSetup-1.1.0.exe"
|
||||
bootstrap_log = tmp_path / "bootstrap.log"
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def capture(executable: str, arguments: list[str]) -> None:
|
||||
captured["executable"] = executable
|
||||
captured["arguments"] = arguments
|
||||
|
||||
monkeypatch.setattr(updater, "_start_detached", capture)
|
||||
monkeypatch.setattr(updater.os, "name", "nt")
|
||||
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)
|
||||
|
||||
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 "'/VERYSILENT'" in script
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Erzeugt das native Windows-Programmsymbol aus dem Qt-Vektordrawing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from syncvito.icons import app_icon # noqa: E402
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Schreibt eine hochauflösende ICO-Datei in den Assets-Ordner."""
|
||||
application = QApplication.instance() or QApplication([])
|
||||
output = PROJECT_ROOT / "assets" / "SyncVito.ico"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
pixmap = app_icon(256).pixmap(256, 256)
|
||||
if not pixmap.save(str(output), "ICO"):
|
||||
raise RuntimeError(f"Programmsymbol konnte nicht geschrieben werden: {output}")
|
||||
application.quit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
Reference in new issue
Block a user