Erste Version: Tauri-Grundgerüst, WinGet-Anbindung, Selbst-Updater und Release-Skript
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
No files matched your search
@@ -0,0 +1,5 @@
|
||||
* text=auto eol=lf
|
||||
*.ps1 text working-tree-encoding=UTF-8
|
||||
*.png binary
|
||||
*.ico binary
|
||||
*.icns binary
|
||||
@@ -0,0 +1,64 @@
|
||||
# VitosUpdater – Projektanweisungen
|
||||
|
||||
## Dauerhafte Regel: Release auf Gitea
|
||||
|
||||
Wenn der Nutzer schreibt 'Bau den Installer neu und lade es auf Gitea hoch' (oder sinngemäß: Installer neu bauen / auf Gitea hochladen / Release erstellen), führe IMMER scripts/release.ps1 aus: Version erhöhen, Installer bauen und signieren, latest.json erzeugen, committen, taggen, pushen, Gitea-Release inklusive Assets erstellen und das rollende Release 'latest' aktualisieren. Danach auf Deutsch zusammenfassen (Version, Links, Ergebnis der Prüfung). Antworte immer auf Deutsch, Code und Kommentare auf Englisch.
|
||||
|
||||
Hinweise zum Aufruf (aus dem Projektordner, `pwsh` oder `powershell`):
|
||||
|
||||
- Standard (Patch-Version): `powershell -ExecutionPolicy Bypass -File scripts\release.ps1`
|
||||
- Größere Sprünge: `-Bump minor`, `-Bump major` oder `-Version x.y.z`
|
||||
- Nur bauen, nichts veröffentlichen: `-NoPublish` (Ergebnis in `release-out\`)
|
||||
- Das Skript bricht bei jedem Fehler ab und nimmt eine Versionsänderung vor dem Commit wieder zurück.
|
||||
- Fehlt `GITEA_TOKEN`, dem Nutzer auf Deutsch erklären, wie er es anlegt (Gitea → Einstellungen → Anwendungen → Zugriffstoken, Berechtigung `write:repository`), und warten. Das Token nie ausgeben oder speichern.
|
||||
|
||||
## Projektüberblick
|
||||
|
||||
VitosUpdater ist ein Klon von UniGetUI für Windows 10/11: eine einheitliche Oberfläche zum Suchen, Installieren, Aktualisieren und Deinstallieren von Software über mehrere Paketmanager (WinGet, Scoop, Chocolatey, Pip, Npm, .NET Tool, PowerShell Gallery, Cargo, Vcpkg). Technik: Rust (Edition 2021) + Tauri 2, Frontend mit Svelte 5 + TypeScript + Tailwind CSS 4. Der Selbst-Updater nutzt `tauri-plugin-updater` mit `latest.json` auf dem eigenen Gitea (`https://gitea.davito.synology.me`, Repo `Davito/VitosUpdater`).
|
||||
|
||||
## Ordnerstruktur
|
||||
|
||||
```
|
||||
build.ps1 Komfortskript für Installer-Build
|
||||
scripts\release.ps1 Vollständiger Release-Ablauf (siehe oben)
|
||||
scripts\cdp.mjs Entwicklungshilfe: JavaScript in der laufenden WebView2 auswerten
|
||||
src\ Frontend (Svelte)
|
||||
i18n\de.json, t.ts ALLE sichtbaren Texte (nur Deutsch) und Formatierer
|
||||
views\ Ansichten (Entdecken, Updates, Installiert, Bundles, Einstellungen)
|
||||
components\ UI-Bausteine (Sidebar, VirtualTable, DetailsPanel, UpdateDialog, ...)
|
||||
stores\ Zustand (Svelte-5-Runes)
|
||||
lib\ api.ts (typisierte Tauri-Befehle), types.ts, Hilfsfunktionen
|
||||
src-tauri\ Backend (Rust)
|
||||
src\managers\ Trait PackageManager + ein Modul je Manager (winget\ ...)
|
||||
src\updater\ Selbst-Update (mod.rs: Zustand, Scheduler, Befehle; policy.rs: reine Logik)
|
||||
src\settings\, commands\, process.rs, logging.rs, models.rs, error.rs
|
||||
tests\fixtures\ Echte Beispielausgaben der Paketmanager für Parser-Tests
|
||||
```
|
||||
|
||||
Konfiguration und Zustand der App liegen in `%APPDATA%\VitosUpdater` (Logs unter `logs\`).
|
||||
|
||||
## Bauen und Testen
|
||||
|
||||
Voraussetzungen: Rust (MSVC), Node.js LTS, WebView2, VS Build Tools (C++), Tauri CLI (`cargo install tauri-cli --version "^2"`).
|
||||
|
||||
- Entwickeln: `cargo tauri dev` (aus dem Projektordner)
|
||||
- Rust prüfen: in `src-tauri` → `cargo check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`
|
||||
- Frontend prüfen: `npm run check` (svelte-check) und `npm run build`
|
||||
- Das Projekt muss nach jeder Änderung ohne Fehler und Warnungen kompilieren.
|
||||
- Parser immer mit echter Beispielausgabe testen (Fixtures in `src-tauri\tests\fixtures`). Keine CLI-Flags raten: im Zweifel lokal ausprobieren.
|
||||
|
||||
## Sprache und Texte
|
||||
|
||||
- Die Oberfläche ist ausschließlich Deutsch: UI-Texte, Schaltflächen, Menüs, Tooltips, Dialoge, Tray, Benachrichtigungen, Fehlermeldungen, Einstellungen, Installer, README.
|
||||
- Korrektes Deutsch mit echten Umlauten und ß, korrekte Grammatik und Zeichensetzung, deutsche Formate (dd.MM.yyyy, 24 Stunden, Dezimalkomma, „1.234,5 MB“).
|
||||
- Neutrale Ansprache ohne „du/Sie“ (z. B. „Updates suchen“, „Alle aktualisieren“); einheitliche Begriffe (Paket, Quelle, Aktualisierung, Deinstallieren, Warteschlange, Einstellungen).
|
||||
- Alle Texte stehen zentral in `src/i18n/de.json` und werden über `t()`/`tp()` verwendet. Nie Texte in Komponenten hartcodieren. Ausnahme: Eigennamen (WinGet, Scoop, Chocolatey).
|
||||
- Texte, die im Backend entstehen (Fehlermeldungen, Benachrichtigungen), sind ebenfalls Deutsch.
|
||||
- Code, Bezeichner und Code-Kommentare sind Englisch. Commit-Nachrichten sind Deutsch (sie erscheinen als Release-Notizen im Update-Popup).
|
||||
|
||||
## Geheimnisse
|
||||
|
||||
- Niemals Geheimnisse einchecken oder ausgeben: Gitea-Token, Signierschlüssel (`updater.key`), Passwörter, `.env`-Dateien.
|
||||
- Der private Signierschlüssel liegt außerhalb des Repos (`%USERPROFILE%\.vitosupdater\updater.key`); nur der öffentliche Schlüssel steht in `tauri.conf.json`.
|
||||
- Das Gitea-Token kommt aus der Umgebungsvariable `GITEA_TOKEN` oder der git-ignorierten Datei `.env.local`. Git-Pushes laufen über den Windows Git Credential Manager.
|
||||
- TLS-Prüfung nie abschalten (keine `dangerous_*`-Optionen im Updater).
|
||||
@@ -0,0 +1,22 @@
|
||||
node_modules/
|
||||
dist/
|
||||
target/
|
||||
src-tauri/gen/
|
||||
release-out/
|
||||
|
||||
# Geheimnisse und lokale Konfiguration – niemals einchecken
|
||||
*.key
|
||||
*.key.pub.bak
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pem
|
||||
*.pfx
|
||||
*.sig.key
|
||||
|
||||
# Editor und Betriebssystem
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
@@ -0,0 +1,64 @@
|
||||
# VitosUpdater – Projektanweisungen
|
||||
|
||||
## Dauerhafte Regel: Release auf Gitea
|
||||
|
||||
Wenn der Nutzer schreibt 'Bau den Installer neu und lade es auf Gitea hoch' (oder sinngemäß: Installer neu bauen / auf Gitea hochladen / Release erstellen), führe IMMER scripts/release.ps1 aus: Version erhöhen, Installer bauen und signieren, latest.json erzeugen, committen, taggen, pushen, Gitea-Release inklusive Assets erstellen und das rollende Release 'latest' aktualisieren. Danach auf Deutsch zusammenfassen (Version, Links, Ergebnis der Prüfung). Antworte immer auf Deutsch, Code und Kommentare auf Englisch.
|
||||
|
||||
Hinweise zum Aufruf (aus dem Projektordner, `pwsh` oder `powershell`):
|
||||
|
||||
- Standard (Patch-Version): `powershell -ExecutionPolicy Bypass -File scripts\release.ps1`
|
||||
- Größere Sprünge: `-Bump minor`, `-Bump major` oder `-Version x.y.z`
|
||||
- Nur bauen, nichts veröffentlichen: `-NoPublish` (Ergebnis in `release-out\`)
|
||||
- Das Skript bricht bei jedem Fehler ab und nimmt eine Versionsänderung vor dem Commit wieder zurück.
|
||||
- Fehlt `GITEA_TOKEN`, dem Nutzer auf Deutsch erklären, wie er es anlegt (Gitea → Einstellungen → Anwendungen → Zugriffstoken, Berechtigung `write:repository`), und warten. Das Token nie ausgeben oder speichern.
|
||||
|
||||
## Projektüberblick
|
||||
|
||||
VitosUpdater ist ein Klon von UniGetUI für Windows 10/11: eine einheitliche Oberfläche zum Suchen, Installieren, Aktualisieren und Deinstallieren von Software über mehrere Paketmanager (WinGet, Scoop, Chocolatey, Pip, Npm, .NET Tool, PowerShell Gallery, Cargo, Vcpkg). Technik: Rust (Edition 2021) + Tauri 2, Frontend mit Svelte 5 + TypeScript + Tailwind CSS 4. Der Selbst-Updater nutzt `tauri-plugin-updater` mit `latest.json` auf dem eigenen Gitea (`https://gitea.davito.synology.me`, Repo `Davito/VitosUpdater`).
|
||||
|
||||
## Ordnerstruktur
|
||||
|
||||
```
|
||||
build.ps1 Komfortskript für Installer-Build
|
||||
scripts\release.ps1 Vollständiger Release-Ablauf (siehe oben)
|
||||
scripts\cdp.mjs Entwicklungshilfe: JavaScript in der laufenden WebView2 auswerten
|
||||
src\ Frontend (Svelte)
|
||||
i18n\de.json, t.ts ALLE sichtbaren Texte (nur Deutsch) und Formatierer
|
||||
views\ Ansichten (Entdecken, Updates, Installiert, Bundles, Einstellungen)
|
||||
components\ UI-Bausteine (Sidebar, VirtualTable, DetailsPanel, UpdateDialog, ...)
|
||||
stores\ Zustand (Svelte-5-Runes)
|
||||
lib\ api.ts (typisierte Tauri-Befehle), types.ts, Hilfsfunktionen
|
||||
src-tauri\ Backend (Rust)
|
||||
src\managers\ Trait PackageManager + ein Modul je Manager (winget\ ...)
|
||||
src\updater\ Selbst-Update (mod.rs: Zustand, Scheduler, Befehle; policy.rs: reine Logik)
|
||||
src\settings\, commands\, process.rs, logging.rs, models.rs, error.rs
|
||||
tests\fixtures\ Echte Beispielausgaben der Paketmanager für Parser-Tests
|
||||
```
|
||||
|
||||
Konfiguration und Zustand der App liegen in `%APPDATA%\VitosUpdater` (Logs unter `logs\`).
|
||||
|
||||
## Bauen und Testen
|
||||
|
||||
Voraussetzungen: Rust (MSVC), Node.js LTS, WebView2, VS Build Tools (C++), Tauri CLI (`cargo install tauri-cli --version "^2"`).
|
||||
|
||||
- Entwickeln: `cargo tauri dev` (aus dem Projektordner)
|
||||
- Rust prüfen: in `src-tauri` → `cargo check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`
|
||||
- Frontend prüfen: `npm run check` (svelte-check) und `npm run build`
|
||||
- Das Projekt muss nach jeder Änderung ohne Fehler und Warnungen kompilieren.
|
||||
- Parser immer mit echter Beispielausgabe testen (Fixtures in `src-tauri\tests\fixtures`). Keine CLI-Flags raten: im Zweifel lokal ausprobieren.
|
||||
|
||||
## Sprache und Texte
|
||||
|
||||
- Die Oberfläche ist ausschließlich Deutsch: UI-Texte, Schaltflächen, Menüs, Tooltips, Dialoge, Tray, Benachrichtigungen, Fehlermeldungen, Einstellungen, Installer, README.
|
||||
- Korrektes Deutsch mit echten Umlauten und ß, korrekte Grammatik und Zeichensetzung, deutsche Formate (dd.MM.yyyy, 24 Stunden, Dezimalkomma, „1.234,5 MB“).
|
||||
- Neutrale Ansprache ohne „du/Sie“ (z. B. „Updates suchen“, „Alle aktualisieren“); einheitliche Begriffe (Paket, Quelle, Aktualisierung, Deinstallieren, Warteschlange, Einstellungen).
|
||||
- Alle Texte stehen zentral in `src/i18n/de.json` und werden über `t()`/`tp()` verwendet. Nie Texte in Komponenten hartcodieren. Ausnahme: Eigennamen (WinGet, Scoop, Chocolatey).
|
||||
- Texte, die im Backend entstehen (Fehlermeldungen, Benachrichtigungen), sind ebenfalls Deutsch.
|
||||
- Code, Bezeichner und Code-Kommentare sind Englisch. Commit-Nachrichten sind Deutsch (sie erscheinen als Release-Notizen im Update-Popup).
|
||||
|
||||
## Geheimnisse
|
||||
|
||||
- Niemals Geheimnisse einchecken oder ausgeben: Gitea-Token, Signierschlüssel (`updater.key`), Passwörter, `.env`-Dateien.
|
||||
- Der private Signierschlüssel liegt außerhalb des Repos (`%USERPROFILE%\.vitosupdater\updater.key`); nur der öffentliche Schlüssel steht in `tauri.conf.json`.
|
||||
- Das Gitea-Token kommt aus der Umgebungsvariable `GITEA_TOKEN` oder der git-ignorierten Datei `.env.local`. Git-Pushes laufen über den Windows Git Credential Manager.
|
||||
- TLS-Prüfung nie abschalten (keine `dangerous_*`-Optionen im Updater).
|
||||
@@ -0,0 +1,100 @@
|
||||
# VitosUpdater
|
||||
|
||||
VitosUpdater ist eine einheitliche Oberfläche zum Suchen, Installieren, Aktualisieren und Deinstallieren von Software über mehrere Paketmanager unter Windows 10/11. Die Oberfläche ist vollständig deutsch.
|
||||
|
||||
**Stand:** Phase 1 (WinGet, Entdecken/Installiert/Updates) und der Selbst-Updater sind umgesetzt. Warteschlange, weitere Paketmanager, Bundles, Tray und Backup folgen in den nächsten Phasen.
|
||||
|
||||
## Funktionen (aktueller Stand)
|
||||
|
||||
- WinGet-Erkennung beim Start (PATH, danach `%LOCALAPPDATA%\Microsoft\WindowsApps`) mit Versionsanzeige; Hinweis und Ein-Klick-Installation des „App-Installers“, falls WinGet fehlt
|
||||
- Optional: PowerShell-Modul `Microsoft.WinGet.Client` (strukturierte JSON-Daten); ohne Modul wird die lokalisierte Tabellenausgabe von `winget.exe` ausgewertet
|
||||
- Ansichten: Pakete entdecken (Suche), Software-Updates (mit Zähler in der Seitenleiste), Installierte Pakete – mit Details-Panel und virtualisierter Tabelle (flüssig auch mit 5000+ Zeilen)
|
||||
- Dunkles Design (Standard), helles Design, „System folgen“, Akzentfarbe wählbar
|
||||
- Fensterposition und -größe werden gemerkt
|
||||
- **Selbst-Update** von VitosUpdater (siehe unten)
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
| Komponente | Hinweis |
|
||||
|---|---|
|
||||
| Rust (rustup, MSVC-Toolchain) | `rustup default stable-x86_64-pc-windows-msvc` |
|
||||
| Node.js LTS | inkl. npm |
|
||||
| WebView2-Laufzeit | unter Windows 11 vorinstalliert |
|
||||
| Visual Studio Build Tools | Workload „Desktopentwicklung mit C++“ |
|
||||
| Tauri CLI | `cargo install tauri-cli --version "^2"` |
|
||||
| WinGet | Teil des „App-Installers“ (Microsoft Store) |
|
||||
|
||||
## Entwickeln und Bauen
|
||||
|
||||
```powershell
|
||||
npm install # einmalig
|
||||
cargo tauri dev # Entwicklungsmodus mit Hot Reload
|
||||
|
||||
npm run check # Frontend-Typprüfung (svelte-check)
|
||||
cd src-tauri
|
||||
cargo check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test # inkl. Parser-Tests mit echter WinGet-Ausgabe
|
||||
|
||||
.\build.ps1 # Prüfungen + Installer (NSIS) + portable EXE
|
||||
```
|
||||
|
||||
Der portable Build liegt unter `src-tauri\target\release\vitos-updater.exe`, der Installer unter `src-tauri\target\release\bundle\nsis\`.
|
||||
|
||||
Konfiguration, Zustand und Logs liegen in `%APPDATA%\VitosUpdater` (Logs unter `logs\`).
|
||||
|
||||
## Selbst-Update über Gitea
|
||||
|
||||
VitosUpdater prüft nach dem Start und danach alle 30 Minuten (einstellbar unter *Einstellungen → Programm-Updates*, auch bei ausgeblendetem Fenster), ob eine neue Version vorliegt. Quelle:
|
||||
|
||||
```
|
||||
https://gitea.davito.synology.me/Davito/VitosUpdater/releases/download/latest/latest.json
|
||||
```
|
||||
|
||||
- Updates werden mit einem Minisign-Schlüsselpaar signiert und vor der Installation geprüft. Zusätzlich ist `requireSignedVersion` aktiv: Die Signatur ist an die Versionsnummer gebunden, ein Downgrade durch eine manipulierte `latest.json` ist nicht möglich.
|
||||
- Die Versionen werden nach SemVer verglichen; es werden nie ältere Versionen angeboten.
|
||||
- Bei einem neuen Update erscheint ein Popup mit Versionswechsel, Datum, Größe und den Release-Notizen. Möglich sind „Jetzt aktualisieren“, „Später erinnern“ (4 Stunden) und „Diese Version überspringen“. Eine übersprungene Version unter *Einstellungen* wieder freigeben.
|
||||
- Ist das Fenster ausgeblendet oder minimiert, erscheint zuerst eine Windows-Benachrichtigung. Das Popup wird gezeigt, sobald das Fenster wieder geöffnet wird. Nach der Installation startet VitosUpdater neu und zeigt „Aktualisiert auf Version x.y.z“ mit den Änderungen.
|
||||
|
||||
### Zertifikat (Synology / Gitea)
|
||||
|
||||
Der Updater nutzt den Windows-Zertifikatsspeicher (Schannel) und prüft das TLS-Zertifikat immer. Die Prüfung wird **nicht** abgeschaltet. Der Server `gitea.davito.synology.me` verwendet derzeit ein öffentlich vertrauenswürdiges Let's-Encrypt-Zertifikat.
|
||||
|
||||
Erscheint in der App die Meldung „… weil Windows dem Zertifikat nicht vertraut“ (z. B. bei einem selbst signierten Synology-Zertifikat), die Zertifizierungsstelle in Windows als vertrauenswürdig eintragen:
|
||||
|
||||
1. DSM: *Systemsteuerung → Sicherheit → Zertifikat → Aktion → Zertifikat exportieren*; aus dem Archiv die Datei `syno-ca-cert.crt` (die CA, nicht den privaten Schlüssel) entnehmen.
|
||||
2. In PowerShell (ohne Administratorrechte, gilt für den aktuellen Benutzer):
|
||||
```powershell
|
||||
Import-Certificate -FilePath "$env:USERPROFILE\Downloads\syno-ca-cert.crt" -CertStoreLocation Cert:\CurrentUser\Root
|
||||
```
|
||||
Für alle Benutzer (Administrator): `-CertStoreLocation Cert:\LocalMachine\Root`.
|
||||
3. VitosUpdater neu starten.
|
||||
|
||||
Prüfen lässt sich die Verbindung mit `curl.exe -I https://gitea.davito.synology.me`.
|
||||
|
||||
## Release veröffentlichen
|
||||
|
||||
Alles erledigt `scripts\release.ps1` (Version erhöhen, bauen, signieren, `latest.json`, Commit, Tag, Push, Gitea-Release, rollendes Release `latest`, Prüfung):
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts\release.ps1 # Patch-Version
|
||||
powershell -ExecutionPolicy Bypass -File scripts\release.ps1 -Bump minor
|
||||
powershell -ExecutionPolicy Bypass -File scripts\release.ps1 -Version 1.2.0
|
||||
powershell -ExecutionPolicy Bypass -File scripts\release.ps1 -NoPublish # nur bauen, Ergebnis in release-out\
|
||||
```
|
||||
|
||||
Commit-Nachrichten auf Deutsch schreiben: Sie bilden die Release-Notizen im Update-Popup.
|
||||
|
||||
### Einmalige Einrichtung
|
||||
|
||||
1. **Zugriffstoken:** Gitea → *Einstellungen → Anwendungen → Zugriffstoken*, Berechtigung *Repository: Lesen und Schreiben* (`write:repository`). Danach entweder dauerhaft `setx GITEA_TOKEN "<token>"` (neues Terminal öffnen) oder die Zeile `GITEA_TOKEN=<token>` in die Datei `.env.local` im Projektordner schreiben. Beides ist von Git ausgeschlossen.
|
||||
2. **Git-Push:** läuft über den Windows Git Credential Manager (beim ersten Push anmelden).
|
||||
3. **Signierschlüssel:** liegt unter `%USERPROFILE%\.vitosupdater\updater.key` (außerhalb des Repos, nur für den Benutzer lesbar). **Unbedingt sichern** (z. B. Passwortmanager oder verschlüsseltes Backup): Ohne diesen Schlüssel können keine Updates für bereits installierte Versionen signiert werden. Neu erzeugen: `cargo tauri signer generate -w "%USERPROFILE%\.vitosupdater\updater.key"` – danach den öffentlichen Schlüssel in `src-tauri\tauri.conf.json` (`plugins.updater.pubkey`) eintragen; alle bereits installierten Versionen müssten dann manuell neu installiert werden.
|
||||
|
||||
Optional kann der Schlüssel über `TAURI_SIGNING_PRIVATE_KEY` bzw. `TAURI_SIGNING_PRIVATE_KEY_PATH` (und `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`) bereitgestellt werden.
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- Das PowerShell-Modul `Microsoft.WinGet.Client` wird beim Start erkannt und genutzt, wenn vorhanden. Diese Strecke ist mangels installiertem Modul auf dem Entwicklungsrechner noch nicht mit echten Daten getestet; bei jedem Fehler wird automatisch auf die Kommandozeile zurückgegriffen.
|
||||
- Die Beschreibung in den Paketdetails übernimmt die Zeilenumbrüche von WinGet.
|
||||
- Ein Klick auf die Windows-Benachrichtigung öffnet das Fenster nicht zuverlässig; das Popup erscheint, sobald das Fenster geöffnet oder fokussiert wird.
|
||||
@@ -0,0 +1,47 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Baut VitosUpdater lokal (Installer + portable EXE) ohne Veröffentlichung.
|
||||
|
||||
.DESCRIPTION
|
||||
Führt Frontend-Prüfung und Rust-Prüfungen aus und baut danach mit `cargo tauri build`.
|
||||
Ergebnis: src-tauri\target\release\bundle\nsis\*-setup.exe sowie src-tauri\target\release\vitos-updater.exe (portabel).
|
||||
Für signierte Updater-Artefakte muss der Signierschlüssel gesetzt sein (siehe README, Abschnitt "Release").
|
||||
Für eine Veröffentlichung auf Gitea stattdessen scripts\release.ps1 verwenden.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param([switch]$SkipChecks)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-Location $PSScriptRoot
|
||||
|
||||
function Invoke-Native([string]$Description, [scriptblock]$Command) {
|
||||
& $Command
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Description ist fehlgeschlagen (Exit-Code $LASTEXITCODE)." }
|
||||
}
|
||||
|
||||
$defaultKey = Join-Path $env:USERPROFILE '.vitosupdater\updater.key'
|
||||
if (-not $env:TAURI_SIGNING_PRIVATE_KEY -and -not $env:TAURI_SIGNING_PRIVATE_KEY_PATH -and (Test-Path $defaultKey)) {
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY_PATH = $defaultKey
|
||||
}
|
||||
$env:CI = 'true'
|
||||
|
||||
if (-not (Test-Path 'node_modules')) { Invoke-Native 'npm install' { npm install } }
|
||||
|
||||
if (-not $SkipChecks) {
|
||||
Write-Host '==> Prüfungen' -ForegroundColor Cyan
|
||||
Invoke-Native 'svelte-check' { npm run check }
|
||||
Push-Location 'src-tauri'
|
||||
try {
|
||||
Invoke-Native 'cargo clippy' { cargo clippy --all-targets -- -D warnings }
|
||||
Invoke-Native 'cargo test' { cargo test }
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
|
||||
Write-Host '==> Bauen (cargo tauri build)' -ForegroundColor Cyan
|
||||
Invoke-Native 'cargo tauri build' { cargo tauri build }
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Fertig. Ergebnisse:' -ForegroundColor Green
|
||||
Get-ChildItem 'src-tauri\target\release\bundle\nsis' -Filter '*-setup.exe' | ForEach-Object { Write-Host " Installer: $($_.FullName)" }
|
||||
Write-Host " Portabel: $(Resolve-Path 'src-tauri\target\release\vitos-updater.exe')"
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VitosUpdater</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "vitos-updater",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tauri-apps/cli": "^2.12.4",
|
||||
"svelte": "^5.57.3",
|
||||
"svelte-check": "^4.7.7",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@lucide/svelte": "^1.65.0",
|
||||
"@tauri-apps/api": "^2.12.4",
|
||||
"@tauri-apps/plugin-autostart": "^2.7.0",
|
||||
"@tauri-apps/plugin-notification": "^2.8.0",
|
||||
"@tauri-apps/plugin-opener": "^2.7.2",
|
||||
"@tauri-apps/plugin-process": "^2.4.0",
|
||||
"@tauri-apps/plugin-updater": "^2.14.1",
|
||||
"dompurify": "^3.4.17",
|
||||
"marked": "^18.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Dev helper: evaluates JavaScript in the running WebView2 page via the DevTools protocol.
|
||||
// Usage: node scripts/cdp.mjs "<expression>" [--shot out.png]
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const shotIdx = args.indexOf("--shot");
|
||||
const shot = shotIdx >= 0 ? args.splice(shotIdx, 2)[1] : null;
|
||||
const expression = args[0] ?? "document.title";
|
||||
|
||||
const targets = await (await fetch("http://127.0.0.1:9222/json")).json();
|
||||
const page = targets.find((t) => t.type === "page");
|
||||
if (!page) throw new Error("Keine Seite gefunden");
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.id && pending.has(msg.id)) pending.get(msg.id)(msg);
|
||||
};
|
||||
const send = (method, params = {}) =>
|
||||
new Promise((resolve) => {
|
||||
const myId = ++id;
|
||||
pending.set(myId, resolve);
|
||||
ws.send(JSON.stringify({ id: myId, method, params }));
|
||||
});
|
||||
await new Promise((resolve) => (ws.onopen = resolve));
|
||||
|
||||
const res = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
|
||||
console.log(JSON.stringify(res.result?.result?.value ?? res.result, null, 2));
|
||||
if (shot) {
|
||||
const img = await send("Page.captureScreenshot", { format: "png" });
|
||||
writeFileSync(shot, Buffer.from(img.result.data, "base64"));
|
||||
}
|
||||
ws.close();
|
||||
@@ -0,0 +1,403 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Baut, signiert und veröffentlicht eine neue VitosUpdater-Version auf Gitea.
|
||||
|
||||
.DESCRIPTION
|
||||
1. Prüft Git-Zustand, cargo check, cargo clippy, cargo test und svelte-check
|
||||
2. Erhöht die Version in tauri.conf.json, Cargo.toml und package.json
|
||||
3. Baut den NSIS-Installer samt signiertem Updater-Artefakt (.sig)
|
||||
4. Erzeugt latest.json
|
||||
5. Committet die Version, setzt den Tag vX.Y.Z und pusht beides
|
||||
6. Erstellt das Gitea-Release vX.Y.Z und lädt Installer, .sig und latest.json hoch
|
||||
7. Aktualisiert das rollende Release "latest"
|
||||
8. Prüft die öffentliche latest.json und die Installer-URL
|
||||
|
||||
Voraussetzungen: Umgebungsvariable GITEA_TOKEN (oder Datei .env.local mit GITEA_TOKEN=...),
|
||||
Signierschlüssel unter %USERPROFILE%\.vitosupdater\updater.key (oder TAURI_SIGNING_PRIVATE_KEY[_PATH]).
|
||||
|
||||
.PARAMETER Bump
|
||||
Art der Versionserhöhung: patch (Standard), minor oder major.
|
||||
|
||||
.PARAMETER Version
|
||||
Explizite Version x.y.z (überschreibt -Bump).
|
||||
|
||||
.PARAMETER NoPublish
|
||||
Nur bauen und latest.json erzeugen. Es wird nichts committet, getaggt, gepusht oder hochgeladen;
|
||||
die Versionsänderung wird danach zurückgenommen. Die Ergebnisse liegen in release-out\.
|
||||
|
||||
.PARAMETER SkipChecks
|
||||
Überspringt cargo check/clippy/test und svelte-check (nur für schnelle Wiederholungen).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('patch', 'minor', 'major')]
|
||||
[string]$Bump = 'patch',
|
||||
[string]$Version,
|
||||
[switch]$NoPublish,
|
||||
[switch]$SkipChecks
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Console]::OutputEncoding = [Text.Encoding]::UTF8
|
||||
$OutputEncoding = [Text.Encoding]::UTF8
|
||||
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch { }
|
||||
|
||||
$GiteaBase = 'https://gitea.davito.synology.me'
|
||||
$Owner = 'Davito'
|
||||
$Repo = 'VitosUpdater'
|
||||
$Branch = 'main'
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
$OutDir = Join-Path $Root 'release-out'
|
||||
$Utf8NoBom = New-Object Text.UTF8Encoding($false)
|
||||
$VersionFiles = @('src-tauri/tauri.conf.json', 'src-tauri/Cargo.toml', 'src-tauri/Cargo.lock', 'package.json', 'package-lock.json')
|
||||
$script:Token = $null
|
||||
$script:Committed = $false
|
||||
$script:Bumped = $false
|
||||
|
||||
function Write-Step([string]$Text) { Write-Host ''; Write-Host "==> $Text" -ForegroundColor Cyan }
|
||||
function Write-Ok([string]$Text) { Write-Host " [OK] $Text" -ForegroundColor Green }
|
||||
function Write-Info([string]$Text) { Write-Host " $Text" -ForegroundColor Gray }
|
||||
|
||||
function Invoke-Native([string]$Description, [scriptblock]$Command) {
|
||||
& $Command
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Description ist fehlgeschlagen (Exit-Code $LASTEXITCODE)." }
|
||||
}
|
||||
|
||||
function Get-GitOutput([string[]]$GitArgs) {
|
||||
$result = & git @GitArgs
|
||||
if ($LASTEXITCODE -ne 0) { throw "git $($GitArgs -join ' ') ist fehlgeschlagen (Exit-Code $LASTEXITCODE)." }
|
||||
return $result
|
||||
}
|
||||
|
||||
function Read-Token {
|
||||
if ($env:GITEA_TOKEN) { return $env:GITEA_TOKEN.Trim() }
|
||||
$file = Join-Path $Root '.env.local'
|
||||
if (Test-Path $file) {
|
||||
foreach ($line in Get-Content $file -Encoding UTF8) {
|
||||
if ($line -match '^\s*GITEA_TOKEN\s*=\s*(.+?)\s*$') { return $Matches[1].Trim('"', "'") }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Show-TokenHelp {
|
||||
Write-Host ''
|
||||
Write-Host 'Es wurde kein Gitea-Zugriffstoken gefunden.' -ForegroundColor Yellow
|
||||
Write-Host ' 1. In Gitea anmelden: Einstellungen -> Anwendungen -> Zugriffstoken'
|
||||
Write-Host ' 2. Neues Token erzeugen, Berechtigung: Repository = Lesen und Schreiben (write:repository)'
|
||||
Write-Host ' 3. Token hinterlegen, entweder'
|
||||
Write-Host ' dauerhaft: setx GITEA_TOKEN "<token>" (danach neues Terminal öffnen)'
|
||||
Write-Host " oder lokal: Datei .env.local im Projektordner mit der Zeile GITEA_TOKEN=<token>"
|
||||
Write-Host ' (.env.local ist in .gitignore eingetragen und wird nie eingecheckt.)'
|
||||
}
|
||||
|
||||
function Get-CurrentVersion {
|
||||
$conf = Get-Content (Join-Path $Root 'src-tauri/tauri.conf.json') -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
return [string]$conf.version
|
||||
}
|
||||
|
||||
function Get-NextVersion([string]$Current) {
|
||||
if ($Version) {
|
||||
if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "Ungültige Version '$Version'. Erwartet wird x.y.z." }
|
||||
return $Version
|
||||
}
|
||||
if ($Current -notmatch '^(\d+)\.(\d+)\.(\d+)$') { throw "Aktuelle Version '$Current' ist keine einfache SemVer-Version." }
|
||||
$major = [int]$Matches[1]; $minor = [int]$Matches[2]; $patch = [int]$Matches[3]
|
||||
switch ($Bump) {
|
||||
'major' { return '{0}.0.0' -f ($major + 1) }
|
||||
'minor' { return '{0}.{1}.0' -f $major, ($minor + 1) }
|
||||
default { return '{0}.{1}.{2}' -f $major, $minor, ($patch + 1) }
|
||||
}
|
||||
}
|
||||
|
||||
function Set-FirstMatch([string]$Path, [string]$Pattern, [string]$Replacement) {
|
||||
$full = Join-Path $Root $Path
|
||||
$text = [IO.File]::ReadAllText($full, $Utf8NoBom)
|
||||
$regex = New-Object Text.RegularExpressions.Regex($Pattern, [Text.RegularExpressions.RegexOptions]::Multiline)
|
||||
if (-not $regex.IsMatch($text)) { throw "Versionszeile in $Path nicht gefunden." }
|
||||
[IO.File]::WriteAllText($full, $regex.Replace($text, $Replacement, 1), $Utf8NoBom)
|
||||
}
|
||||
|
||||
function Set-ProjectVersion([string]$New) {
|
||||
Set-FirstMatch 'src-tauri/tauri.conf.json' '^( "version": ")[^"]+(")' ('${1}' + $New + '${2}')
|
||||
Set-FirstMatch 'src-tauri/Cargo.toml' '^(version = ")[^"]+(")' ('${1}' + $New + '${2}')
|
||||
Invoke-Native 'npm version' { npm version $New --no-git-tag-version --allow-same-version | Out-Null }
|
||||
}
|
||||
|
||||
function Undo-VersionChange {
|
||||
if ($script:Bumped -and -not $script:Committed) {
|
||||
Write-Info 'Versionsänderung wird zurückgenommen ...'
|
||||
& git checkout -- @VersionFiles 2>$null | Out-Null
|
||||
$script:Bumped = $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ReleaseNotes([string]$NewVersion) {
|
||||
$lastTag = $null
|
||||
$found = & git describe --tags --abbrev=0 --match 'v*' 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $found) { $lastTag = [string]$found }
|
||||
$range = 'HEAD'
|
||||
if ($lastTag) { $range = "$lastTag..HEAD" }
|
||||
$lines = & git -c core.quotepath=off log $range --no-merges --pretty=format:'- %s (%h)' --grep='^Release v' --invert-grep
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git log ist fehlgeschlagen.' }
|
||||
$text = ($lines | Where-Object { $_ }) -join "`n"
|
||||
if (-not $text) { $text = '- Interne Verbesserungen und Fehlerbehebungen' }
|
||||
return $text
|
||||
}
|
||||
|
||||
# ---------- Gitea-API ----------
|
||||
function Invoke-Gitea([string]$Method, [string]$Path, $Body = $null) {
|
||||
$uri = "$GiteaBase/api/v1/repos/$Owner/$Repo$Path"
|
||||
$headers = @{ Authorization = "token $script:Token"; Accept = 'application/json' }
|
||||
if ($null -ne $Body) {
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Depth 6))
|
||||
return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $bytes
|
||||
}
|
||||
return Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers
|
||||
}
|
||||
|
||||
function Get-ReleaseByTag([string]$Tag) {
|
||||
try {
|
||||
return Invoke-Gitea 'GET' "/releases/tags/$Tag"
|
||||
} catch {
|
||||
$status = $null
|
||||
if ($_.Exception.PSObject.Properties.Name -contains 'Response' -and $_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
|
||||
if ($status -eq 404) { return $null }
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
function Send-Asset([int]$ReleaseId, [string]$File, [string]$Name) {
|
||||
$url = "$GiteaBase/api/v1/repos/$Owner/$Repo/releases/$ReleaseId/assets?name=" + [uri]::EscapeDataString($Name)
|
||||
# Das Token wird über stdin an curl übergeben, damit es nicht in der Prozessliste erscheint.
|
||||
$config = 'header = "Authorization: token ' + $script:Token + '"'
|
||||
$response = $config | & curl.exe --silent --show-error --fail --config - -X POST -F "attachment=@$File" $url
|
||||
if ($LASTEXITCODE -ne 0) { throw "Upload von '$Name' ist fehlgeschlagen (curl-Exit-Code $LASTEXITCODE)." }
|
||||
Write-Ok "Hochgeladen: $Name ($([math]::Round((Get-Item $File).Length / 1MB, 1)) MB)"
|
||||
}
|
||||
|
||||
function Clear-ReleaseAssets([int]$ReleaseId) {
|
||||
$release = Invoke-Gitea 'GET' "/releases/$ReleaseId"
|
||||
foreach ($asset in @($release.assets)) {
|
||||
Invoke-Gitea 'DELETE' "/releases/$ReleaseId/assets/$($asset.id)" | Out-Null
|
||||
Write-Info "Altes Asset entfernt: $($asset.name)"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-HttpStatus([string]$Url) {
|
||||
$code = & curl.exe --silent --location --head --output NUL --write-out '%{http_code}' $Url
|
||||
if ($code -eq '405' -or $code -eq '000') {
|
||||
$code = & curl.exe --silent --location --range 0-0 --output NUL --write-out '%{http_code}' $Url
|
||||
}
|
||||
return [string]$code
|
||||
}
|
||||
|
||||
# ---------- Ablauf ----------
|
||||
Set-Location $Root
|
||||
$started = Get-Date
|
||||
try {
|
||||
Write-Host 'VitosUpdater – Release-Skript' -ForegroundColor White
|
||||
|
||||
# --- 1. Voraussetzungen und Prüfungen ---
|
||||
Write-Step '1/8 Voraussetzungen und Qualitätsprüfungen'
|
||||
if (-not $NoPublish) {
|
||||
$script:Token = Read-Token
|
||||
if (-not $script:Token) { Show-TokenHelp; throw 'GITEA_TOKEN fehlt.' }
|
||||
Write-Ok 'Gitea-Token gefunden (wird nicht angezeigt)'
|
||||
}
|
||||
|
||||
$keyPath = Join-Path $env:USERPROFILE '.vitosupdater\updater.key'
|
||||
if (-not $env:TAURI_SIGNING_PRIVATE_KEY -and -not $env:TAURI_SIGNING_PRIVATE_KEY_PATH) {
|
||||
if (-not (Test-Path $keyPath)) {
|
||||
throw "Signierschlüssel nicht gefunden: $keyPath. Erzeugen mit: cargo tauri signer generate -w `"$keyPath`" (öffentlichen Schlüssel danach in tauri.conf.json eintragen)."
|
||||
}
|
||||
$env:TAURI_SIGNING_PRIVATE_KEY_PATH = $keyPath
|
||||
}
|
||||
Write-Ok 'Signierschlüssel gefunden'
|
||||
|
||||
$dirty = & git status --porcelain
|
||||
if ($LASTEXITCODE -ne 0) { throw 'git status ist fehlgeschlagen. Ist dies ein Git-Repository?' }
|
||||
if ($dirty) {
|
||||
Write-Host ($dirty -join "`n") -ForegroundColor Yellow
|
||||
throw 'Das Arbeitsverzeichnis ist nicht sauber. Bitte zuerst committen oder verwerfen.'
|
||||
}
|
||||
$branch = (Get-GitOutput @('rev-parse', '--abbrev-ref', 'HEAD') | Select-Object -First 1).Trim()
|
||||
if ($branch -ne $Branch) { throw "Es muss der Branch '$Branch' ausgecheckt sein (aktuell: '$branch')." }
|
||||
if (-not $NoPublish) {
|
||||
Invoke-Native 'git fetch' { git fetch origin --tags --force --quiet }
|
||||
$remoteMain = & git rev-parse --verify --quiet "refs/remotes/origin/$Branch"
|
||||
if ($LASTEXITCODE -eq 0 -and $remoteMain) {
|
||||
$behind = [int](Get-GitOutput @('rev-list', '--count', "HEAD..origin/$Branch") | Select-Object -First 1)
|
||||
if ($behind -gt 0) { throw "Der lokale Branch liegt $behind Commit(s) hinter origin/$Branch. Bitte zuerst git pull ausführen." }
|
||||
}
|
||||
}
|
||||
Write-Ok 'Git-Zustand ist sauber'
|
||||
|
||||
if ($SkipChecks) {
|
||||
Write-Info 'Prüfungen werden übersprungen (-SkipChecks).'
|
||||
} else {
|
||||
Push-Location (Join-Path $Root 'src-tauri')
|
||||
try {
|
||||
Invoke-Native 'cargo check' { cargo check --all-targets }
|
||||
Write-Ok 'cargo check'
|
||||
Invoke-Native 'cargo clippy' { cargo clippy --all-targets -- -D warnings }
|
||||
Write-Ok 'cargo clippy (ohne Warnungen)'
|
||||
Invoke-Native 'cargo test' { cargo test }
|
||||
Write-Ok 'cargo test'
|
||||
} finally { Pop-Location }
|
||||
Invoke-Native 'svelte-check' { npm run check }
|
||||
Write-Ok 'svelte-check'
|
||||
}
|
||||
|
||||
# --- 2. Version erhöhen ---
|
||||
Write-Step '2/8 Version erhöhen'
|
||||
$current = Get-CurrentVersion
|
||||
$new = Get-NextVersion $current
|
||||
if (-not $NoPublish) {
|
||||
$existingTag = & git tag --list "v$new"
|
||||
if ($existingTag) { throw "Der Tag v$new existiert bereits. Bitte eine höhere Version wählen." }
|
||||
if ([version]$new -le [version]$current) { throw "Die neue Version $new ist nicht größer als die aktuelle Version $current." }
|
||||
}
|
||||
$script:Bumped = $true
|
||||
Set-ProjectVersion $new
|
||||
Write-Ok "Version $current -> $new (tauri.conf.json, Cargo.toml, package.json)"
|
||||
|
||||
# --- 3. Bauen ---
|
||||
Write-Step '3/8 Installer bauen und signieren (cargo tauri build)'
|
||||
$env:CI = 'true'
|
||||
Invoke-Native 'cargo tauri build' { cargo tauri build }
|
||||
$nsisDir = Join-Path $Root 'src-tauri/target/release/bundle/nsis'
|
||||
$installer = Get-ChildItem $nsisDir -Filter "*_${new}_*-setup.exe" | Select-Object -First 1
|
||||
if (-not $installer) { throw "Installer für Version $new wurde nicht gefunden in $nsisDir." }
|
||||
$sigFile = "$($installer.FullName).sig"
|
||||
if (-not (Test-Path $sigFile)) { throw "Signaturdatei fehlt: $sigFile. Wurde createUpdaterArtifacts aktiviert und der Schlüssel gefunden?" }
|
||||
Write-Ok "Installer: $($installer.Name) ($([math]::Round($installer.Length / 1MB, 1)) MB)"
|
||||
Write-Ok 'Signatur (.sig) erzeugt'
|
||||
|
||||
# --- 4. latest.json ---
|
||||
Write-Step '4/8 latest.json erzeugen'
|
||||
$notes = Get-ReleaseNotes $new
|
||||
$assetUrl = "$GiteaBase/$Owner/$Repo/releases/download/v$new/$($installer.Name)"
|
||||
$manifest = [ordered]@{
|
||||
version = $new
|
||||
notes = $notes
|
||||
pub_date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
|
||||
platforms = [ordered]@{
|
||||
'windows-x86_64' = [ordered]@{
|
||||
signature = (Get-Content $sigFile -Raw -Encoding UTF8).Trim()
|
||||
url = $assetUrl
|
||||
size = [int64]$installer.Length
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Test-Path $OutDir) { Remove-Item $OutDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $OutDir | Out-Null
|
||||
$latestFile = Join-Path $OutDir 'latest.json'
|
||||
[IO.File]::WriteAllText($latestFile, ($manifest | ConvertTo-Json -Depth 6), $Utf8NoBom)
|
||||
Copy-Item $installer.FullName $OutDir
|
||||
Copy-Item $sigFile $OutDir
|
||||
$outInstaller = Join-Path $OutDir $installer.Name
|
||||
$outSig = Join-Path $OutDir "$($installer.Name).sig"
|
||||
Write-Ok "latest.json -> $latestFile"
|
||||
|
||||
if ($NoPublish) {
|
||||
Undo-VersionChange
|
||||
Write-Step 'Fertig (-NoPublish)'
|
||||
Write-Ok "Version $new gebaut. Dateien liegen in: $OutDir"
|
||||
return
|
||||
}
|
||||
|
||||
# --- 5. Commit, Tag, Push ---
|
||||
Write-Step "5/8 Commit, Tag v$new und Push"
|
||||
Invoke-Native 'git add' { git add -- @VersionFiles }
|
||||
Invoke-Native 'git commit' { git commit --quiet -m "Release v$new" }
|
||||
$script:Committed = $true
|
||||
Invoke-Native 'git tag' { git tag -a "v$new" -m "Release v$new" }
|
||||
Invoke-Native 'git push (Branch)' { git push origin $Branch }
|
||||
Invoke-Native 'git push (Tag)' { git push origin "v$new" }
|
||||
Write-Ok "Commit und Tag v$new gepusht"
|
||||
|
||||
# --- 6. Gitea-Release ---
|
||||
Write-Step "6/8 Gitea-Release v$new erstellen"
|
||||
$release = Get-ReleaseByTag "v$new"
|
||||
if ($release) {
|
||||
Write-Info 'Release existiert bereits, Assets werden ersetzt.'
|
||||
Clear-ReleaseAssets $release.id
|
||||
} else {
|
||||
$release = Invoke-Gitea 'POST' '/releases' @{
|
||||
tag_name = "v$new"
|
||||
target_commitish = $Branch
|
||||
name = "VitosUpdater $new"
|
||||
body = $notes
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
}
|
||||
}
|
||||
Write-Ok "Release angelegt: $($release.html_url)"
|
||||
Send-Asset $release.id $outInstaller $installer.Name
|
||||
Send-Asset $release.id $outSig "$($installer.Name).sig"
|
||||
Send-Asset $release.id $latestFile 'latest.json'
|
||||
|
||||
# --- 7. Rollendes Release "latest" ---
|
||||
Write-Step '7/8 Rollendes Release "latest" aktualisieren'
|
||||
Invoke-Native 'git tag latest' { git tag -f latest HEAD | Out-Null }
|
||||
Invoke-Native 'git push latest' { git push --force origin refs/tags/latest }
|
||||
$rolling = Get-ReleaseByTag 'latest'
|
||||
$rollingBody = "Aktuelle Version: **$new**`n`n$notes"
|
||||
if ($rolling) {
|
||||
Invoke-Gitea 'PATCH' "/releases/$($rolling.id)" @{ name = "Aktuellste Version ($new)"; body = $rollingBody } | Out-Null
|
||||
Clear-ReleaseAssets $rolling.id
|
||||
} else {
|
||||
$rolling = Invoke-Gitea 'POST' '/releases' @{
|
||||
tag_name = 'latest'
|
||||
target_commitish = $Branch
|
||||
name = "Aktuellste Version ($new)"
|
||||
body = $rollingBody
|
||||
draft = $false
|
||||
prerelease = $false
|
||||
}
|
||||
}
|
||||
Send-Asset $rolling.id $outInstaller $installer.Name
|
||||
Send-Asset $rolling.id $outSig "$($installer.Name).sig"
|
||||
Send-Asset $rolling.id $latestFile 'latest.json'
|
||||
Write-Ok 'Rollendes Release aktualisiert'
|
||||
|
||||
# --- 8. Prüfung ---
|
||||
Write-Step '8/8 Veröffentlichung prüfen'
|
||||
$latestUrl = "$GiteaBase/$Owner/$Repo/releases/download/latest/latest.json"
|
||||
$raw = Invoke-WebRequest -Uri $latestUrl -UseBasicParsing
|
||||
$content = $raw.Content
|
||||
if ($content -is [byte[]]) { $content = [Text.Encoding]::UTF8.GetString($content) }
|
||||
$remote = $content | ConvertFrom-Json
|
||||
if ($remote.version -ne $new) { throw "Öffentliche latest.json enthält Version $($remote.version), erwartet wurde $new." }
|
||||
Write-Ok "latest.json öffentlich erreichbar, Version $($remote.version)"
|
||||
$platform = $remote.platforms.'windows-x86_64'
|
||||
$status = Get-HttpStatus $platform.url
|
||||
if ($status -ne '200' -and $status -ne '206') { throw "Installer-URL antwortet mit HTTP ${status}: $($platform.url)" }
|
||||
Write-Ok "Installer-URL antwortet mit HTTP $status"
|
||||
$rollingStatus = Get-HttpStatus "$GiteaBase/$Owner/$Repo/releases/download/latest/$($installer.Name)"
|
||||
if ($rollingStatus -ne '200' -and $rollingStatus -ne '206') { throw "Installer im Release 'latest' antwortet mit HTTP $rollingStatus." }
|
||||
Write-Ok "Installer im Release 'latest' antwortet mit HTTP $rollingStatus"
|
||||
|
||||
$elapsed = [math]::Round(((Get-Date) - $started).TotalMinutes, 1)
|
||||
Write-Host ''
|
||||
Write-Host '==================== Zusammenfassung ====================' -ForegroundColor Green
|
||||
Write-Host " Version: $new (vorher $current)"
|
||||
Write-Host " Release: $($release.html_url)"
|
||||
Write-Host " Rollendes Release: $GiteaBase/$Owner/$Repo/releases/tag/latest"
|
||||
Write-Host " Update-Endpunkt: $latestUrl"
|
||||
Write-Host " Installer: $($platform.url)"
|
||||
Write-Host " Dauer: $elapsed Minuten"
|
||||
Write-Host '==========================================================' -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Host ''
|
||||
Write-Host "FEHLER: $($_.Exception.Message)" -ForegroundColor Red
|
||||
if ($script:Committed) {
|
||||
Write-Host 'Der Versions-Commit wurde bereits lokal erstellt. Nach der Fehlerbehebung zurücknehmen mit: git tag -d v<Version>; git reset --hard HEAD~1 (bereits gepushte Teile ggf. auf Gitea prüfen), danach das Skript erneut starten.' -ForegroundColor Yellow
|
||||
} else {
|
||||
Undo-VersionChange
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "vitos-updater"
|
||||
version = "0.1.0"
|
||||
description = "VitosUpdater – einheitliche Paketverwaltung für Windows"
|
||||
edition = "2021"
|
||||
rust-version = "1.82"
|
||||
|
||||
[lib]
|
||||
name = "vitos_updater_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-updater = { version = "2", default-features = false, features = ["native-tls", "system-proxy", "zip"] }
|
||||
semver = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["process", "time", "io-util", "sync", "rt-multi-thread", "macros"] }
|
||||
async-trait = "0.1"
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
tracing-appender = "0.2"
|
||||
unicode-width = "0.2"
|
||||
which = "8"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Standardberechtigungen des Hauptfensters",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "core:window:allow-show", "core:window:allow-set-focus", "opener:allow-open-url", "notification:default", "updater:default"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,126 @@
|
||||
//! Tauri-Befehle, die die Oberfläche aufruft.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::managers::winget::WingetManager;
|
||||
use crate::managers::PackageManager;
|
||||
use crate::models::{ManagerId, ManagerInfo, Package, PackageDetails, Source};
|
||||
use crate::process;
|
||||
|
||||
/// Registrierte Paketmanager.
|
||||
pub struct Registry {
|
||||
managers: HashMap<ManagerId, Arc<dyn PackageManager>>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
/// Erstellt die Registry mit allen verfügbaren Implementierungen.
|
||||
pub fn new() -> Self {
|
||||
let mut managers: HashMap<ManagerId, Arc<dyn PackageManager>> = HashMap::new();
|
||||
managers.insert(ManagerId::Winget, Arc::new(WingetManager::new()));
|
||||
Self { managers }
|
||||
}
|
||||
|
||||
fn get(&self, id: ManagerId) -> AppResult<Arc<dyn PackageManager>> {
|
||||
self.managers
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::Command("Dieser Paketmanager wird noch nicht unterstützt.".to_owned()))
|
||||
}
|
||||
|
||||
async fn detect_all(&self) -> Vec<ManagerInfo> {
|
||||
let mut infos = Vec::new();
|
||||
for manager in self.managers.values() {
|
||||
infos.push(manager.detect().await);
|
||||
}
|
||||
infos
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Registry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn logged<T>(what: &str, result: AppResult<T>) -> AppResult<T> {
|
||||
if let Err(err) = &result {
|
||||
tracing::error!(%err, "{what} fehlgeschlagen");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Erkennt alle Paketmanager (Pfad, Version, Zusatzkomponenten).
|
||||
#[tauri::command]
|
||||
pub async fn detect_managers(registry: State<'_, Registry>) -> AppResult<Vec<ManagerInfo>> {
|
||||
Ok(registry.detect_all().await)
|
||||
}
|
||||
|
||||
/// Sucht Pakete.
|
||||
#[tauri::command]
|
||||
pub async fn search_packages(registry: State<'_, Registry>, manager: ManagerId, query: String) -> AppResult<Vec<Package>> {
|
||||
let query = query.trim().to_owned();
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
logged("Paketsuche", registry.get(manager)?.search(&query).await)
|
||||
}
|
||||
|
||||
/// Listet installierte Pakete.
|
||||
#[tauri::command]
|
||||
pub async fn list_installed(registry: State<'_, Registry>, manager: ManagerId) -> AppResult<Vec<Package>> {
|
||||
logged("Liste der installierten Pakete", registry.get(manager)?.list_installed().await)
|
||||
}
|
||||
|
||||
/// Listet verfügbare Aktualisierungen.
|
||||
#[tauri::command]
|
||||
pub async fn list_updates(registry: State<'_, Registry>, manager: ManagerId) -> AppResult<Vec<Package>> {
|
||||
logged("Suche nach Aktualisierungen", registry.get(manager)?.list_updates().await)
|
||||
}
|
||||
|
||||
/// Lädt Paketdetails.
|
||||
#[tauri::command]
|
||||
pub async fn get_package_details(registry: State<'_, Registry>, package: Package) -> AppResult<PackageDetails> {
|
||||
logged("Paketdetails", registry.get(package.manager)?.get_details(&package).await)
|
||||
}
|
||||
|
||||
/// Listet die Quellen eines Managers.
|
||||
#[tauri::command]
|
||||
pub async fn list_sources(registry: State<'_, Registry>, manager: ManagerId) -> AppResult<Vec<Source>> {
|
||||
logged("Quellenliste", registry.get(manager)?.list_sources().await)
|
||||
}
|
||||
|
||||
async fn run_powershell(script: &str, timeout: Duration) -> AppResult<()> {
|
||||
let shell = which::which("powershell")
|
||||
.map_err(|_| AppError::ManagerMissing("Windows PowerShell".to_owned()))?;
|
||||
let out = process::run(&shell, &["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], timeout).await?;
|
||||
if out.code == Some(0) {
|
||||
Ok(())
|
||||
} else {
|
||||
let detail = out.stderr.lines().find(|l| !l.trim().is_empty()).unwrap_or("unbekannter Fehler");
|
||||
Err(AppError::Command(format!("Die Installation ist fehlgeschlagen: {detail}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Installiert das PowerShell-Modul `Microsoft.WinGet.Client` für den aktuellen Benutzer.
|
||||
#[tauri::command]
|
||||
pub async fn install_winget_module(registry: State<'_, Registry>) -> AppResult<Vec<ManagerInfo>> {
|
||||
let script = "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Stop'; \
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force | Out-Null; \
|
||||
Install-Module -Name Microsoft.WinGet.Client -Scope CurrentUser -Repository PSGallery -Force";
|
||||
logged("Modulinstallation", run_powershell(script, Duration::from_secs(300)).await)?;
|
||||
Ok(registry.detect_all().await)
|
||||
}
|
||||
|
||||
/// Registriert bzw. installiert den „App-Installer“ (enthält WinGet).
|
||||
#[tauri::command]
|
||||
pub async fn install_app_installer(registry: State<'_, Registry>) -> AppResult<Vec<ManagerInfo>> {
|
||||
let script = "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Stop'; \
|
||||
Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
|
||||
logged("Installation des App-Installers", run_powershell(script, Duration::from_secs(300)).await)?;
|
||||
Ok(registry.detect_all().await)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Zentrale Fehlertypen. Die Meldungen sind für die Anzeige in der Oberfläche gedacht und daher deutsch.
|
||||
|
||||
use serde::{Serialize, Serializer};
|
||||
|
||||
/// Fehler, die an die Oberfläche weitergereicht werden.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
/// Der Paketmanager ist nicht installiert oder nicht gefunden worden.
|
||||
#[error("{0} wurde nicht gefunden.")]
|
||||
ManagerMissing(String),
|
||||
/// Ein externer Prozess konnte nicht gestartet werden.
|
||||
#[error("Der Prozess „{program}“ konnte nicht gestartet werden: {source}")]
|
||||
Spawn {
|
||||
program: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// Ein externer Prozess hat das Zeitlimit überschritten.
|
||||
#[error("Zeitüberschreitung nach {0} Sekunden.")]
|
||||
Timeout(u64),
|
||||
/// Ein Paketmanager hat mit einem Fehlercode geendet.
|
||||
#[error("{0}")]
|
||||
Command(String),
|
||||
/// Die Ausgabe eines Paketmanagers konnte nicht gelesen werden.
|
||||
#[error("Die Ausgabe konnte nicht gelesen werden: {0}")]
|
||||
Parse(String),
|
||||
/// Ein-/Ausgabefehler.
|
||||
#[error("Ein-/Ausgabefehler: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// JSON-Fehler.
|
||||
#[error("Ungültige JSON-Daten: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl Serialize for AppError {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergebnistyp der Anwendung.
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
@@ -0,0 +1,146 @@
|
||||
//! VitosUpdater – einheitliche Paketverwaltung für Windows.
|
||||
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
pub mod logging;
|
||||
pub mod managers;
|
||||
pub mod models;
|
||||
pub mod process;
|
||||
pub mod settings;
|
||||
pub mod updater;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{Manager, PhysicalPosition, PhysicalSize, WebviewWindow, WindowEvent};
|
||||
|
||||
use settings::WindowState;
|
||||
|
||||
/// Letzte normale (nicht maximierte) Geometrie und Zähler für das Entprellen des Speicherns.
|
||||
struct WindowTracker {
|
||||
dir: PathBuf,
|
||||
normal: Mutex<Option<WindowState>>,
|
||||
generation: AtomicU64,
|
||||
}
|
||||
|
||||
fn restore_window(window: &WebviewWindow, saved: Option<WindowState>) {
|
||||
if let Some(state) = saved {
|
||||
let monitors: Vec<(i32, i32, u32, u32)> = window
|
||||
.available_monitors()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|m| (m.position().x, m.position().y, m.size().width, m.size().height))
|
||||
.collect();
|
||||
if settings::is_visible_on(&state, &monitors) {
|
||||
let _ = window.set_size(PhysicalSize::new(state.width, state.height));
|
||||
let _ = window.set_position(PhysicalPosition::new(state.x, state.y));
|
||||
}
|
||||
if state.maximized {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
let _ = window.show();
|
||||
}
|
||||
|
||||
fn track_window(window: &WebviewWindow, tracker: &Arc<WindowTracker>) {
|
||||
let maximized = window.is_maximized().unwrap_or(false);
|
||||
let minimized = window.is_minimized().unwrap_or(false);
|
||||
if minimized {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut normal = tracker.normal.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !maximized {
|
||||
if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) {
|
||||
*normal = Some(WindowState { x: pos.x, y: pos.y, width: size.width, height: size.height, maximized: false });
|
||||
}
|
||||
} else if let Some(state) = normal.as_mut() {
|
||||
state.maximized = true;
|
||||
}
|
||||
}
|
||||
let generation = tracker.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let tracker = Arc::clone(tracker);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if tracker.generation.load(Ordering::SeqCst) != generation {
|
||||
return;
|
||||
}
|
||||
let snapshot = *tracker.normal.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(mut state) = snapshot {
|
||||
state.maximized = maximized;
|
||||
if let Err(err) = settings::save_window_state(&tracker.dir, &state) {
|
||||
tracing::warn!(%err, "Fensterzustand konnte nicht gespeichert werden");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Startet die Anwendung.
|
||||
pub fn run() {
|
||||
let dir = logging::data_dir();
|
||||
let _guard = logging::init(&dir);
|
||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), "VitosUpdater startet");
|
||||
|
||||
let tracker = Arc::new(WindowTracker {
|
||||
dir: dir.clone(),
|
||||
normal: Mutex::new(settings::load_window_state(&dir)),
|
||||
generation: AtomicU64::new(0),
|
||||
});
|
||||
let saved = settings::load_window_state(&dir);
|
||||
let event_tracker = Arc::clone(&tracker);
|
||||
|
||||
let result = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(commands::Registry::new())
|
||||
.manage(updater::UpdaterState::new(dir.clone()))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::detect_managers,
|
||||
commands::search_packages,
|
||||
commands::list_installed,
|
||||
commands::list_updates,
|
||||
commands::get_package_details,
|
||||
commands::list_sources,
|
||||
commands::install_winget_module,
|
||||
commands::install_app_installer,
|
||||
updater::updater_ready,
|
||||
updater::get_updater_status,
|
||||
updater::get_update_offer,
|
||||
updater::check_for_update,
|
||||
updater::snooze_update,
|
||||
updater::skip_update,
|
||||
updater::clear_skipped_version,
|
||||
updater::set_update_interval,
|
||||
updater::install_update,
|
||||
updater::take_update_notice,
|
||||
])
|
||||
.setup(move |app| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
restore_window(&window, saved);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(move |window, event| {
|
||||
if matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) {
|
||||
if let Some(webview) = window.app_handle().get_webview_window(window.label()) {
|
||||
track_window(&webview, &event_tracker);
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!());
|
||||
|
||||
if let Err(err) = result {
|
||||
tracing::error!(%err, "Anwendung wurde mit einem Fehler beendet");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Protokollierung in rotierende Logdateien unter `%APPDATA%\VitosUpdater\logs`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_appender::rolling;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// Basisverzeichnis für Konfiguration und Zustand (`%APPDATA%\VitosUpdater`).
|
||||
pub fn data_dir() -> PathBuf {
|
||||
let base = std::env::var_os("APPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
base.join("VitosUpdater")
|
||||
}
|
||||
|
||||
/// Initialisiert die Protokollierung. Der Guard muss bis zum Programmende gehalten werden.
|
||||
pub fn init(dir: &Path) -> Option<WorkerGuard> {
|
||||
let logs = dir.join("logs");
|
||||
std::fs::create_dir_all(&logs).ok()?;
|
||||
let appender = rolling::Builder::new()
|
||||
.rotation(rolling::Rotation::DAILY)
|
||||
.filename_prefix("vitosupdater")
|
||||
.filename_suffix("log")
|
||||
.max_log_files(14)
|
||||
.build(&logs)
|
||||
.ok()?;
|
||||
let (writer, guard) = tracing_appender::non_blocking(appender);
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_writer(writer)
|
||||
.with_ansi(false)
|
||||
.try_init()
|
||||
.ok()?;
|
||||
Some(guard)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
vitos_updater_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Gemeinsame Schnittstelle aller Paketmanager.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::AppResult;
|
||||
use crate::models::{ManagerId, ManagerInfo, Package, PackageDetails, Source};
|
||||
|
||||
pub mod winget;
|
||||
|
||||
/// Einheitliche Schnittstelle für Paketmanager.
|
||||
///
|
||||
/// Die Operationen `install`, `update` und `uninstall` kommen mit der Warteschlange in Phase 2 hinzu,
|
||||
/// weil sie den Ausgabestrom und den Prozess-Lebenszyklus der Warteschlange benötigen.
|
||||
#[async_trait]
|
||||
pub trait PackageManager: Send + Sync {
|
||||
/// Kennung des Managers.
|
||||
fn id(&self) -> ManagerId;
|
||||
|
||||
/// Erkennt Installation, Pfad und Version.
|
||||
async fn detect(&self) -> ManagerInfo;
|
||||
|
||||
/// Sucht Pakete.
|
||||
async fn search(&self, query: &str) -> AppResult<Vec<Package>>;
|
||||
|
||||
/// Listet installierte Pakete.
|
||||
async fn list_installed(&self) -> AppResult<Vec<Package>>;
|
||||
|
||||
/// Listet Pakete mit verfügbarer Aktualisierung.
|
||||
async fn list_updates(&self) -> AppResult<Vec<Package>>;
|
||||
|
||||
/// Lädt Detailinformationen zu einem Paket.
|
||||
async fn get_details(&self, package: &Package) -> AppResult<PackageDetails>;
|
||||
|
||||
/// Listet die konfigurierten Quellen.
|
||||
async fn list_sources(&self) -> AppResult<Vec<Source>>;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! WinGet-Manager: PowerShell-Modul (JSON) wenn vorhanden, sonst Tabellenparser der Kommandozeile.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::PackageManager;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{ExtraInfo, ManagerId, ManagerInfo, Package, PackageDetails, Source};
|
||||
use crate::process::{self, CmdOutput};
|
||||
|
||||
pub mod parser;
|
||||
pub mod ps;
|
||||
|
||||
/// Exit-Code von WinGet bei „keine Treffer“ (0x8A150014), per Test mit WinGet 1.29 ermittelt.
|
||||
const EXIT_NO_MATCH: i32 = 0x8A15_0014_u32 as i32;
|
||||
/// Gemeinsame Argumente, damit WinGet nie auf Eingaben wartet.
|
||||
const COMMON_ARGS: [&str; 2] = ["--accept-source-agreements", "--disable-interactivity"];
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct Detected {
|
||||
path: Option<PathBuf>,
|
||||
module: Option<ps::ModuleInfo>,
|
||||
}
|
||||
|
||||
/// WinGet-Implementierung von [`PackageManager`].
|
||||
#[derive(Default)]
|
||||
pub struct WingetManager {
|
||||
state: RwLock<Detected>,
|
||||
}
|
||||
|
||||
/// Sucht `winget.exe` im PATH und danach in `%LOCALAPPDATA%\Microsoft\WindowsApps`.
|
||||
pub fn locate_winget() -> Option<PathBuf> {
|
||||
if let Ok(path) = which::which("winget") {
|
||||
return Some(path);
|
||||
}
|
||||
let local = std::env::var_os("LOCALAPPDATA")?;
|
||||
let candidate = Path::new(&local).join("Microsoft").join("WindowsApps").join("winget.exe");
|
||||
candidate.is_file().then_some(candidate)
|
||||
}
|
||||
|
||||
/// Formatiert einen WinGet-Exit-Code als hexadezimale Zahl, z. B. `0x8A150014`.
|
||||
pub fn format_exit_code(code: i32) -> String {
|
||||
format!("0x{:08X}", code as u32)
|
||||
}
|
||||
|
||||
impl WingetManager {
|
||||
/// Erstellt einen Manager ohne Erkennung. [`PackageManager::detect`] füllt den Zustand.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn exe(&self) -> AppResult<PathBuf> {
|
||||
if let Some(path) = self.state.read().await.path.clone() {
|
||||
return Ok(path);
|
||||
}
|
||||
self.detect().await;
|
||||
self.state
|
||||
.read()
|
||||
.await
|
||||
.path
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::ManagerMissing("WinGet".to_owned()))
|
||||
}
|
||||
|
||||
async fn module_shell(&self) -> Option<PathBuf> {
|
||||
self.state.read().await.module.as_ref().map(|m| m.shell.clone())
|
||||
}
|
||||
|
||||
/// Führt winget aus; „keine Treffer“ ergibt eine leere Ausgabe, andere Fehler einen Fehler.
|
||||
async fn table(&self, args: &[&str]) -> AppResult<String> {
|
||||
let exe = self.exe().await?;
|
||||
let out: CmdOutput = process::run(&exe, args, QUERY_TIMEOUT).await?;
|
||||
match out.code {
|
||||
Some(0) => Ok(out.stdout),
|
||||
Some(EXIT_NO_MATCH) => Ok(String::new()),
|
||||
// Manche Versionen liefern trotz Teilfehler (z. B. eine nicht erreichbare Quelle) eine Tabelle.
|
||||
Some(code) if !parser::parse_table(&out.stdout).is_empty() => {
|
||||
tracing::warn!(code = %format_exit_code(code), "WinGet meldete einen Fehler, Tabelle wird trotzdem verwendet");
|
||||
Ok(out.stdout)
|
||||
}
|
||||
Some(code) => Err(AppError::Command(format!(
|
||||
"WinGet wurde mit dem Fehlercode {} beendet.",
|
||||
format_exit_code(code)
|
||||
))),
|
||||
None => Err(AppError::Command("WinGet wurde unerwartet beendet.".to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PackageManager for WingetManager {
|
||||
fn id(&self) -> ManagerId {
|
||||
ManagerId::Winget
|
||||
}
|
||||
|
||||
async fn detect(&self) -> ManagerInfo {
|
||||
let path = locate_winget();
|
||||
let version = match &path {
|
||||
Some(p) => process::run(p, &["--version"], Duration::from_secs(20))
|
||||
.await
|
||||
.ok()
|
||||
.filter(|o| o.code == Some(0))
|
||||
.map(|o| o.stdout.trim().trim_start_matches('v').to_owned())
|
||||
.filter(|v| !v.is_empty()),
|
||||
None => None,
|
||||
};
|
||||
let module = ps::detect_module().await;
|
||||
let info = ManagerInfo {
|
||||
id: ManagerId::Winget,
|
||||
name: "WinGet".to_owned(),
|
||||
available: path.is_some() && version.is_some(),
|
||||
path: path.as_ref().map(|p| p.display().to_string()),
|
||||
version: version.clone(),
|
||||
extras: vec![ExtraInfo {
|
||||
name: "Microsoft.WinGet.Client".to_owned(),
|
||||
available: module.is_some(),
|
||||
version: module.as_ref().map(|m| m.version.clone()),
|
||||
}],
|
||||
};
|
||||
*self.state.write().await = Detected { path: path.filter(|_| info.available), module };
|
||||
tracing::info!(available = info.available, version = ?info.version, module = info.extras[0].available, "WinGet erkannt");
|
||||
info
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> AppResult<Vec<Package>> {
|
||||
if let Some(shell) = self.module_shell().await {
|
||||
match ps::search(&shell, query).await {
|
||||
Ok(list) => return Ok(list),
|
||||
Err(err) => tracing::warn!(%err, "Modulsuche fehlgeschlagen, Kommandozeile wird verwendet"),
|
||||
}
|
||||
}
|
||||
let text = self
|
||||
.table(&["search", "--query", query, "--count", "200", COMMON_ARGS[0], COMMON_ARGS[1]])
|
||||
.await?;
|
||||
Ok(parser::parse_packages(&text))
|
||||
}
|
||||
|
||||
async fn list_installed(&self) -> AppResult<Vec<Package>> {
|
||||
if let Some(shell) = self.module_shell().await {
|
||||
match ps::list_installed(&shell).await {
|
||||
Ok(list) => return Ok(list),
|
||||
Err(err) => tracing::warn!(%err, "Modulabfrage fehlgeschlagen, Kommandozeile wird verwendet"),
|
||||
}
|
||||
}
|
||||
let text = self.table(&["list", COMMON_ARGS[0], COMMON_ARGS[1]]).await?;
|
||||
Ok(parser::parse_packages(&text))
|
||||
}
|
||||
|
||||
async fn list_updates(&self) -> AppResult<Vec<Package>> {
|
||||
if let Some(shell) = self.module_shell().await {
|
||||
match ps::list_updates(&shell).await {
|
||||
Ok(list) => return Ok(list),
|
||||
Err(err) => tracing::warn!(%err, "Modulabfrage fehlgeschlagen, Kommandozeile wird verwendet"),
|
||||
}
|
||||
}
|
||||
let text = self.table(&["upgrade", COMMON_ARGS[0], COMMON_ARGS[1]]).await?;
|
||||
Ok(parser::parse_packages(&text))
|
||||
}
|
||||
|
||||
async fn get_details(&self, package: &Package) -> AppResult<PackageDetails> {
|
||||
let mut base: Vec<&str> = vec!["show", "--id", &package.id, "--exact"];
|
||||
if let Some(source) = package.source.as_deref() {
|
||||
base.extend(["--source", source]);
|
||||
}
|
||||
base.extend(COMMON_ARGS);
|
||||
let mut with_versions = base.clone();
|
||||
with_versions.push("--versions");
|
||||
|
||||
let (show, versions) = tokio::join!(self.table(&base), self.table(&with_versions));
|
||||
let mut details = parser::parse_details(&show?);
|
||||
details.versions = parser::parse_versions(&versions.unwrap_or_default());
|
||||
Ok(details)
|
||||
}
|
||||
|
||||
async fn list_sources(&self) -> AppResult<Vec<Source>> {
|
||||
let text = self.table(&["source", "list"]).await?;
|
||||
Ok(parser::parse_table(&text)
|
||||
.into_iter()
|
||||
.map(|cells| Source {
|
||||
name: cells.first().cloned().unwrap_or_default(),
|
||||
url: cells.get(1).cloned().unwrap_or_default(),
|
||||
explicit: cells.get(2).is_some_and(|v| v.eq_ignore_ascii_case("true")),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Parser für die lokalisierte Textausgabe von `winget`.
|
||||
//!
|
||||
//! WinGet richtet Tabellenspalten nach der *Anzeigebreite* aus (breite Zeichen belegen zwei Zellen).
|
||||
//! Spaltenüberschriften sind lokalisiert; die Spalten werden daher aus den Positionen der Überschrift
|
||||
//! abgeleitet und nach ihrem Inhalt (nicht nach ihrem Namen) zugeordnet.
|
||||
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use crate::models::{ensure_unique_keys, ManagerId, Package, PackageDetails};
|
||||
|
||||
/// Eine Zeile als Zellenfolge, bei der breite Zeichen zwei Zellen belegen (zweite Zelle = `'\0'`).
|
||||
fn to_cells(line: &str) -> Vec<char> {
|
||||
let mut cells = Vec::with_capacity(line.len());
|
||||
for ch in line.chars() {
|
||||
let width = ch.width().unwrap_or(0);
|
||||
match width {
|
||||
0 => {}
|
||||
1 => cells.push(ch),
|
||||
_ => {
|
||||
cells.push(ch);
|
||||
cells.extend(std::iter::repeat_n('\0', width - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
fn slice_cells(cells: &[char], start: usize, end: Option<usize>) -> String {
|
||||
if start >= cells.len() {
|
||||
return String::new();
|
||||
}
|
||||
let end = end.map_or(cells.len(), |e| e.min(cells.len()));
|
||||
cells[start..end].iter().filter(|c| **c != '\0').collect::<String>().trim().to_owned()
|
||||
}
|
||||
|
||||
/// Bereinigt Rohausgabe: entfernt Fortschrittsanzeigen (`\r`-Überschreibungen) und Steuerzeichen.
|
||||
pub fn clean_lines(text: &str) -> Vec<String> {
|
||||
text.split('\n')
|
||||
.map(|line| {
|
||||
let line = line.trim_end_matches('\r');
|
||||
let last = line.rsplit('\r').next().unwrap_or(line);
|
||||
last.chars().filter(|c| !c.is_control() || *c == '\t').collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_separator(line: &str) -> bool {
|
||||
let t = line.trim();
|
||||
t.len() >= 3 && t.chars().all(|c| c == '-')
|
||||
}
|
||||
|
||||
/// Zerlegt die erste Tabelle der Ausgabe in Zellen (ohne Kopfzeile).
|
||||
///
|
||||
/// Zeilen nach der Tabelle (Zusammenfassungen wie „4 Aktualisierungen verfügbar.“) werden verworfen.
|
||||
pub fn parse_table(text: &str) -> Vec<Vec<String>> {
|
||||
let lines = clean_lines(text);
|
||||
let Some(sep_idx) = lines.iter().position(|l| is_separator(l)).filter(|i| *i > 0) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let header = to_cells(&lines[sep_idx - 1]);
|
||||
|
||||
// Kandidaten für Zeilen: bis zur ersten Leerzeile.
|
||||
let body: Vec<Vec<char>> = lines[sep_idx + 1..]
|
||||
.iter()
|
||||
.take_while(|l| !l.trim().is_empty())
|
||||
.map(|l| to_cells(l))
|
||||
.collect();
|
||||
if body.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Spaltenanfänge aus der Überschrift (Wortanfänge nach einem Leerzeichen).
|
||||
let mut starts = vec![0usize];
|
||||
for i in 1..header.len() {
|
||||
if header[i] != ' ' && header[i - 1] == ' ' && header[i] != '\0' {
|
||||
starts.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Mehrwörtrige Überschriften: ein Anfang gilt nur, wenn davor in den meisten Zeilen eine Lücke liegt.
|
||||
let gap_ok = |row: &[char], start: usize| row.len() < start || row.get(start - 1).is_none_or(|c| *c == ' ');
|
||||
let threshold = (body.len() * 4).div_ceil(5);
|
||||
starts.retain(|&s| s == 0 || body.iter().filter(|r| gap_ok(r, s)).count() >= threshold);
|
||||
|
||||
let mut rows = Vec::new();
|
||||
for row in &body {
|
||||
// Zeilen, die eine Spaltenlücke verletzen, sind Fließtext (Zusammenfassung) und beenden die Tabelle.
|
||||
if starts.iter().skip(1).any(|&s| !gap_ok(row, s)) {
|
||||
break;
|
||||
}
|
||||
let mut cells = Vec::with_capacity(starts.len());
|
||||
for (i, &s) in starts.iter().enumerate() {
|
||||
cells.push(slice_cells(row, s, starts.get(i + 1).copied()));
|
||||
}
|
||||
if cells.get(1).is_none_or(|id| id.is_empty()) {
|
||||
break;
|
||||
}
|
||||
rows.push(cells);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn looks_like_source(value: &str) -> bool {
|
||||
let mut chars = value.chars();
|
||||
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& value.chars().all(|c| c.is_ascii_alphabetic() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ColumnKind {
|
||||
Available,
|
||||
Match,
|
||||
Source,
|
||||
}
|
||||
|
||||
fn classify(rows: &[Vec<String>], col: usize) -> ColumnKind {
|
||||
let values: Vec<&str> = rows.iter().filter_map(|r| r.get(col)).map(String::as_str).filter(|v| !v.is_empty()).collect();
|
||||
if values.iter().any(|v| v.contains(':')) {
|
||||
ColumnKind::Match
|
||||
} else if !values.is_empty() && values.iter().all(|v| looks_like_source(v)) {
|
||||
ColumnKind::Source
|
||||
} else if values.is_empty() {
|
||||
// Nur leere Zellen: letzte Spalte ist praktisch immer die Quelle.
|
||||
ColumnKind::Source
|
||||
} else {
|
||||
ColumnKind::Available
|
||||
}
|
||||
}
|
||||
|
||||
/// Wandelt eine WinGet-Tabelle (`list`, `upgrade`, `search`) in Pakete um.
|
||||
pub fn parse_packages(text: &str) -> Vec<Package> {
|
||||
let rows = parse_table(text);
|
||||
if rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let width = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let kinds: Vec<(usize, ColumnKind)> = (3..width).map(|c| (c, classify(&rows, c))).collect();
|
||||
let available_col = kinds.iter().find(|(_, k)| *k == ColumnKind::Available).map(|(c, _)| *c);
|
||||
let source_col = kinds.iter().rev().find(|(_, k)| *k == ColumnKind::Source).map(|(c, _)| *c);
|
||||
|
||||
let mut packages: Vec<Package> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let cell = |i: usize| r.get(i).cloned().unwrap_or_default();
|
||||
let non_empty = |s: String| if s.is_empty() { None } else { Some(s) };
|
||||
let version = cell(2).trim_start_matches(['<', '>', ' ']).to_owned();
|
||||
Package::new(
|
||||
ManagerId::Winget,
|
||||
cell(1),
|
||||
cell(0),
|
||||
version,
|
||||
available_col.map(cell).and_then(non_empty),
|
||||
source_col.map(cell).and_then(non_empty),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
ensure_unique_keys(&mut packages);
|
||||
packages
|
||||
}
|
||||
|
||||
/// Liest die Versionsliste aus `winget show --versions`.
|
||||
pub fn parse_versions(text: &str) -> Vec<String> {
|
||||
let lines = clean_lines(text);
|
||||
let Some(sep) = lines.iter().position(|l| is_separator(l)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
lines[sep + 1..]
|
||||
.iter()
|
||||
.map(|l| l.trim().to_owned())
|
||||
.take_while(|l| !l.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn split_key(line: &str) -> Option<(&str, &str)> {
|
||||
let idx = line.find(':')?;
|
||||
let (key, rest) = line.split_at(idx);
|
||||
let rest = &rest[1..];
|
||||
// Schlüssel enthalten keine URL-Teile und sind kurz; URLs wie „https://…“ werden so nicht zerlegt.
|
||||
if key.is_empty() || key.contains("//") || key.len() > 48 || !(rest.is_empty() || rest.starts_with(' ')) {
|
||||
return None;
|
||||
}
|
||||
Some((key.trim(), rest.trim()))
|
||||
}
|
||||
|
||||
fn opt(s: &str) -> Option<String> {
|
||||
if s.is_empty() { None } else { Some(s.to_owned()) }
|
||||
}
|
||||
|
||||
/// Liest die Ausgabe von `winget show`. Bekannte Bezeichnungen (de/en) werden direkt erkannt;
|
||||
/// der Installer-Block wird zusätzlich anhand seines Inhalts erkannt.
|
||||
pub fn parse_details(text: &str) -> PackageDetails {
|
||||
let mut details = PackageDetails::default();
|
||||
let lines = clean_lines(text);
|
||||
let mut block_key: Option<String> = None;
|
||||
let mut block_lines: Vec<String> = Vec::new();
|
||||
let mut blocks: Vec<(String, Vec<String>)> = Vec::new();
|
||||
|
||||
let mut flush = |key: &mut Option<String>, lines: &mut Vec<String>| {
|
||||
if let Some(k) = key.take() {
|
||||
blocks.push((k, std::mem::take(lines)));
|
||||
}
|
||||
};
|
||||
|
||||
for line in &lines {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
if block_key.is_some() {
|
||||
block_lines.push(line.trim_end().to_owned());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
flush(&mut block_key, &mut block_lines);
|
||||
let Some((key, value)) = split_key(line) else { continue };
|
||||
let k = key.to_lowercase();
|
||||
if value.is_empty() {
|
||||
block_key = Some(k);
|
||||
continue;
|
||||
}
|
||||
match k.as_str() {
|
||||
"herausgeber" | "publisher" => details.publisher = opt(value),
|
||||
"startseite" | "homepage" => details.homepage = opt(value),
|
||||
"lizenz" | "license" => details.license = opt(value),
|
||||
"lizenz-url" | "license url" => details.license_url = opt(value),
|
||||
"beschreibung" | "description" => details.description = opt(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flush(&mut block_key, &mut block_lines);
|
||||
|
||||
for (key, body) in blocks {
|
||||
let is_description = matches!(key.as_str(), "beschreibung" | "description");
|
||||
if is_description {
|
||||
let text = body.iter().map(|l| l.trim()).collect::<Vec<_>>().join("\n");
|
||||
details.description = opt(text.trim());
|
||||
continue;
|
||||
}
|
||||
// Installer-Block: Einträge mit URL und Hash erkennen.
|
||||
let pairs: Vec<(String, String)> = body
|
||||
.iter()
|
||||
.filter_map(|l| split_key(l.trim()).map(|(k, v)| (k.to_lowercase(), v.to_owned())))
|
||||
.collect();
|
||||
for (k, v) in pairs {
|
||||
if v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if k.contains("sha256") || k.contains("hash") {
|
||||
details.hash.get_or_insert(v);
|
||||
} else if k.contains("url") && v.starts_with("http") {
|
||||
details.installer_url.get_or_insert(v);
|
||||
} else if k.contains("typ") || k.contains("type") {
|
||||
details.installer_type.get_or_insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
details
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const LIST: &str = include_str!("../../../tests/fixtures/winget_list_de.txt");
|
||||
const UPGRADE: &str = include_str!("../../../tests/fixtures/winget_upgrade_de.txt");
|
||||
const SEARCH: &str = include_str!("../../../tests/fixtures/winget_search_de.txt");
|
||||
const SHOW: &str = include_str!("../../../tests/fixtures/winget_show_de.txt");
|
||||
|
||||
#[test]
|
||||
fn upgrade_table_from_real_output() {
|
||||
let pkgs = parse_packages(UPGRADE);
|
||||
assert_eq!(pkgs.len(), 4, "Zusammenfassungszeile darf kein Paket ergeben");
|
||||
let vlc = pkgs.iter().find(|p| p.id == "VideoLAN.VLC").expect("VLC fehlt");
|
||||
assert_eq!(vlc.name, "VLC media player");
|
||||
assert_eq!(vlc.version, "3.0.23");
|
||||
assert_eq!(vlc.available_version.as_deref(), Some("3.0.24"));
|
||||
assert_eq!(vlc.source.as_deref(), Some("winget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_table_from_real_output() {
|
||||
let pkgs = parse_packages(LIST);
|
||||
assert!(pkgs.len() > 50, "erwartet viele Pakete, erhalten {}", pkgs.len());
|
||||
let arp = pkgs.iter().find(|p| p.id.starts_with("ARP\\")).expect("ARP-Paket fehlt");
|
||||
assert!(arp.source.is_none());
|
||||
let betterbird = pkgs.iter().filter(|p| p.id == "Betterbird.Betterbird").count();
|
||||
assert_eq!(betterbird, 2);
|
||||
let keys: std::collections::HashSet<_> = pkgs.iter().map(|p| &p.key).collect();
|
||||
assert_eq!(keys.len(), pkgs.len(), "Schlüssel müssen eindeutig sein");
|
||||
assert!(pkgs.iter().all(|p| !p.id.is_empty() && !p.name.is_empty()));
|
||||
let anti = pkgs.iter().find(|p| p.id == "Google.Antigravity").expect("Antigravity fehlt");
|
||||
assert!(anti.available_version.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_table_has_match_column() {
|
||||
let pkgs = parse_packages(SEARCH);
|
||||
assert!(!pkgs.is_empty());
|
||||
let vlc = &pkgs[0];
|
||||
assert_eq!(vlc.id, "VideoLAN.VLC");
|
||||
assert_eq!(vlc.version, "3.0.24");
|
||||
assert_eq!(vlc.available_version, None, "Spalte „Übereinstimmung“ ist keine Version");
|
||||
assert_eq!(vlc.source.as_deref(), Some("winget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_noise_is_ignored() {
|
||||
let noisy = format!(" - \r \\ \r | \r\r\n{UPGRADE}");
|
||||
assert_eq!(parse_packages(&noisy).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_message_only_output() {
|
||||
assert!(parse_packages("Es wurde kein Paket gefunden, das den Eingabekriterien entspricht.\n").is_empty());
|
||||
assert!(parse_packages("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_characters_keep_columns_aligned() {
|
||||
// Synthetische Zeile (nicht aus echter Ausgabe): CJK-Zeichen belegen zwei Anzeigezellen.
|
||||
let text = "Name ID Version Quelle\n\
|
||||
----------------------------------------\n\
|
||||
微信 Tencent.WeChat 3.9.1 winget\n\
|
||||
Foo Foo.Bar 1.0 winget\n";
|
||||
let pkgs = parse_packages(text);
|
||||
assert_eq!(pkgs.len(), 2);
|
||||
assert_eq!(pkgs[0].name, "微信");
|
||||
assert_eq!(pkgs[0].id, "Tencent.WeChat");
|
||||
assert_eq!(pkgs[1].id, "Foo.Bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_from_real_output() {
|
||||
let d = parse_details(SHOW);
|
||||
assert_eq!(d.publisher.as_deref(), Some("VideoLAN"));
|
||||
assert_eq!(d.license.as_deref(), Some("GPL-2.0"));
|
||||
assert_eq!(d.homepage.as_deref(), Some("https://www.videolan.org/vlc/"));
|
||||
assert_eq!(d.installer_type.as_deref(), Some("wix"));
|
||||
assert!(d.installer_url.as_deref().is_some_and(|u| u.ends_with(".msi")));
|
||||
assert_eq!(d.hash.as_deref().map(str::len), Some(64));
|
||||
assert!(d.description.as_deref().is_some_and(|t| t.starts_with("VLC is a free")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_list() {
|
||||
let text = "Gefunden VLC media player [VideoLAN.VLC]\nVersion\n--------\n3.0.24\n3.0.23\n";
|
||||
assert_eq!(parse_versions(text), vec!["3.0.24", "3.0.23"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Zugriff auf das PowerShell-Modul `Microsoft.WinGet.Client` (Ausgabe als JSON).
|
||||
//!
|
||||
//! Hinweis: Dieser Pfad ist nur aktiv, wenn das Modul installiert ist. Bei jedem Fehler fällt der
|
||||
//! WinGet-Manager auf die Kommandozeile zurück.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{ManagerId, Package};
|
||||
use crate::process;
|
||||
|
||||
const PS_PREAMBLE: &str = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; \
|
||||
$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; \
|
||||
Import-Module Microsoft.WinGet.Client; ";
|
||||
|
||||
/// Ergebnis der Modulerkennung.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModuleInfo {
|
||||
/// PowerShell-Programm, in dem das Modul gefunden wurde.
|
||||
pub shell: PathBuf,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
fn shells() -> Vec<PathBuf> {
|
||||
["pwsh", "powershell"].iter().filter_map(|n| which::which(n).ok()).collect()
|
||||
}
|
||||
|
||||
/// Sucht das Modul in PowerShell 7 und Windows PowerShell.
|
||||
pub async fn detect_module() -> Option<ModuleInfo> {
|
||||
let script = "$m = Get-Module -ListAvailable -Name Microsoft.WinGet.Client | \
|
||||
Sort-Object Version -Descending | Select-Object -First 1; \
|
||||
if ($m) { $m.Version.ToString() }";
|
||||
for shell in shells() {
|
||||
let Ok(out) = process::run(
|
||||
&shell,
|
||||
&["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let version = out.stdout.trim().to_owned();
|
||||
if out.code == Some(0) && !version.is_empty() {
|
||||
return Some(ModuleInfo { shell, version });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PsPackage {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
version: Option<String>,
|
||||
available: Option<String>,
|
||||
update: Option<bool>,
|
||||
source: Option<String>,
|
||||
}
|
||||
|
||||
async fn run_json(shell: &Path, body: &str, query: &str) -> AppResult<Vec<PsPackage>> {
|
||||
let script = format!("{PS_PREAMBLE}{body}");
|
||||
let out = process::run_env(
|
||||
shell,
|
||||
&["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", &script],
|
||||
&[("VU_QUERY", query)],
|
||||
Duration::from_secs(180),
|
||||
)
|
||||
.await?;
|
||||
if out.code != Some(0) {
|
||||
return Err(AppError::Command(format!(
|
||||
"PowerShell-Modul fehlgeschlagen: {}",
|
||||
out.stderr.lines().next().unwrap_or("unbekannter Fehler")
|
||||
)));
|
||||
}
|
||||
let text = out.stdout.trim();
|
||||
if text.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(serde_json::from_str(text)?)
|
||||
}
|
||||
|
||||
fn convert(items: Vec<PsPackage>, only_updates: bool) -> Vec<Package> {
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|p| !only_updates || p.update.unwrap_or(false))
|
||||
.filter_map(|p| {
|
||||
let id = p.id.filter(|s| !s.is_empty())?;
|
||||
let non_empty = |s: Option<String>| s.filter(|v| !v.is_empty());
|
||||
Some(Package::new(
|
||||
ManagerId::Winget,
|
||||
id.clone(),
|
||||
non_empty(p.name).unwrap_or(id),
|
||||
p.version.unwrap_or_default(),
|
||||
if only_updates || p.update.unwrap_or(false) { non_empty(p.available) } else { None },
|
||||
non_empty(p.source),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const INSTALLED_BODY: &str = "$r = @(Get-WinGetPackage | ForEach-Object { [pscustomobject]@{ \
|
||||
id = $_.Id; name = $_.Name; version = $_.InstalledVersion; \
|
||||
available = ($_.AvailableVersions | Select-Object -First 1); \
|
||||
update = [bool]$_.IsUpdateAvailable; source = $_.Source } }); \
|
||||
ConvertTo-Json -InputObject $r -Compress -Depth 3";
|
||||
|
||||
const SEARCH_BODY: &str = "$r = @(Find-WinGetPackage -Query $env:VU_QUERY | ForEach-Object { [pscustomobject]@{ \
|
||||
id = $_.Id; name = $_.Name; version = $_.Version; source = $_.Source } }); \
|
||||
ConvertTo-Json -InputObject $r -Compress -Depth 3";
|
||||
|
||||
/// Installierte Pakete über das Modul.
|
||||
pub async fn list_installed(shell: &Path) -> AppResult<Vec<Package>> {
|
||||
Ok(convert(run_json(shell, INSTALLED_BODY, "").await?, false))
|
||||
}
|
||||
|
||||
/// Verfügbare Aktualisierungen über das Modul.
|
||||
pub async fn list_updates(shell: &Path) -> AppResult<Vec<Package>> {
|
||||
Ok(convert(run_json(shell, INSTALLED_BODY, "").await?, true))
|
||||
}
|
||||
|
||||
/// Suche über das Modul.
|
||||
pub async fn search(shell: &Path, query: &str) -> AppResult<Vec<Package>> {
|
||||
Ok(convert(run_json(shell, SEARCH_BODY, query).await?, false))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Gemeinsame Datentypen, die zwischen Backend und Oberfläche ausgetauscht werden.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Kennung eines Paketmanagers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ManagerId {
|
||||
Winget,
|
||||
Scoop,
|
||||
Choco,
|
||||
Pip,
|
||||
Npm,
|
||||
Dotnet,
|
||||
Psgallery,
|
||||
Cargo,
|
||||
Vcpkg,
|
||||
}
|
||||
|
||||
/// Ein Paket in einer Liste (Suche, installiert, Aktualisierung).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Package {
|
||||
/// Stabile Kennung innerhalb der Oberfläche (`manager:id:quelle`).
|
||||
pub key: String,
|
||||
/// Paket-ID des Managers.
|
||||
pub id: String,
|
||||
/// Anzeigename.
|
||||
pub name: String,
|
||||
/// Installierte oder (bei Suche) aktuelle Version.
|
||||
pub version: String,
|
||||
/// Verfügbare neue Version, falls vorhanden.
|
||||
pub available_version: Option<String>,
|
||||
/// Zugehöriger Manager.
|
||||
pub manager: ManagerId,
|
||||
/// Quelle (z. B. `winget`, `msstore`).
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
/// Erzeugt ein Paket und berechnet den eindeutigen Schlüssel.
|
||||
pub fn new(
|
||||
manager: ManagerId,
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
available_version: Option<String>,
|
||||
source: Option<String>,
|
||||
) -> Self {
|
||||
let key = format!(
|
||||
"{}:{}:{}:{}",
|
||||
serde_json::to_value(manager)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or_default(),
|
||||
id,
|
||||
source.as_deref().unwrap_or(""),
|
||||
version
|
||||
);
|
||||
Self { key, id, name, version, available_version, manager, source }
|
||||
}
|
||||
}
|
||||
|
||||
/// Macht die Schlüssel einer Liste eindeutig (z. B. bei Paketen mit gleicher ID, aber verschiedenem Namen).
|
||||
pub fn ensure_unique_keys(packages: &mut [Package]) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for pkg in packages.iter_mut() {
|
||||
let mut candidate = pkg.key.clone();
|
||||
let mut n = 2;
|
||||
while !seen.insert(candidate.clone()) {
|
||||
candidate = format!("{}#{n}", pkg.key);
|
||||
n += 1;
|
||||
}
|
||||
pkg.key = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/// Detailinformationen zu einem Paket.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackageDetails {
|
||||
pub description: Option<String>,
|
||||
pub publisher: Option<String>,
|
||||
pub license: Option<String>,
|
||||
pub license_url: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub installer_url: Option<String>,
|
||||
pub installer_type: Option<String>,
|
||||
pub hash: Option<String>,
|
||||
pub versions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Ergebnis der Erkennung eines Paketmanagers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManagerInfo {
|
||||
pub id: ManagerId,
|
||||
pub name: String,
|
||||
/// Ob die ausführbare Datei gefunden wurde.
|
||||
pub available: bool,
|
||||
pub path: Option<String>,
|
||||
pub version: Option<String>,
|
||||
/// Zusatzkomponenten, z. B. das PowerShell-Modul bei WinGet.
|
||||
pub extras: Vec<ExtraInfo>,
|
||||
}
|
||||
|
||||
/// Eine optionale Zusatzkomponente eines Managers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExtraInfo {
|
||||
pub name: String,
|
||||
pub available: bool,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Eine Paketquelle.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Source {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub explicit: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duplicate_keys_are_made_unique() {
|
||||
let make = |name: &str| {
|
||||
Package::new(ManagerId::Winget, "A.B".into(), name.into(), "1.0".into(), None, Some("winget".into()))
|
||||
};
|
||||
let mut list = vec![make("x"), make("y"), make("z")];
|
||||
ensure_unique_keys(&mut list);
|
||||
let keys: std::collections::HashSet<_> = list.iter().map(|p| p.key.clone()).collect();
|
||||
assert_eq!(keys.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Start externer Prozesse ohne Konsolenfenster, mit Zeitlimit und UTF-8-Dekodierung.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Ergebnis eines beendeten Prozesses.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CmdOutput {
|
||||
/// Exit-Code (`None`, wenn der Prozess beendet wurde).
|
||||
pub code: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
/// Dekodiert Prozessausgabe als UTF-8 und entfernt ein führendes BOM.
|
||||
pub fn decode(bytes: &[u8]) -> String {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
text.trim_start_matches('\u{feff}').to_owned()
|
||||
}
|
||||
|
||||
/// Erzeugt ein Kommando, das unter Windows kein Konsolenfenster öffnet.
|
||||
pub fn command(program: &Path) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd.kill_on_drop(true)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Führt ein Programm aus und wartet höchstens `timeout`.
|
||||
pub async fn run(program: &Path, args: &[&str], timeout: Duration) -> AppResult<CmdOutput> {
|
||||
run_env(program, args, &[], timeout).await
|
||||
}
|
||||
|
||||
/// Wie [`run`], setzt zusätzlich Umgebungsvariablen (z. B. um Benutzereingaben sicher zu übergeben).
|
||||
pub async fn run_env(
|
||||
program: &Path,
|
||||
args: &[&str],
|
||||
envs: &[(&str, &str)],
|
||||
timeout: Duration,
|
||||
) -> AppResult<CmdOutput> {
|
||||
let mut cmd = command(program);
|
||||
cmd.args(args).envs(envs.iter().copied());
|
||||
tracing::debug!(program = %program.display(), ?args, "Prozess wird gestartet");
|
||||
let child = cmd.spawn().map_err(|source| AppError::Spawn {
|
||||
program: program.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let output = tokio::time::timeout(timeout, child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| AppError::Timeout(timeout.as_secs()))??;
|
||||
Ok(CmdOutput {
|
||||
code: output.status.code(),
|
||||
stdout: decode(&output.stdout),
|
||||
stderr: decode(&output.stderr),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Persistenter Zustand unter `%APPDATA%\VitosUpdater` (Phase 1: Fensterposition und -größe).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Gespeicherte Fenstergeometrie in physischen Pixeln.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WindowState {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub maximized: bool,
|
||||
}
|
||||
|
||||
fn file(dir: &Path) -> PathBuf {
|
||||
dir.join("window.json")
|
||||
}
|
||||
|
||||
/// Lädt die gespeicherte Fenstergeometrie. Fehlt die Datei oder ist sie defekt, ist das Ergebnis `None`.
|
||||
pub fn load_window_state(dir: &Path) -> Option<WindowState> {
|
||||
let text = std::fs::read_to_string(file(dir)).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// Speichert die Fenstergeometrie atomar (Schreiben in temporäre Datei, dann Umbenennen).
|
||||
pub fn save_window_state(dir: &Path, state: &WindowState) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = dir.join("window.json.tmp");
|
||||
std::fs::write(&tmp, serde_json::to_string_pretty(state)?)?;
|
||||
std::fs::rename(tmp, file(dir))
|
||||
}
|
||||
|
||||
/// Prüft, ob ein Fensterrechteck ein Monitorrechteck `(x, y, breite, höhe)` ausreichend überlappt.
|
||||
pub fn is_visible_on(state: &WindowState, monitors: &[(i32, i32, u32, u32)]) -> bool {
|
||||
const MIN_VISIBLE: i64 = 120;
|
||||
monitors.iter().any(|&(mx, my, mw, mh)| {
|
||||
let left = i64::from(state.x.max(mx));
|
||||
let top = i64::from(state.y.max(my));
|
||||
let right = (i64::from(state.x) + i64::from(state.width)).min(i64::from(mx) + i64::from(mw));
|
||||
let bottom = (i64::from(state.y) + i64::from(state.height)).min(i64::from(my) + i64::from(mh));
|
||||
right - left >= MIN_VISIBLE && bottom - top >= MIN_VISIBLE
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn state(x: i32, y: i32) -> WindowState {
|
||||
WindowState { x, y, width: 1280, height: 800, maximized: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_on_primary_monitor() {
|
||||
assert!(is_visible_on(&state(100, 100), &[(0, 0, 1920, 1080)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offscreen_after_monitor_removed() {
|
||||
assert!(!is_visible_on(&state(2500, 100), &[(0, 0, 1920, 1080)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let dir = std::env::temp_dir().join("vitosupdater-test-window");
|
||||
let s = state(10, 20);
|
||||
save_window_state(&dir, &s).unwrap();
|
||||
assert_eq!(load_window_state(&dir), Some(s));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! Selbst-Update von VitosUpdater über `tauri-plugin-updater` (statische `latest.json` auf Gitea).
|
||||
|
||||
pub mod policy;
|
||||
|
||||
use std::error::Error as StdError;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use policy::{Offer, UpdaterSettings};
|
||||
|
||||
const SETTINGS_FILE: &str = "updater.json";
|
||||
const NOTICE_FILE: &str = "update-notice.json";
|
||||
const MAIN_WINDOW: &str = "main";
|
||||
|
||||
/// Öffentliche Angaben zu einem verfügbaren Update.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
pub version: String,
|
||||
pub current_version: String,
|
||||
/// Veröffentlichungsdatum als Unix-Sekunden.
|
||||
pub date: Option<i64>,
|
||||
/// Release-Notizen (Markdown).
|
||||
pub notes: String,
|
||||
/// Größe des Installers in Bytes, falls in `latest.json` angegeben.
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
/// Ergebnis einer Update-Prüfung.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum CheckOutcome {
|
||||
Available { info: UpdateInfo },
|
||||
Skipped { info: UpdateInfo },
|
||||
Snoozed { info: UpdateInfo },
|
||||
UpToDate,
|
||||
}
|
||||
|
||||
/// Fortschrittsereignisse der Installation.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "stage", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum InstallEvent {
|
||||
Started { total: Option<u64> },
|
||||
Progress { downloaded: u64, total: Option<u64> },
|
||||
Installing,
|
||||
}
|
||||
|
||||
/// Zustand für die Oberfläche.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdaterStatus {
|
||||
pub current_version: String,
|
||||
pub settings: UpdaterSettings,
|
||||
pub last_check: Option<i64>,
|
||||
pub last_error: Option<String>,
|
||||
/// Aktuell anzubietendes Update (nicht übersprungen/zurückgestellt).
|
||||
pub offer: Option<UpdateInfo>,
|
||||
}
|
||||
|
||||
/// Hinweis, der nach dem Neustart den erfolgreichen Wechsel anzeigt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateNotice {
|
||||
pub version: String,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
settings: UpdaterSettings,
|
||||
pending: Option<Update>,
|
||||
last_check: Option<i64>,
|
||||
last_error: Option<String>,
|
||||
notified_version: Option<String>,
|
||||
}
|
||||
|
||||
/// Gemeinsamer Zustand des Updaters.
|
||||
pub struct UpdaterState {
|
||||
dir: PathBuf,
|
||||
inner: Mutex<Inner>,
|
||||
check_lock: tokio::sync::Mutex<()>,
|
||||
wake: Notify,
|
||||
started: AtomicBool,
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(tmp, path)
|
||||
}
|
||||
|
||||
/// Verkettet eine Fehlerkette zu einem Text.
|
||||
fn error_chain(err: &dyn StdError) -> String {
|
||||
let mut text = err.to_string();
|
||||
let mut source = err.source();
|
||||
while let Some(s) = source {
|
||||
text.push_str(": ");
|
||||
text.push_str(&s.to_string());
|
||||
source = s.source();
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn info_of(update: &Update) -> UpdateInfo {
|
||||
// Die Größe steht als zusätzliches Feld am Plattformeintrag, dessen URL zum Download passt.
|
||||
let size = update
|
||||
.raw_json
|
||||
.get("platforms")
|
||||
.and_then(|p| p.as_object())
|
||||
.and_then(|platforms| {
|
||||
platforms
|
||||
.values()
|
||||
.find(|p| p.get("url").and_then(|u| u.as_str()) == Some(update.download_url.as_str()))
|
||||
})
|
||||
.and_then(|p| p.get("size"))
|
||||
.and_then(serde_json::Value::as_u64);
|
||||
UpdateInfo {
|
||||
version: update.version.clone(),
|
||||
current_version: update.current_version.clone(),
|
||||
date: update.date.map(|d| d.unix_timestamp()),
|
||||
notes: update.body.clone().unwrap_or_default(),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdaterState {
|
||||
/// Lädt gespeicherte Einstellungen aus dem Datenverzeichnis.
|
||||
pub fn new(dir: PathBuf) -> Self {
|
||||
let settings = std::fs::read_to_string(dir.join(SETTINGS_FILE))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<UpdaterSettings>(&t).ok())
|
||||
.unwrap_or_default()
|
||||
.sanitized();
|
||||
Self {
|
||||
dir,
|
||||
inner: Mutex::new(Inner { settings, pending: None, last_check: None, last_error: None, notified_version: None }),
|
||||
check_lock: tokio::sync::Mutex::new(()),
|
||||
wake: Notify::new(),
|
||||
started: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, Inner> {
|
||||
self.inner.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn persist(&self, settings: &UpdaterSettings) {
|
||||
match serde_json::to_string_pretty(settings) {
|
||||
Ok(text) => {
|
||||
if let Err(err) = write_atomic(&self.dir.join(SETTINGS_FILE), &text) {
|
||||
tracing::warn!(%err, "Updater-Einstellungen konnten nicht gespeichert werden");
|
||||
}
|
||||
}
|
||||
Err(err) => tracing::warn!(%err, "Updater-Einstellungen konnten nicht serialisiert werden"),
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self, current_version: &str) -> UpdaterStatus {
|
||||
let inner = self.lock();
|
||||
UpdaterStatus {
|
||||
current_version: current_version.to_owned(),
|
||||
settings: inner.settings.clone(),
|
||||
last_check: inner.last_check,
|
||||
last_error: inner.last_error.clone(),
|
||||
offer: Self::offer_of(&inner),
|
||||
}
|
||||
}
|
||||
|
||||
fn offer_of(inner: &Inner) -> Option<UpdateInfo> {
|
||||
let update = inner.pending.as_ref()?;
|
||||
(policy::decide(&inner.settings, &update.version, now_secs(), false) == Offer::Show).then(|| info_of(update))
|
||||
}
|
||||
}
|
||||
|
||||
fn main_window_visible(app: &AppHandle) -> bool {
|
||||
app.get_webview_window(MAIN_WINDOW)
|
||||
.is_some_and(|w| w.is_visible().unwrap_or(false) && !w.is_minimized().unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Meldet ein neues Update: Popup-Ereignis bei sichtbarem Fenster, sonst einmalig eine Windows-Benachrichtigung.
|
||||
fn announce(app: &AppHandle, info: &UpdateInfo) {
|
||||
if main_window_visible(app) {
|
||||
if let Err(err) = app.emit("update-available", info) {
|
||||
tracing::warn!(%err, "Ereignis „update-available“ konnte nicht gesendet werden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
let state = app.state::<UpdaterState>();
|
||||
{
|
||||
let mut inner = state.lock();
|
||||
if inner.notified_version.as_deref() == Some(info.version.as_str()) {
|
||||
return;
|
||||
}
|
||||
inner.notified_version = Some(info.version.clone());
|
||||
}
|
||||
let result = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Update verfügbar")
|
||||
.body(format!("VitosUpdater {} steht bereit. Zum Installieren die Anwendung öffnen.", info.version))
|
||||
.show();
|
||||
if let Err(err) = result {
|
||||
tracing::warn!(%err, "Benachrichtigung konnte nicht angezeigt werden");
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_check(app: &AppHandle, manual: bool) -> Result<CheckOutcome, String> {
|
||||
let state = app.state::<UpdaterState>();
|
||||
let _guard = state.check_lock.lock().await;
|
||||
|
||||
let restart_handle = app.clone();
|
||||
let checked = async {
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.on_before_exit(move || {
|
||||
let _ = restart_handle.emit("update-restarting", ());
|
||||
})
|
||||
.build()
|
||||
.map_err(|e| error_chain(&e))?;
|
||||
updater.check().await.map_err(|e| error_chain(&e))
|
||||
}
|
||||
.await;
|
||||
|
||||
let now = now_secs();
|
||||
let mut inner = state.lock();
|
||||
inner.last_check = Some(now);
|
||||
match checked {
|
||||
Ok(Some(update)) if policy::is_newer(&update.version, &update.current_version) => {
|
||||
inner.last_error = None;
|
||||
let info = info_of(&update);
|
||||
let offer = policy::decide(&inner.settings, &update.version, now, manual);
|
||||
inner.pending = Some(update);
|
||||
drop(inner);
|
||||
tracing::info!(version = %info.version, ?offer, manual, "Update gefunden");
|
||||
Ok(match offer {
|
||||
Offer::Show => {
|
||||
if !manual {
|
||||
announce(app, &info);
|
||||
}
|
||||
CheckOutcome::Available { info }
|
||||
}
|
||||
Offer::Skipped => CheckOutcome::Skipped { info },
|
||||
Offer::Snoozed => CheckOutcome::Snoozed { info },
|
||||
})
|
||||
}
|
||||
Ok(_) => {
|
||||
inner.last_error = None;
|
||||
inner.pending = None;
|
||||
tracing::info!("Kein neueres Update verfügbar");
|
||||
Ok(CheckOutcome::UpToDate)
|
||||
}
|
||||
Err(raw) => {
|
||||
let text = policy::friendly_error(&raw);
|
||||
inner.last_error = Some(text.clone());
|
||||
tracing::warn!(error = %raw, "Update-Prüfung fehlgeschlagen");
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn scheduler(app: AppHandle) {
|
||||
let _ = run_check(&app, false).await;
|
||||
loop {
|
||||
let state = app.state::<UpdaterState>();
|
||||
let minutes = state.lock().settings.check_interval_minutes;
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(Duration::from_secs(u64::from(minutes) * 60)) => {
|
||||
let _ = run_check(&app, false).await;
|
||||
}
|
||||
// Intervall wurde geändert: Wartezeit mit dem neuen Wert neu beginnen.
|
||||
() = state.wake.notified() => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wird von der Oberfläche aufgerufen, sobald sie bereit ist; startet die zyklische Prüfung (einmalig).
|
||||
#[tauri::command]
|
||||
pub fn updater_ready(app: AppHandle, state: State<'_, UpdaterState>) -> UpdaterStatus {
|
||||
if !state.started.swap(true, Ordering::SeqCst) {
|
||||
tauri::async_runtime::spawn(scheduler(app.clone()));
|
||||
}
|
||||
state.status(&app.package_info().version.to_string())
|
||||
}
|
||||
|
||||
/// Liefert Zustand und Einstellungen des Updaters.
|
||||
#[tauri::command]
|
||||
pub fn get_updater_status(app: AppHandle, state: State<'_, UpdaterState>) -> UpdaterStatus {
|
||||
state.status(&app.package_info().version.to_string())
|
||||
}
|
||||
|
||||
/// Liefert das aktuell anzubietende Update, falls vorhanden (z. B. nachdem das Fenster wieder sichtbar wurde).
|
||||
#[tauri::command]
|
||||
pub fn get_update_offer(state: State<'_, UpdaterState>) -> Option<UpdateInfo> {
|
||||
UpdaterState::offer_of(&state.lock())
|
||||
}
|
||||
|
||||
/// Prüft sofort auf Updates. Manuelle Prüfungen ignorieren „Überspringen“ und „Später erinnern“.
|
||||
#[tauri::command]
|
||||
pub async fn check_for_update(app: AppHandle, manual: bool) -> Result<CheckOutcome, String> {
|
||||
run_check(&app, manual).await
|
||||
}
|
||||
|
||||
/// Stellt das gefundene Update für vier Stunden zurück.
|
||||
#[tauri::command]
|
||||
pub fn snooze_update(state: State<'_, UpdaterState>) {
|
||||
let mut inner = state.lock();
|
||||
if let Some(version) = inner.pending.as_ref().map(|u| u.version.clone()) {
|
||||
inner.settings.snoozed_version = Some(version);
|
||||
inner.settings.snoozed_until = Some(now_secs() + policy::SNOOZE_SECONDS);
|
||||
let settings = inner.settings.clone();
|
||||
drop(inner);
|
||||
state.persist(&settings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Überspringt die gefundene Version dauerhaft (neuere Versionen werden weiterhin angeboten).
|
||||
#[tauri::command]
|
||||
pub fn skip_update(state: State<'_, UpdaterState>) {
|
||||
let mut inner = state.lock();
|
||||
if let Some(version) = inner.pending.as_ref().map(|u| u.version.clone()) {
|
||||
inner.settings.skipped_version = Some(version);
|
||||
let settings = inner.settings.clone();
|
||||
drop(inner);
|
||||
state.persist(&settings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hebt „Diese Version überspringen“ wieder auf.
|
||||
#[tauri::command]
|
||||
pub fn clear_skipped_version(state: State<'_, UpdaterState>) -> UpdaterSettings {
|
||||
let mut inner = state.lock();
|
||||
inner.settings.skipped_version = None;
|
||||
inner.settings.snoozed_version = None;
|
||||
inner.settings.snoozed_until = None;
|
||||
let settings = inner.settings.clone();
|
||||
drop(inner);
|
||||
state.persist(&settings);
|
||||
settings
|
||||
}
|
||||
|
||||
/// Ändert das Prüfintervall (in Minuten, wird begrenzt) und startet die Wartezeit neu.
|
||||
#[tauri::command]
|
||||
pub fn set_update_interval(state: State<'_, UpdaterState>, minutes: u32) -> UpdaterSettings {
|
||||
let mut inner = state.lock();
|
||||
inner.settings.check_interval_minutes = minutes;
|
||||
inner.settings = inner.settings.clone().sanitized();
|
||||
let settings = inner.settings.clone();
|
||||
drop(inner);
|
||||
state.persist(&settings);
|
||||
state.wake.notify_one();
|
||||
settings
|
||||
}
|
||||
|
||||
/// Lädt das Update herunter, prüft die Signatur, installiert es und startet die Anwendung neu.
|
||||
#[tauri::command]
|
||||
pub async fn install_update(
|
||||
app: AppHandle,
|
||||
state: State<'_, UpdaterState>,
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> Result<(), String> {
|
||||
let update = state.lock().pending.clone().ok_or_else(|| "Es liegt kein Update zur Installation vor.".to_owned())?;
|
||||
let notice = UpdateNotice { version: update.version.clone(), notes: update.body.clone().unwrap_or_default() };
|
||||
let notice_path = state.dir.join(NOTICE_FILE);
|
||||
if let Ok(text) = serde_json::to_string(¬ice) {
|
||||
if let Err(err) = write_atomic(¬ice_path, &text) {
|
||||
tracing::warn!(%err, "Update-Hinweis konnte nicht gespeichert werden");
|
||||
}
|
||||
}
|
||||
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut started = false;
|
||||
let progress = on_event.clone();
|
||||
let finished = on_event.clone();
|
||||
tracing::info!(version = %update.version, "Update wird heruntergeladen und installiert");
|
||||
let result = update
|
||||
.download_and_install(
|
||||
move |chunk, total| {
|
||||
if !started {
|
||||
started = true;
|
||||
let _ = progress.send(InstallEvent::Started { total });
|
||||
}
|
||||
downloaded += chunk as u64;
|
||||
let _ = progress.send(InstallEvent::Progress { downloaded, total });
|
||||
},
|
||||
move || {
|
||||
let _ = finished.send(InstallEvent::Installing);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
// Unter Windows beendet das Plugin die Anwendung selbst; der Installer startet sie neu.
|
||||
Ok(()) => app.restart(),
|
||||
Err(err) => {
|
||||
let _ = std::fs::remove_file(¬ice_path);
|
||||
let raw = error_chain(&err);
|
||||
tracing::error!(error = %raw, "Update-Installation fehlgeschlagen");
|
||||
let text = policy::friendly_error(&raw);
|
||||
state.lock().last_error = Some(text.clone());
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Liefert (und löscht) den Hinweis auf ein soeben installiertes Update, wenn die laufende Version dazu passt.
|
||||
#[tauri::command]
|
||||
pub fn take_update_notice(app: AppHandle, state: State<'_, UpdaterState>) -> Option<UpdateNotice> {
|
||||
let path = state.dir.join(NOTICE_FILE);
|
||||
let notice = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str::<UpdateNotice>(&t).ok());
|
||||
if notice.is_some() || path.exists() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
let current = app.package_info().version.to_string();
|
||||
notice.filter(|n| n.version == current)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Reine Entscheidungslogik des Selbst-Updaters (ohne Netzwerk, daher gut testbar).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Dauer, für die „Später erinnern“ ein Update zurückstellt (4 Stunden).
|
||||
pub const SNOOZE_SECONDS: i64 = 4 * 60 * 60;
|
||||
/// Standardintervall der Update-Prüfung in Minuten.
|
||||
pub const DEFAULT_INTERVAL_MINUTES: u32 = 30;
|
||||
/// Kleinstes erlaubtes Prüfintervall in Minuten.
|
||||
pub const MIN_INTERVAL_MINUTES: u32 = 5;
|
||||
/// Größtes erlaubtes Prüfintervall in Minuten (eine Woche).
|
||||
pub const MAX_INTERVAL_MINUTES: u32 = 7 * 24 * 60;
|
||||
|
||||
/// Gespeicherte Einstellungen des Selbst-Updaters.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UpdaterSettings {
|
||||
/// Prüfintervall in Minuten.
|
||||
pub check_interval_minutes: u32,
|
||||
/// Version, die der Nutzer übersprungen hat.
|
||||
pub skipped_version: Option<String>,
|
||||
/// Version, die zurückgestellt wurde.
|
||||
pub snoozed_version: Option<String>,
|
||||
/// Zeitpunkt (Unix-Sekunden), bis zu dem zurückgestellt wurde.
|
||||
pub snoozed_until: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for UpdaterSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_interval_minutes: DEFAULT_INTERVAL_MINUTES,
|
||||
skipped_version: None,
|
||||
snoozed_version: None,
|
||||
snoozed_until: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdaterSettings {
|
||||
/// Begrenzt das Intervall auf den erlaubten Bereich.
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
self.check_interval_minutes = self.check_interval_minutes.clamp(MIN_INTERVAL_MINUTES, MAX_INTERVAL_MINUTES);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Entscheidung, ob eine gefundene Version angeboten wird.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Offer {
|
||||
/// Popup zeigen.
|
||||
Show,
|
||||
/// Vom Nutzer übersprungen.
|
||||
Skipped,
|
||||
/// Noch zurückgestellt.
|
||||
Snoozed,
|
||||
}
|
||||
|
||||
/// Prüft per SemVer, ob `remote` neuer als `current` ist. Ungültige Versionen gelten nie als neuer.
|
||||
pub fn is_newer(remote: &str, current: &str) -> bool {
|
||||
match (semver::Version::parse(remote.trim_start_matches('v')), semver::Version::parse(current.trim_start_matches('v'))) {
|
||||
(Ok(r), Ok(c)) => r > c,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Entscheidet anhand der Einstellungen, ob `version` angeboten wird. Manuelle Prüfungen bieten immer an.
|
||||
pub fn decide(settings: &UpdaterSettings, version: &str, now: i64, manual: bool) -> Offer {
|
||||
if manual {
|
||||
return Offer::Show;
|
||||
}
|
||||
if settings.skipped_version.as_deref() == Some(version) {
|
||||
return Offer::Skipped;
|
||||
}
|
||||
if settings.snoozed_version.as_deref() == Some(version) && settings.snoozed_until.is_some_and(|until| until > now) {
|
||||
return Offer::Snoozed;
|
||||
}
|
||||
Offer::Show
|
||||
}
|
||||
|
||||
/// Art eines Update-Fehlers, abgeleitet aus der Fehlermeldung.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorKind {
|
||||
Certificate,
|
||||
NotFound,
|
||||
Network,
|
||||
Signature,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Ordnet eine technische Fehlermeldung einer Fehlerart zu.
|
||||
pub fn classify_error(message: &str) -> ErrorKind {
|
||||
let m = message.to_lowercase();
|
||||
const CERT: [&str; 9] = [
|
||||
"certificate",
|
||||
"schannel",
|
||||
"sec_e_",
|
||||
"0x80090325",
|
||||
"0x80090326",
|
||||
"unknown ca",
|
||||
"untrusted",
|
||||
"invalid peer",
|
||||
"ssl",
|
||||
];
|
||||
if CERT.iter().any(|p| m.contains(p)) {
|
||||
ErrorKind::Certificate
|
||||
} else if m.contains("signature") || m.contains("minisign") || m.contains("signed version") {
|
||||
ErrorKind::Signature
|
||||
} else if m.contains("404") || m.contains("not found") {
|
||||
ErrorKind::NotFound
|
||||
} else if ["error sending request", "dns", "connect", "timed out", "timeout", "network", "unreachable"]
|
||||
.iter()
|
||||
.any(|p| m.contains(p))
|
||||
{
|
||||
ErrorKind::Network
|
||||
} else {
|
||||
ErrorKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Deutsche, für die Oberfläche bestimmte Fehlermeldung.
|
||||
pub fn friendly_error(message: &str) -> String {
|
||||
match classify_error(message) {
|
||||
ErrorKind::Certificate => "Die sichere Verbindung zum Update-Server wurde abgelehnt, weil Windows dem Zertifikat nicht vertraut. \
|
||||
Das Zertifikat der Zertifizierungsstelle muss in Windows als vertrauenswürdig installiert werden (siehe README, Abschnitt „Zertifikat“)."
|
||||
.to_owned(),
|
||||
ErrorKind::NotFound => "Die Update-Beschreibung (latest.json) wurde auf dem Server nicht gefunden. Möglicherweise ist noch kein Release veröffentlicht."
|
||||
.to_owned(),
|
||||
ErrorKind::Network => "Der Update-Server ist nicht erreichbar. Bitte die Netzwerkverbindung prüfen.".to_owned(),
|
||||
ErrorKind::Signature => "Die Signatur des Updates ist ungültig. Das Update wurde aus Sicherheitsgründen nicht installiert.".to_owned(),
|
||||
ErrorKind::Other => format!("Die Update-Prüfung ist fehlgeschlagen: {message}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn newer_uses_semver_not_string_order() {
|
||||
assert!(is_newer("0.10.0", "0.9.0"));
|
||||
assert!(is_newer("v1.0.0", "0.9.9"));
|
||||
assert!(!is_newer("0.9.0", "0.10.0"), "Downgrade darf nie angeboten werden");
|
||||
assert!(!is_newer("1.0.0", "1.0.0"));
|
||||
assert!(!is_newer("1.0.0-beta.1", "1.0.0"));
|
||||
assert!(!is_newer("kaputt", "1.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_version_is_not_offered_but_newer_is() {
|
||||
let s = UpdaterSettings { skipped_version: Some("1.2.0".into()), ..Default::default() };
|
||||
assert_eq!(decide(&s, "1.2.0", 0, false), Offer::Skipped);
|
||||
assert_eq!(decide(&s, "1.3.0", 0, false), Offer::Show);
|
||||
assert_eq!(decide(&s, "1.2.0", 0, true), Offer::Show, "manuelle Prüfung zeigt immer an");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snooze_expires() {
|
||||
let s = UpdaterSettings { snoozed_version: Some("2.0.0".into()), snoozed_until: Some(1_000), ..Default::default() };
|
||||
assert_eq!(decide(&s, "2.0.0", 999, false), Offer::Snoozed);
|
||||
assert_eq!(decide(&s, "2.0.0", 1_000, false), Offer::Show);
|
||||
assert_eq!(decide(&s, "2.1.0", 10, false), Offer::Show);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_is_clamped() {
|
||||
let low = UpdaterSettings { check_interval_minutes: 0, ..Default::default() }.sanitized();
|
||||
let high = UpdaterSettings { check_interval_minutes: u32::MAX, ..Default::default() }.sanitized();
|
||||
assert_eq!(low.check_interval_minutes, MIN_INTERVAL_MINUTES);
|
||||
assert_eq!(high.check_interval_minutes, MAX_INTERVAL_MINUTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_classified() {
|
||||
assert_eq!(classify_error("error sending request: invalid peer certificate: UnknownIssuer"), ErrorKind::Certificate);
|
||||
assert_eq!(classify_error("SEC_E_UNTRUSTED_ROOT (0x80090325)"), ErrorKind::Certificate);
|
||||
assert_eq!(classify_error("Download request failed with status: 404 Not Found"), ErrorKind::NotFound);
|
||||
assert_eq!(classify_error("error sending request for url: dns error"), ErrorKind::Network);
|
||||
assert_eq!(classify_error("minisign: signature verification failed"), ErrorKind::Signature);
|
||||
assert!(friendly_error("unknown ca").contains("Zertifikat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_tolerate_missing_fields() {
|
||||
let s: UpdaterSettings = serde_json::from_str("{\"checkIntervalMinutes\": 60}").unwrap();
|
||||
assert_eq!(s.check_interval_minutes, 60);
|
||||
assert!(s.skipped_version.is_none());
|
||||
let empty: UpdaterSettings = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(empty, UpdaterSettings::default());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "VitosUpdater",
|
||||
"version": "0.1.0",
|
||||
"identifier": "de.vitos.updater",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": false,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "VitosUpdater",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 600,
|
||||
"visible": false,
|
||||
"resizable": true,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"createUpdaterArtifacts": true,
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico"],
|
||||
"windows": {
|
||||
"nsis": { "languages": ["German"], "displayLanguageSelector": false, "installMode": "currentUser" }
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNFRDA4NTBFNEM1MjE3OUYKUldTZkYxSk1Eb1hRUGdEV0lycFF4SnZ4SHBmZm5EVzVXemZNd0VUd2hsSUI4SllIMThrcTV0QUoK",
|
||||
"endpoints": ["https://gitea.davito.synology.me/Davito/VitosUpdater/releases/download/latest/latest.json"],
|
||||
"requireSignedVersion": true,
|
||||
"windows": { "installMode": "passive" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
Name ID Version Verfügbar Quelle
|
||||
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
AMD Chipset Software ARP\Machine\X86\AMD_Chipset_IODrivers 8.09.28.1124
|
||||
Antigravity 2.25.0 Google.Antigravity 2.25.0 2.28.2 winget
|
||||
App-Installer Microsoft.AppInstaller 1.29.380.0 winget
|
||||
Apple Application Support (64-Bit) Apple.AppleApplicationSupport.x64 8.7 winget
|
||||
AV1 Video Extension MSIX\Microsoft.AV1VideoExtension_2.0.35.0_x64__8wekyb3d8bbwe 2.0.35.0
|
||||
AVC Encoder-Videoerweiterung MSIX\Microsoft.AVCEncoderVideoExtension_1.1.47.0_x64__8wekyb3d8bbwe 1.1.47.0
|
||||
Betterbird (x64 de) Betterbird.Betterbird 153.5.0 winget
|
||||
Betterbird (x64 en-US) Betterbird.Betterbird 153.6.0 winget
|
||||
Bloons TD 6 ARP\Machine\X64\Steam App 960090 Unknown
|
||||
Ccache Ccache.Ccache 4.14.1 winget
|
||||
Composite Blur plugin for OBS ARP\Machine\X64\{186038F5-DE78-439D-B727-1D9B43E3701F} 1.6.1
|
||||
Corsair Device Control Service Bundle ARP\Machine\X86\{dffe1772-7f42-4dd2-ae7c-f07b34d720be} 1.9.9
|
||||
Deezer 8.0.10 Deezer.Deezer 8.0.10 winget
|
||||
Deno DenoLand.Deno 2.9.8 winget
|
||||
Deutsch Local Experience Pack MSIX\Microsoft.LanguageExperiencePackde-DE_26100.169.270.0_neutral__8wekyb3d8bbwe 26100.169.270.0
|
||||
Dev Home MSIX\Microsoft.Windows.DevHome_0.0.0.0_x64__8wekyb3d8bbwe 0.0.0.0
|
||||
Discord ARP\User\X64\Discord 1.0.9266
|
||||
EarTrumpet MSIX\40459File-New-Project.EarTrumpet_2.3.0.0_x86__1sdd7yawvg6ne 2.3.0.0
|
||||
eM Client MSIX\eMClient.20054CA46072C_11.0.1201.0_x64__veq4nz3ze5v72 11.0.1201.0
|
||||
EuerChat ARP\Machine\X64\EuerChat 1.2.10
|
||||
FFmpeg Gyan.FFmpeg 9.0.2 winget
|
||||
Game Bar MSIX\Microsoft.XboxGamingOverlay_7.326.10141.0_x64__8wekyb3d8bbwe 7.326.10141.0
|
||||
GBT_Dynamic_Lighting_Lib_UC 25.07.21.01 ARP\Machine\X64\GBT_Dynamic_Lighting_Lib_UC 25.07.21.01
|
||||
GBT_MB_Update ARP\Machine\X64\GBT_MB_Update 26.07.22.01
|
||||
GBT_RGB_Sync_Control 26.08.31.01 ARP\Machine\X64\GBT_RGB_Sync_Control 26.08.31.01
|
||||
GBT_rgbMotherboard_UC 26.08.27.01 ARP\Machine\X64\GBT_rgbMotherboard_UC 26.08.27.01
|
||||
GIGABYTE Control Center 26.08.28.01 ARP\Machine\X64\GIGABYTE Control Center 26.08.28.01
|
||||
GIGABYTE Performance Library ARP\Machine\X64\MBEasyTune 26.08.06.01
|
||||
GIGABYTE Storage Library ARP\Machine\X64\MBStorage 26.09.18.01
|
||||
GigabyteControlCenter MSIX\65d483df-b37e-4fcf-94de-8b795233db63_25.1.23.0_x64__1mmjbktjj1mkp 25.1.23.0
|
||||
Git Git.Git 2.56.0.4 winget
|
||||
Google Chrome ARP\Machine\X86\Google Chrome 156.0.8078.80
|
||||
HEIF-Bilderweiterung MSIX\Microsoft.HEIFImageExtension_1.2.62.0_x64__8wekyb3d8bbwe 1.2.62.0
|
||||
HEVC-Videoerweiterungen vom Gerätehersteller MSIX\Microsoft.HEVCVideoExtension_2.5.48.0_x64__8wekyb3d8bbwe 2.5.48.0
|
||||
Host der Store-Benutzeroberfläche MSIX\Microsoft.StorePurchaseApp_22608.1401.1.0_x64__8wekyb3d8bbwe 22608.1401.1.0
|
||||
Microsoft .Net Native Framework Package 2.2 MSIX\Microsoft.NET.Native.Framework.2.2_2.2.29512.0_x64__8wekyb3d8bbwe 2.2.29512.0
|
||||
Microsoft .Net Native Framework Package 2.2 MSIX\Microsoft.NET.Native.Framework.2.2_2.2.29512.0_x86__8wekyb3d8bbwe 2.2.29512.0
|
||||
Microsoft .Net Native Runtime Package 2.2 Microsoft.DotNet.Native.Runtime 2.2.28604.0 winget
|
||||
Microsoft .Net Native Runtime Package 2.2 Microsoft.DotNet.Native.Runtime 2.2.28604.0 winget
|
||||
Microsoft Edge Microsoft.Edge 156.0.4314.50 156.0.4314.56 winget
|
||||
Microsoft Edge MSIX\Microsoft.MicrosoftEdge.Stable_155.0.4283.45_neutral__8wekyb3d8bbwe 155.0.4283.45
|
||||
Microsoft Fotos MSIX\Microsoft.Windows.Photos_2026.11090.22001.0_x64__8wekyb3d8bbwe 2026.11090.22001.0
|
||||
Microsoft OneDrive Microsoft.OneDrive 26.189.0928.0009 winget
|
||||
Microsoft Store MSIX\Microsoft.WindowsStore_22609.1401.8.0_x64__8wekyb3d8bbwe 22609.1401.8.0
|
||||
Microsoft Visual C++ 2010 x64 Redistributable - 10.0.40219 Microsoft.VCRedist.2010.x64 10.0.40219 winget
|
||||
Microsoft Visual C++ 2010 x86 Redistributable - 10.0.40219 Microsoft.VCRedist.2010.x86 10.0.40219 winget
|
||||
Microsoft Visual C++ 2015 UWP Desktop Runtime Package Microsoft.VCLibs.Desktop.14 14.0.33728.0 winget
|
||||
Microsoft Visual C++ 2015 UWP Desktop Runtime Package Microsoft.VCLibs.Desktop.14 14.0.33728.0 winget
|
||||
Microsoft Visual C++ 2015 UWP Runtime Package Microsoft.VCLibs.14 14.0.33519.0 winget
|
||||
Microsoft Visual C++ 2015 UWP Runtime Package Microsoft.VCLibs.14 14.0.33519.0 winget
|
||||
Microsoft Visual C++ v14 Redistributable (x64) - 14.51.36247 Microsoft.VCRedist.2015+.x64 14.51.36247.0 winget
|
||||
Microsoft Visual C++ v14 Redistributable (x86) - 14.51.36247 Microsoft.VCRedist.2015+.x86 14.51.36247.0 winget
|
||||
Microsoft Visual Studio Code (User) Microsoft.VisualStudioCode 1.144.0 winget
|
||||
Microsoft Visual Studio Installer ARP\Machine\X64\{6F320B93-EE3C-4826-85E0-ADF79F8D4C61} 4.10.30.62513
|
||||
Microsoft Windows Application Compatibility Fix Database ARP\Machine\X64\{22221111-1111-1111-1111-111111111111}.sdb Unknown
|
||||
Microsoft Windows Desktop Runtime - 8.0.21 (x64) Microsoft.DotNet.DesktopRuntime.8 8.0.21 8.0.33 winget
|
||||
Microsoft Windows Desktop Runtime - 9.0.22 (x64) Microsoft.DotNet.DesktopRuntime.9 9.0.22 winget
|
||||
Microsoft Windows Desktop Runtime 10.0.14 (x64) Microsoft.DotNet.DesktopRuntime.10 10.0.14 winget
|
||||
Microsoft.UI.Xaml.2.8 Microsoft.UI.Xaml.2.8 8.2511.26001.0 winget
|
||||
Microsoft.UI.Xaml.2.8 Microsoft.UI.Xaml.2.8 8.2501.31001.0 winget
|
||||
Mozilla Firefox (x64 de) ARP\Machine\X64\Mozilla Firefox 159.0
|
||||
Mozilla Maintenance Service ARP\Machine\X64\MozillaMaintenanceService 158.0
|
||||
Mp3tag MSIX\35795FlorianHeidenreich.Mp3tag.ShellExtension_3.34.0.6_x64__hyhc94k68ebpm 3.34.0.6
|
||||
Mp3tag v3.36.1 FlorianHeidenreich.Mp3tag 3.36.1 winget
|
||||
MPC-HC 2.8.4 (64-bit) clsid2.mpc-hc 2.8.4 winget
|
||||
MPEG-2-Videoerweiterung MSIX\Microsoft.MPEG2VideoExtension_1.2.32.0_x64__8wekyb3d8bbwe 1.2.32.0
|
||||
MusicBrain 4.1.8 ARP\User\X64\{B7D1E98B-35F6-4F63-9F99-7D77DF0C5C4A}_is1 4.1.8
|
||||
NanaZip M2Team.NanaZip 7.0.1882.0 winget
|
||||
Node.js OpenJS.NodeJS.LTS 26.13.0 winget
|
||||
Notepad++ MSIX\NotepadPlusPlus_1.0.0.0_neutral__2247w0b46hfww 1.0.0.0
|
||||
Notepad++ (64-bit x64) Notepad++.Notepad++ 8.9.8.1 winget
|
||||
NVIDIA Broadcast 2.3.0 ARP\Machine\X64\{B2FE1952-0186-46C3-BAEC-A80AA35AC5B8}_NVIDIABroadcast 2.3.0
|
||||
NVIDIA Grafiktreiber 617.42 ARP\Machine\X64\{B2FE1952-0186-46C3-BAEC-A80AA35AC5B8}_Display.Driver 617.42
|
||||
OBS Studio OBSProject.OBSStudio 33.0.1 winget
|
||||
Oh My Posh JanDeDobbeleer.OhMyPosh 31.9.1.0 winget
|
||||
ParkControl BitSum.ParkControl 6.0.1.8 winget
|
||||
Peak Level Meter Version 1.76 ARP\Machine\X86\Peak Level Meter_is1 Unknown
|
||||
Power Automate MSIX\Microsoft.PowerAutomateDesktop_1.0.2159.0_x64__8wekyb3d8bbwe 1.0.2159.0
|
||||
PowerShell Microsoft.PowerShell 7.6.9.0 winget
|
||||
Python 3.12.10 (64-bit) Python.Python.3.12 3.12.10 winget
|
||||
Python 3.14.10 (64-bit) Python.Python.3.13 > 3.13.16 winget
|
||||
Python Launcher Python.Launcher > 3.14.7 winget
|
||||
Python Launcher Python.Launcher 3.14.7 winget
|
||||
Qobuz Qobuz.Qobuz 8.3.0-b028 winget
|
||||
Raw-Bilderweiterung MSIX\Microsoft.RawImageExtension_2.5.42.0_x64__8wekyb3d8bbwe 2.5.42.0
|
||||
Realtek Audio Control MSIX\RealtekSemiconductorCorp.RealtekAudioControl_1.53.385.0_x64__dt26b99r8h8gj 1.53.385.0
|
||||
Realtek Audio Driver ARP\Machine\X86\{F132AF7F-7BCA-4EDE-8A7C-958108FE7DBC} 6.0.9927.1
|
||||
Realtek Ethernet Controller Driver ARP\Machine\X86\{8833FFB6-5B0C-4764-81AA-06DFEED9A476} 11.29.50.0202
|
||||
RustDesk ARP\Machine\X64\RustDesk 1.5.1
|
||||
Rustup: the Rust toolchain installer Rustlang.Rustup 1.29.2 winget
|
||||
ShareX ShareX.ShareX 21.0.0 winget
|
||||
Start Experiences-App MSIX\Microsoft.StartExperiencesApp_1.398.4.0_x64__8wekyb3d8bbwe 1.398.4.0
|
||||
Steam Valve.Steam 2.10.91.91 winget
|
||||
Tuta 362.261023.2 Tutanota.Tutanota 362.261023.2 winget
|
||||
UniGetUI ARP\User\X64\{889610CC-4337-4BDB-AC3B-4F21806C0BDE}_is1 2026.3.3
|
||||
Verbesserungen an der Windows-Anwendungskompatibilität MSIX\Microsoft.ApplicationCompatibilityEnhancements_1.2511.9.0_x64__8wekyb3d8bbwe 1.2511.9.0
|
||||
Visual Studio Build Tools 2022 Microsoft.VisualStudio.2022.BuildTools 17.14.44 winget
|
||||
VLC media player VideoLAN.VLC 3.0.23 3.0.24 winget
|
||||
VP9-Videoerweiterungen MSIX\Microsoft.VP9VideoExtensions_1.2.20.0_x64__8wekyb3d8bbwe 1.2.20.0
|
||||
vs_CoreEditorFonts ARP\Machine\X86\{1851460E-0E63-4117-B5BA-25A2F045801B} 17.7.40001
|
||||
Wallpaper Engine ARP\Machine\X64\Steam App 431960 Unknown
|
||||
WebCamSettingsReloader-GUI version 1.02 ARP\Machine\X64\{3a628199-4053-4b34-8eb6-f3d8021a7164}_is1 1.02
|
||||
Webmedienerweiterungen MSIX\Microsoft.WebMediaExtensions_2.1.51.0_x64__8wekyb3d8bbwe 2.1.51.0
|
||||
WebP-Bilderweiterung MSIX\Microsoft.WebpImageExtension_1.2.31.0_x64__8wekyb3d8bbwe 1.2.31.0
|
||||
Widgets Platform Runtime MSIX\Microsoft.WidgetsPlatformRuntime_1.6.19.0_x64__8wekyb3d8bbwe 1.6.19.0
|
||||
WinAppRuntime.Main.1.5 MSIX\MicrosoftCorporationII.WinAppRuntime.Main.1.5_5001.373.1736.0_x64__8wekyb3d8bbwe 5001.373.1736.0
|
||||
WinAppRuntime.Main.2 MSIX\MicrosoftCorporationII.WinAppRuntime.Main.2_2.5.1.0_x64__8wekyb3d8bbwe 2.5.1.0
|
||||
WinAppRuntime.Singleton MSIX\MicrosoftCorporationII.WinAppRuntime.Singleton_8002.5.1.0_x64__8wekyb3d8bbwe 8002.5.1.0
|
||||
Windows App Runtime DDLM 5001.373.1736.0-x6 MSIX\Microsoft.WinAppRuntime.DDLM.5001.373.1736.0-x6_5001.373.1736.0_x64__8wekyb3d8bbwe 5001.373.1736.0
|
||||
Windows App Runtime DDLM 5001.373.1736.0-x8 MSIX\Microsoft.WinAppRuntime.DDLM.5001.373.1736.0-x8_5001.373.1736.0_x86__8wekyb3d8bbwe 5001.373.1736.0
|
||||
Windows ML Runtime NVIDIA TensorRT-RTX Execution Provider 2 MSIX\Microsoft.WinML.NVIDIA.TRT-RTX.EP.2_2.30.49.0_x64__8wekyb3d8bbwe 2.30.49.0
|
||||
Windows Package Manager Source (winget) V2 MSIX\Microsoft.Winget.Source_2026.1030.1409.1_neutral__8wekyb3d8bbwe 2026.1030.1409.1
|
||||
Windows Package Manager Source (winget-font) V2 MSIX\Microsoft.Winget.Fonts.Source_2025.1016.311.49_neutral__8wekyb3d8bbwe 2025.1016.311.49
|
||||
Windows SDK AddOn ARP\Machine\X86\{13D2A7A2-1B42-4C03-8F0F-697991259AEE} 10.1.0.0
|
||||
Windows Software Development Kit - Windows 10.0.26100.7705 Microsoft.WindowsSDK.10.0.26100 10.0.26100.7705 winget
|
||||
Windows Web Experience Pack MSIX\MicrosoftWindows.Client.WebExperience_526.21100.40.0_x64__cw5n1h2txyewy 526.21100.40.0
|
||||
Windows-Editor MSIX\Microsoft.WindowsNotepad_11.2607.14.0_x64__8wekyb3d8bbwe 11.2607.14.0
|
||||
Windows-Rechner MSIX\Microsoft.WindowsCalculator_11.2607.0.0_x64__8wekyb3d8bbwe 11.2607.0.0
|
||||
Windows-Sicherheit MSIX\Microsoft.SecHealthUI_1000.29680.1000.0_x64__8wekyb3d8bbwe 1000.29680.1000.0
|
||||
Windows-Terminal Microsoft.WindowsTerminal 1.25.2733.0 winget
|
||||
WindowsAppRuntime.1.1 Microsoft.WindowsAppRuntime.1.1 1.1.5 winget
|
||||
WindowsAppRuntime.1.1 Microsoft.WindowsAppRuntime.1.1 1.1.5 winget
|
||||
WindowsAppRuntime.1.5 Microsoft.WindowsAppRuntime.1.5 1.5.9 winget
|
||||
WindowsAppRuntime.1.5 Microsoft.WindowsAppRuntime.1.5 1.5.9 winget
|
||||
WindowsAppRuntime.1.7 Microsoft.WindowsAppRuntime.1.7 1.7.9 winget
|
||||
WindowsAppRuntime.1.7 Microsoft.WindowsAppRuntime.1.7 1.7.9 winget
|
||||
WindowsAppRuntime.1.8 Microsoft.WindowsAppRuntime.1.8 > 1.8.10 winget
|
||||
WindowsAppRuntime.1.8 Microsoft.WindowsAppRuntime.1.8 > 1.8.10 winget
|
||||
WindowsAppRuntime.2 Microsoft.WindowsAppRuntime.2 2.5.1.0 winget
|
||||
WindowsAppRuntime.2 Microsoft.WindowsAppRuntime.2 2.5.1.0 winget
|
||||
XBOX MSIX\Microsoft.GamingApp_2610.1001.20.0_x64__8wekyb3d8bbwe 2610.1001.20.0
|
||||
@@ -0,0 +1,12 @@
|
||||
Name ID Version Übereinstimmung Quelle
|
||||
----------------------------------------------------------------------------------------------------
|
||||
VLC media player VideoLAN.VLC 3.0.24 Moniker: vlc winget
|
||||
pympress Cimbali.pympress 1.8.6 Tag: vlc winget
|
||||
Jellyfin VLC Bridge CrySer66.JellyfinVlcBridge 1.18.0 Tag: vlc winget
|
||||
Screenbox Starpine.Screenbox 0.24.0 Tag: vlc winget
|
||||
Streamlink Streamlink.Streamlink 8.6.3-1 Tag: vlc winget
|
||||
wcap mmozeiko.wcap 2025.11.27 Tag: vlc winget
|
||||
SubRenamer qwqcode.SubRenamer 2.4.0 Tag: vlc winget
|
||||
tuna univrsal.tuna 1.9.11 Tag: vlc winget
|
||||
VLC media player (Nightly) VideoLAN.VLC.Nightly 4.0.0.0-nightly20260509 winget
|
||||
Darshan Player UjjwalDadhich.Darshan 1.2.0 Tag: libvlc winget
|
||||
@@ -0,0 +1,35 @@
|
||||
Gefunden VLC media player [VideoLAN.VLC]
|
||||
Version: 3.0.24
|
||||
Herausgeber: VideoLAN
|
||||
Herausgeber-URL: https://www.videolan.org/
|
||||
Herausgeber-Support-URL: https://www.videolan.org/support/
|
||||
Autor: VideoLAN
|
||||
Moniker: vlc
|
||||
Beschreibung:
|
||||
VLC is a free and open source cross-platform multimedia player and framework
|
||||
that plays most multimedia files as well as DVDs, Audio CDs, VCDs, and various
|
||||
streaming protocols.
|
||||
|
||||
Simple, fast and powerful
|
||||
Plays everything - Files, Discs, Webcams, Devices and Streams.
|
||||
Plays most codecs with no codec packs needed - MPEG-2, MPEG-4, H.264, MKV, WebM,
|
||||
WMV, MP3...
|
||||
Completely Free - no spyware, no ads and no user tracking.
|
||||
Startseite: https://www.videolan.org/vlc/
|
||||
Lizenz: GPL-2.0
|
||||
Lizenz-URL: https://code.videolan.org/videolan/vlc/-/blob/HEAD/COPYING
|
||||
Markierungen:
|
||||
audio-player
|
||||
audioplayer
|
||||
cross-platform
|
||||
dvd
|
||||
foss
|
||||
media-player
|
||||
multimedia
|
||||
video-player
|
||||
Installationsprogramm:
|
||||
Installertyp: wix
|
||||
Installer-URL: https://download.videolan.org/pub/videolan/vlc/3.0.24/win64/vlc-3.0.24-win64.msi
|
||||
Sha256-Installer: 22172095ce4c2d5f5b600bb4c142d78105db29a77fa59a931941876f3b20f6d0
|
||||
Freigabedatum: 2026-09-21
|
||||
Unterstützte Offlineverteilung: true
|
||||
@@ -0,0 +1,7 @@
|
||||
Name ID Version Verfügbar Quelle
|
||||
---------------------------------------------------------------------------------------------------------------------
|
||||
Antigravity 2.25.0 Google.Antigravity 2.25.0 2.28.2 winget
|
||||
Microsoft Edge Microsoft.Edge 156.0.4314.50 156.0.4314.56 winget
|
||||
Microsoft Windows Desktop Runtime - 8.0.21 (x64) Microsoft.DotNet.DesktopRuntime.8 8.0.21 8.0.33 winget
|
||||
VLC media player VideoLAN.VLC 3.0.23 3.0.24 winget
|
||||
4 Aktualisierungen verfügbar.
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import Sidebar from "./components/Sidebar.svelte";
|
||||
import SetupBanner from "./components/SetupBanner.svelte";
|
||||
import Discover from "./views/Discover.svelte";
|
||||
import Updates from "./views/Updates.svelte";
|
||||
import Installed from "./views/Installed.svelte";
|
||||
import Bundles from "./views/Bundles.svelte";
|
||||
import Settings from "./views/Settings.svelte";
|
||||
import UpdateDialog from "./components/UpdateDialog.svelte";
|
||||
import UpdatedNotice from "./components/UpdatedNotice.svelte";
|
||||
import { managers } from "./stores/managers.svelte";
|
||||
import { updater } from "./stores/updater.svelte";
|
||||
import { ui, updatesStore, installedStore } from "./stores/packages.svelte";
|
||||
import { t } from "./i18n/t";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
// Nach der Erkennung Updates und installierte Pakete im Hintergrund laden.
|
||||
$effect(() => {
|
||||
if (managers.winget?.available) {
|
||||
void updatesStore.load();
|
||||
void installedStore.load();
|
||||
}
|
||||
});
|
||||
|
||||
void managers.detect();
|
||||
// Die Oberfläche ist bereit: Selbst-Update-Prüfung starten.
|
||||
onMount(() => void updater.init());
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
<Sidebar updateCount={updatesStore.items.length} />
|
||||
<main class="flex min-w-0 flex-1 flex-col">
|
||||
{#if managers.detecting}
|
||||
<p class="mx-6 mt-6 text-muted" role="status">{t("setup.detecting")}</p>
|
||||
{/if}
|
||||
<SetupBanner />
|
||||
<div class="min-h-0 flex-1">
|
||||
{#if ui.view === "discover"}<Discover />
|
||||
{:else if ui.view === "updates"}<Updates />
|
||||
{:else if ui.view === "installed"}<Installed />
|
||||
{:else if ui.view === "bundles"}<Bundles />
|
||||
{:else}<Settings />{/if}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<UpdateDialog />
|
||||
<UpdatedNotice />
|
||||
@@ -0,0 +1,128 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #12141a;
|
||||
--surface: #1a1d25;
|
||||
--surface-2: #222632;
|
||||
--surface-hover: #2a2f3d;
|
||||
--border: #2d3240;
|
||||
--text: #e8eaf0;
|
||||
--text-muted: #a0a8b8;
|
||||
--danger: #ff7a7a;
|
||||
--warning: #f5b94a;
|
||||
--success: #5fd49a;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f3f4f8;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eceef4;
|
||||
--surface-hover: #e3e6ee;
|
||||
--border: #d5d9e4;
|
||||
--text: #1b1e27;
|
||||
--text-muted: #525a6b;
|
||||
--danger: #b3261e;
|
||||
--warning: #8a5a00;
|
||||
--success: #1b7a4b;
|
||||
}
|
||||
|
||||
/* Akzentfarben; Kontrastfarbe wird je Akzent so gewählt, dass Text auf Schaltflächen lesbar bleibt. */
|
||||
[data-accent="blue"] { --accent: #4c8dff; --accent-strong: #3a78e8; --accent-contrast: #06142e; }
|
||||
[data-accent="violet"] { --accent: #8b7bff; --accent-strong: #7466ee; --accent-contrast: #0e0a30; }
|
||||
[data-accent="teal"] { --accent: #2cc4b8; --accent-strong: #20ada2; --accent-contrast: #032420; }
|
||||
[data-accent="green"] { --accent: #4cc97a; --accent-strong: #3bb369; --accent-contrast: #05200f; }
|
||||
[data-accent="orange"] { --accent: #ff9a4d; --accent-strong: #ee8638; --accent-contrast: #2b1100; }
|
||||
[data-accent="pink"] { --accent: #ff6fae; --accent-strong: #ee5a9b; --accent-contrast: #2e0618; }
|
||||
|
||||
@theme inline {
|
||||
--color-bg: var(--bg);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-hover: var(--surface-hover);
|
||||
--color-border: var(--border);
|
||||
--color-fg: var(--text);
|
||||
--color-muted: var(--text-muted);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-strong: var(--accent-strong);
|
||||
--color-accent-fg: var(--accent-contrast);
|
||||
--color-danger: var(--danger);
|
||||
--color-warning: var(--warning);
|
||||
--color-success: var(--success);
|
||||
--font-sans: "Inter Variable", "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
|
||||
--radius-card: 12px;
|
||||
--radius-control: 8px;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button,
|
||||
select,
|
||||
input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 12px; height: 12px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; border: 3px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); background-clip: content-box; border: 3px solid transparent; }
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--surface-2) 25%, var(--surface-hover) 50%, var(--surface-2) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.fade-in { animation: fade-in 180ms ease-out; }
|
||||
|
||||
/* Dargestellte Markdown-Release-Notizen */
|
||||
.notes { font-size: 15px; line-height: 24px; overflow-wrap: anywhere; }
|
||||
.notes > :first-child { margin-top: 0; }
|
||||
.notes p, .notes ul, .notes ol, .notes pre, .notes blockquote { margin: 0 0 10px; }
|
||||
.notes ul { list-style: disc; padding-left: 22px; }
|
||||
.notes ol { list-style: decimal; padding-left: 22px; }
|
||||
.notes li { margin: 2px 0; }
|
||||
.notes h1, .notes h2, .notes h3, .notes h4 { margin: 14px 0 6px; font-weight: 600; line-height: 24px; }
|
||||
.notes h1 { font-size: 18px; } .notes h2 { font-size: 17px; } .notes h3, .notes h4 { font-size: 15px; }
|
||||
.notes code { background: var(--surface-2); border-radius: 4px; padding: 1px 6px; font-size: 13px; }
|
||||
.notes pre { background: var(--surface-2); border-radius: 8px; padding: 10px 12px; overflow-x: auto; }
|
||||
.notes pre code { background: none; padding: 0; }
|
||||
.notes a { color: var(--accent); text-decoration: underline; }
|
||||
.notes blockquote { border-left: 3px solid var(--border); padding-left: 12px; color: var(--text-muted); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { ExternalLink, TriangleAlert, X } from "@lucide/svelte";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { api, errorText } from "../lib/api";
|
||||
import type { Package, PackageDetails } from "../lib/types";
|
||||
import { t, tp, formatNumber } from "../i18n/t";
|
||||
|
||||
interface Props {
|
||||
pkg: Package | null;
|
||||
onclose: () => void;
|
||||
}
|
||||
let { pkg, onclose }: Props = $props();
|
||||
|
||||
const MAX_VERSIONS = 12;
|
||||
const cache = new Map<string, PackageDetails>();
|
||||
let details = $state<PackageDetails | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let token = 0;
|
||||
|
||||
$effect(() => {
|
||||
const current = pkg;
|
||||
details = null;
|
||||
error = null;
|
||||
if (!current) return;
|
||||
const cached = cache.get(current.key);
|
||||
if (cached) {
|
||||
details = cached;
|
||||
return;
|
||||
}
|
||||
const mine = ++token;
|
||||
loading = true;
|
||||
api
|
||||
.getPackageDetails(current)
|
||||
.then((d) => {
|
||||
if (mine !== token) return;
|
||||
cache.set(current.key, d);
|
||||
details = d;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (mine === token) error = errorText(err, t("details.loadFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
if (mine === token) loading = false;
|
||||
});
|
||||
});
|
||||
|
||||
const shownVersions = $derived(details?.versions.slice(0, MAX_VERSIONS) ?? []);
|
||||
const hiddenVersions = $derived(Math.max(0, (details?.versions.length ?? 0) - MAX_VERSIONS));
|
||||
</script>
|
||||
|
||||
<aside class="flex h-full w-[360px] shrink-0 flex-col overflow-hidden rounded-[12px] border border-border bg-surface">
|
||||
<header class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 class="text-[15px] font-semibold">{t("details.title")}</h2>
|
||||
{#if pkg}
|
||||
<button type="button" class="rounded-[8px] p-1.5 text-muted hover:bg-hover hover:text-fg" onclick={onclose} aria-label={t("common.close")}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{#if !pkg}
|
||||
<p class="text-muted">{t("details.selectHint")}</p>
|
||||
{:else}
|
||||
<div class="fade-in space-y-4">
|
||||
<div>
|
||||
<h3 class="break-words text-[18px] font-semibold leading-6">{pkg.name}</h3>
|
||||
<p class="break-all text-[13px] text-muted">{pkg.id}</p>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-[14px]">
|
||||
<dt class="text-muted">{pkg.availableVersion ? t("details.installedVersion") : t("columns.version")}</dt>
|
||||
<dd>{pkg.version || t("common.unknown")}</dd>
|
||||
{#if pkg.availableVersion}
|
||||
<dt class="text-muted">{t("details.availableVersion")}</dt>
|
||||
<dd class="font-medium text-accent">{pkg.availableVersion}</dd>
|
||||
{/if}
|
||||
{#if pkg.source}
|
||||
<dt class="text-muted">{t("columns.source")}</dt>
|
||||
<dd>{pkg.source}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
{#if loading}
|
||||
<div class="space-y-2" aria-label={t("common.loading")}>
|
||||
<div class="skeleton h-4 w-full"></div>
|
||||
<div class="skeleton h-4 w-5/6"></div>
|
||||
<div class="skeleton h-4 w-2/3"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<p class="flex gap-2 rounded-[8px] bg-danger/10 p-3 text-danger"><TriangleAlert size={18} class="mt-0.5 shrink-0" />{error}</p>
|
||||
{:else if details}
|
||||
{#if details.description}
|
||||
<section>
|
||||
<h4 class="mb-1 text-[13px] font-semibold text-muted">{t("details.description")}</h4>
|
||||
<p class="whitespace-pre-line break-words">{details.description}</p>
|
||||
</section>
|
||||
{/if}
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-[14px]">
|
||||
{#if details.publisher}
|
||||
<dt class="text-muted">{t("details.publisher")}</dt><dd class="break-words">{details.publisher}</dd>
|
||||
{/if}
|
||||
{#if details.license}
|
||||
<dt class="text-muted">{t("details.license")}</dt><dd class="break-words">{details.license}</dd>
|
||||
{/if}
|
||||
{#if details.installerType}
|
||||
<dt class="text-muted">{t("details.installerType")}</dt><dd>{details.installerType}</dd>
|
||||
{/if}
|
||||
{#if details.installerUrl}
|
||||
<dt class="text-muted">{t("details.installerUrl")}</dt><dd class="break-all text-[13px]">{details.installerUrl}</dd>
|
||||
{/if}
|
||||
{#if details.hash}
|
||||
<dt class="text-muted">{t("details.hash")}</dt><dd class="break-all font-mono text-[12px]">{details.hash}</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{#if details.homepage}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-[8px] border border-border px-3 py-2 hover:bg-hover"
|
||||
onclick={() => openUrl(details!.homepage!)}
|
||||
>
|
||||
<ExternalLink size={16} />{t("details.openHomepage")}
|
||||
</button>
|
||||
{/if}
|
||||
{#if shownVersions.length}
|
||||
<section>
|
||||
<h4 class="mb-1 text-[13px] font-semibold text-muted">{t("details.versions")}</h4>
|
||||
<ul class="flex flex-wrap gap-1.5">
|
||||
{#each shownVersions as v (v)}
|
||||
<li class="rounded-[6px] bg-surface-2 px-2 py-0.5 text-[13px]">{v}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if hiddenVersions > 0}
|
||||
<p class="mt-1 text-[13px] text-muted">{tp("details.moreVersions", hiddenVersions, { count: formatNumber(hiddenVersions) })}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="text-muted">{t("details.none")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { renderMarkdown } from "../lib/markdown";
|
||||
|
||||
interface Props {
|
||||
source: string;
|
||||
}
|
||||
let { source }: Props = $props();
|
||||
|
||||
const html = $derived(renderMarkdown(source));
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
const anchor = (event.target as HTMLElement).closest("a");
|
||||
if (!anchor) return;
|
||||
event.preventDefault();
|
||||
const href = anchor.getAttribute("href");
|
||||
if (href && /^https?:/i.test(href)) void openUrl(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Inhalt wird vorher mit DOMPurify bereinigt. -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div class="notes" onclick={onClick}>{@html html}</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
labelledBy: string;
|
||||
onescape?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
let { labelledBy, onescape, children }: Props = $props();
|
||||
|
||||
let dialog = $state<HTMLDivElement>();
|
||||
|
||||
$effect(() => {
|
||||
// Fokus in den Dialog setzen und beim Schließen zurückgeben.
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
dialog?.querySelector<HTMLElement>("[data-autofocus]")?.focus();
|
||||
return () => previous?.focus?.();
|
||||
});
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onescape?.();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !dialog) return;
|
||||
const focusable = [...dialog.querySelectorAll<HTMLElement>("button:not([disabled]), a[href], [tabindex]:not([tabindex='-1'])")];
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4 backdrop-blur-sm" role="presentation">
|
||||
<div
|
||||
bind:this={dialog}
|
||||
class="fade-in flex max-h-[88vh] w-[640px] max-w-full flex-col overflow-hidden rounded-[12px] border border-border bg-surface text-[15px] leading-6 shadow-[0_24px_64px_rgba(0,0,0,0.55)]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={labelledBy}
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { RefreshCw, Search, TriangleAlert, X } from "@lucide/svelte";
|
||||
import VirtualTable from "./VirtualTable.svelte";
|
||||
import DetailsPanel from "./DetailsPanel.svelte";
|
||||
import type { ListStore } from "../stores/list.svelte";
|
||||
import type { Package } from "../lib/types";
|
||||
import type { Column } from "../lib/table";
|
||||
import { t, tp, formatTime } from "../i18n/t";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
store: ListStore;
|
||||
/** `search`: Suche wird per Eingabetaste ausgelöst; `filter`: Liste wird lokal gefiltert. */
|
||||
mode: "search" | "filter";
|
||||
placeholder: string;
|
||||
columns: Column<Package>[];
|
||||
storageKey: string;
|
||||
countKey: string;
|
||||
emptyTitle: string;
|
||||
emptyText: (query: string) => string;
|
||||
/** Wird angezeigt, bevor in der Suchansicht erstmals gesucht wurde. */
|
||||
idle?: Snippet;
|
||||
extraActions?: Snippet;
|
||||
}
|
||||
|
||||
let { title, store, mode, placeholder, columns, storageKey, countKey, emptyTitle, emptyText, idle, extraActions }: Props = $props();
|
||||
|
||||
let input = $state("");
|
||||
let sourceFilter = $state("");
|
||||
let selected = $state(new Set<string>());
|
||||
let active = $state<Package | null>(null);
|
||||
|
||||
const sources = $derived([...new Set(store.items.map((p) => p.source).filter((s): s is string => !!s))].sort());
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const needle = input.trim().toLowerCase();
|
||||
return store.items.filter((p) => {
|
||||
if (sourceFilter && p.source !== sourceFilter) return false;
|
||||
if (mode === "filter" && needle) return p.name.toLowerCase().includes(needle) || p.id.toLowerCase().includes(needle);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const showIdle = $derived(mode === "search" && !store.loaded && !store.loading && !store.error);
|
||||
|
||||
// Auswahl nur auf Pakete beschränken, die noch in der Liste vorkommen.
|
||||
$effect(() => {
|
||||
const keys = new Set(store.items.map((p) => p.key));
|
||||
const kept = [...selected].filter((k) => keys.has(k));
|
||||
if (kept.length !== selected.size) selected = new Set(kept);
|
||||
if (active && !keys.has(active.key)) active = null;
|
||||
});
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (mode === "search" && input.trim()) {
|
||||
active = null;
|
||||
selected = new Set();
|
||||
void store.load(input.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
void store.load(mode === "search" ? store.query : "");
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="flex h-full min-h-0 flex-col gap-4 p-6">
|
||||
<header class="flex flex-wrap items-center gap-3">
|
||||
<h1 class="mr-auto text-[22px] font-semibold leading-7">{title}</h1>
|
||||
{@render extraActions?.()}
|
||||
</header>
|
||||
|
||||
<form class="flex flex-wrap items-center gap-3" onsubmit={submit} role="search">
|
||||
<label class="relative min-w-[260px] flex-1">
|
||||
<Search size={18} class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
type="search"
|
||||
bind:value={input}
|
||||
{placeholder}
|
||||
class="h-10 w-full rounded-[8px] border border-border bg-surface pl-10 pr-3 transition-colors placeholder:text-muted hover:border-muted focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
{#if mode === "search"}
|
||||
<button type="submit" class="h-10 rounded-[8px] bg-accent px-5 font-semibold text-accent-fg transition-colors hover:bg-accent-strong disabled:opacity-50" disabled={!input.trim() || store.loading}>
|
||||
{t("common.search")}
|
||||
</button>
|
||||
{/if}
|
||||
{#if sources.length > 1}
|
||||
<select bind:value={sourceFilter} class="h-10 rounded-[8px] border border-border bg-surface px-3" aria-label={t("filters.source")}>
|
||||
<option value="">{t("filters.allSources")}</option>
|
||||
{#each sources as s (s)}<option value={s}>{s}</option>{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-[8px] border border-border px-3 transition-colors hover:bg-hover disabled:opacity-50"
|
||||
onclick={reload}
|
||||
disabled={store.loading || (mode === "search" && !store.query)}
|
||||
>
|
||||
<RefreshCw size={16} class={store.loading ? "animate-spin" : ""} />{t("common.reload")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="flex min-h-[24px] flex-wrap items-center gap-3 text-[14px] text-muted">
|
||||
{#if store.loaded}
|
||||
<span>{tp(countKey, filtered.length)}</span>
|
||||
{/if}
|
||||
{#if selected.size > 0}
|
||||
<span class="flex items-center gap-2 text-fg">
|
||||
{tp("table.selected", selected.size)}
|
||||
<button type="button" class="inline-flex items-center gap-1 text-accent hover:underline" onclick={() => (selected = new Set())}>
|
||||
<X size={14} />{t("table.clearSelection")}
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
{#if store.loadedAt}
|
||||
<span class="ml-auto">{t("status.lastUpdated", { time: formatTime(store.loadedAt) })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if store.error}
|
||||
<div class="flex items-start gap-3 rounded-[12px] border border-danger/40 bg-danger/10 p-4 text-danger" role="alert">
|
||||
<TriangleAlert size={20} class="mt-0.5 shrink-0" />
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold">{t("errors.loadFailed")}</p>
|
||||
<p class="text-[14px]">{store.error}</p>
|
||||
</div>
|
||||
<button type="button" class="rounded-[8px] border border-danger/50 px-3 py-1.5 hover:bg-danger/10" onclick={reload}>{t("common.retry")}</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex min-h-0 flex-1 gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<VirtualTable
|
||||
{columns}
|
||||
rows={filtered}
|
||||
rowKey={(p) => p.key}
|
||||
bind:selected
|
||||
activeKey={active?.key ?? null}
|
||||
onactivate={(p) => (active = p)}
|
||||
{storageKey}
|
||||
loading={store.loading}
|
||||
>
|
||||
{#snippet empty()}
|
||||
{#if showIdle}
|
||||
{@render idle?.()}
|
||||
{:else if !store.error}
|
||||
<div class="flex flex-col items-center gap-1 px-6 py-16 text-center">
|
||||
<p class="text-[16px] font-semibold">{emptyTitle}</p>
|
||||
<p class="max-w-[480px] text-muted">{emptyText(mode === "search" ? store.query : input.trim())}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</VirtualTable>
|
||||
</div>
|
||||
{#if active}
|
||||
<DetailsPanel pkg={active} onclose={() => (active = null)} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { TriangleAlert, Info, LoaderCircle } from "@lucide/svelte";
|
||||
import { managers } from "../stores/managers.svelte";
|
||||
import { t } from "../i18n/t";
|
||||
</script>
|
||||
|
||||
{#if managers.wingetMissing}
|
||||
<div class="fade-in mx-6 mt-6 rounded-[12px] border border-warning/50 bg-warning/10 p-4" role="alert">
|
||||
<div class="flex gap-3">
|
||||
<TriangleAlert size={22} class="mt-0.5 shrink-0 text-warning" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<p class="font-semibold">{t("setup.wingetMissingTitle")}</p>
|
||||
<p class="text-[14px]">{t("setup.wingetMissingText")}</p>
|
||||
{#if managers.installError}<p class="text-[14px] text-danger">{managers.installError}</p>{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-[8px] bg-accent px-4 font-semibold text-accent-fg hover:bg-accent-strong disabled:opacity-60"
|
||||
onclick={() => managers.installAppInstaller()}
|
||||
disabled={managers.installing !== null}
|
||||
>
|
||||
{#if managers.installing === "appInstaller"}<LoaderCircle size={16} class="animate-spin" />{t("setup.installing")}{:else}{t("setup.installAppInstaller")}{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if managers.moduleMissing && !managers.moduleHintDismissed}
|
||||
<div class="fade-in mx-6 mt-6 rounded-[12px] border border-border bg-surface p-4" role="status">
|
||||
<div class="flex gap-3">
|
||||
<Info size={22} class="mt-0.5 shrink-0 text-accent" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<p class="font-semibold">{t("setup.moduleMissingTitle")}</p>
|
||||
<p class="text-[14px] text-muted">{t("setup.moduleMissingText")}</p>
|
||||
{#if managers.installError}<p class="text-[14px] text-danger">{managers.installError}</p>{/if}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-[8px] bg-accent px-4 font-semibold text-accent-fg hover:bg-accent-strong disabled:opacity-60"
|
||||
onclick={() => managers.installModule()}
|
||||
disabled={managers.installing !== null}
|
||||
>
|
||||
{#if managers.installing === "module"}<LoaderCircle size={16} class="animate-spin" />{t("setup.installing")}{:else}{t("setup.installModule")}{/if}
|
||||
</button>
|
||||
<button type="button" class="h-10 rounded-[8px] border border-border px-4 hover:bg-hover" onclick={() => (managers.moduleHintDismissed = true)}>
|
||||
{t("common.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { PackageSearch, CircleArrowUp, HardDrive, Boxes, Settings } from "@lucide/svelte";
|
||||
import { t, formatNumber } from "../i18n/t";
|
||||
import type { ViewId } from "../lib/types";
|
||||
import { ui } from "../stores/packages.svelte";
|
||||
|
||||
interface Props {
|
||||
updateCount: number;
|
||||
}
|
||||
let { updateCount }: Props = $props();
|
||||
|
||||
const items = [
|
||||
{ id: "discover", icon: PackageSearch, label: "nav.discover" },
|
||||
{ id: "updates", icon: CircleArrowUp, label: "nav.updates" },
|
||||
{ id: "installed", icon: HardDrive, label: "nav.installed" },
|
||||
{ id: "bundles", icon: Boxes, label: "nav.bundles" },
|
||||
] as const satisfies readonly { id: ViewId; icon: unknown; label: string }[];
|
||||
</script>
|
||||
|
||||
<nav class="flex h-full w-[248px] shrink-0 flex-col gap-1 border-r border-border bg-surface p-3" aria-label={t("app.name")}>
|
||||
<div class="mb-3 flex items-center gap-3 px-2 py-2">
|
||||
<div class="grid size-9 place-items-center rounded-[10px] bg-accent text-accent-fg">
|
||||
<CircleArrowUp size={20} strokeWidth={2.5} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-[15px] font-semibold leading-5">{t("app.name")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each items as item (item.id)}
|
||||
{@const Icon = item.icon}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 items-center gap-3 rounded-[8px] px-3 text-left transition-colors duration-150
|
||||
{ui.view === item.id ? 'bg-accent/20 font-semibold text-fg' : 'text-muted hover:bg-hover hover:text-fg'}"
|
||||
aria-current={ui.view === item.id ? "page" : undefined}
|
||||
onclick={() => (ui.view = item.id)}
|
||||
>
|
||||
<Icon size={20} class={ui.view === item.id ? "text-accent" : ""} />
|
||||
<span class="flex-1 truncate">{t(item.label)}</span>
|
||||
{#if item.id === "updates" && updateCount > 0}
|
||||
<span class="min-w-6 rounded-full bg-accent px-2 text-center text-[12px] font-bold leading-6 text-accent-fg">{formatNumber(updateCount)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<div class="mt-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-11 w-full items-center gap-3 rounded-[8px] px-3 text-left transition-colors duration-150
|
||||
{ui.view === 'settings' ? 'bg-accent/20 font-semibold text-fg' : 'text-muted hover:bg-hover hover:text-fg'}"
|
||||
aria-current={ui.view === "settings" ? "page" : undefined}
|
||||
onclick={() => (ui.view = "settings")}
|
||||
>
|
||||
<Settings size={20} class={ui.view === "settings" ? "text-accent" : ""} />
|
||||
<span>{t("nav.settings")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { Download, LoaderCircle, TriangleAlert } from "@lucide/svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import { updater } from "../stores/updater.svelte";
|
||||
import { t, formatBytes, formatDate, formatNumber } from "../i18n/t";
|
||||
|
||||
const info = $derived(updater.offer);
|
||||
const statusText = $derived(
|
||||
updater.phase === "downloading"
|
||||
? t("updater.downloading")
|
||||
: updater.phase === "installing"
|
||||
? t("updater.installing")
|
||||
: updater.phase === "restarting"
|
||||
? t("updater.restarting")
|
||||
: "",
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if updater.dialogOpen && info}
|
||||
<Modal labelledBy="update-title" onescape={() => updater.later()}>
|
||||
<header class="border-b border-border px-6 py-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid size-10 place-items-center rounded-[10px] bg-accent text-accent-fg"><Download size={22} /></div>
|
||||
<div>
|
||||
<h2 id="update-title" class="text-[20px] font-semibold leading-7">{t("updater.title")}</h2>
|
||||
<p class="text-muted">
|
||||
{t("updater.versionChange", { current: info.currentVersion, next: info.version })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 flex flex-wrap gap-x-5 text-[14px] text-muted">
|
||||
{#if info.date}<span>{t("updater.released", { date: formatDate(new Date(info.date * 1000)) })}</span>{/if}
|
||||
{#if info.size}<span>{t("updater.size", { size: formatBytes(info.size) })}</span>{/if}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<h3 class="mb-2 text-[13px] font-semibold uppercase tracking-wide text-muted">{t("updater.notes")}</h3>
|
||||
{#if info.notes.trim()}
|
||||
<Markdown source={info.notes} />
|
||||
{:else}
|
||||
<p class="text-muted">{t("updater.noNotes")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if updater.busy}
|
||||
<div class="border-t border-border px-6 py-4" role="status" aria-live="polite">
|
||||
<div class="mb-2 flex items-center justify-between gap-4">
|
||||
<span class="flex items-center gap-2 font-medium"><LoaderCircle size={18} class="animate-spin text-accent" />{statusText}</span>
|
||||
{#if updater.phase === "downloading"}
|
||||
<span class="tabular-nums text-muted">
|
||||
{#if updater.percent !== null}{formatNumber(updater.percent)} % · {/if}{formatBytes(updater.downloaded)}{#if updater.total}
|
||||
{t("updater.of")} {formatBytes(updater.total)}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="h-2.5 overflow-hidden rounded-full bg-surface-2" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={updater.percent ?? undefined}>
|
||||
<div
|
||||
class="h-full rounded-full bg-accent transition-[width] duration-150 {updater.percent === null && updater.phase === 'downloading' ? 'w-1/3 animate-pulse' : ''}"
|
||||
style={updater.percent !== null || updater.phase !== "downloading" ? `width: ${updater.phase === "downloading" ? updater.percent : 100}%` : undefined}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if updater.phase === "error"}
|
||||
<div class="flex items-start gap-3 border-t border-danger/40 bg-danger/10 px-6 py-4 text-danger" role="alert">
|
||||
<TriangleAlert size={20} class="mt-0.5 shrink-0" />
|
||||
<p>{updater.error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<footer class="flex flex-wrap items-center justify-end gap-3 border-t border-border bg-surface-2/50 px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-[8px] px-4 text-muted transition-colors hover:bg-hover hover:text-fg disabled:opacity-40"
|
||||
onclick={() => updater.skip()}
|
||||
disabled={updater.busy}
|
||||
>
|
||||
{t("updater.skip")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 rounded-[8px] border border-border px-4 transition-colors hover:bg-hover disabled:opacity-40"
|
||||
onclick={() => updater.later()}
|
||||
disabled={updater.busy}
|
||||
>
|
||||
{t("updater.later")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-autofocus
|
||||
class="h-10 rounded-[8px] bg-accent px-5 font-semibold text-accent-fg transition-colors hover:bg-accent-strong disabled:opacity-60"
|
||||
onclick={() => updater.install()}
|
||||
disabled={updater.busy}
|
||||
>
|
||||
{updater.phase === "error" ? t("common.retry") : t("updater.now")}
|
||||
</button>
|
||||
</footer>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { CircleCheck } from "@lucide/svelte";
|
||||
import Modal from "./Modal.svelte";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
import { updater } from "../stores/updater.svelte";
|
||||
import { t } from "../i18n/t";
|
||||
</script>
|
||||
|
||||
{#if updater.notice && !updater.dialogOpen}
|
||||
<Modal labelledBy="updated-title" onescape={() => updater.dismissNotice()}>
|
||||
<header class="flex items-center gap-3 border-b border-border px-6 py-5">
|
||||
<div class="grid size-10 place-items-center rounded-[10px] bg-success/20 text-success"><CircleCheck size={22} /></div>
|
||||
<h2 id="updated-title" class="text-[20px] font-semibold leading-7">{t("updater.updatedTo", { version: updater.notice.version })}</h2>
|
||||
</header>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<h3 class="mb-2 text-[13px] font-semibold uppercase tracking-wide text-muted">{t("updater.changes")}</h3>
|
||||
{#if updater.notice.notes.trim()}
|
||||
<Markdown source={updater.notice.notes} />
|
||||
{:else}
|
||||
<p class="text-muted">{t("updater.noNotes")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<footer class="flex justify-end border-t border-border bg-surface-2/50 px-6 py-4">
|
||||
<button type="button" data-autofocus class="h-10 rounded-[8px] bg-accent px-5 font-semibold text-accent-fg transition-colors hover:bg-accent-strong" onclick={() => updater.dismissNotice()}>
|
||||
{t("updater.ok")}
|
||||
</button>
|
||||
</footer>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -0,0 +1,216 @@
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from "svelte";
|
||||
import { ChevronUp, ChevronDown } from "@lucide/svelte";
|
||||
import { t } from "../i18n/t";
|
||||
import { compareText, type Column } from "../lib/table";
|
||||
|
||||
interface Props {
|
||||
columns: Column<T>[];
|
||||
rows: T[];
|
||||
rowKey: (row: T) => string;
|
||||
selected?: Set<string>;
|
||||
activeKey?: string | null;
|
||||
onactivate?: (row: T) => void;
|
||||
storageKey: string;
|
||||
loading?: boolean;
|
||||
empty?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
columns,
|
||||
rows,
|
||||
rowKey,
|
||||
selected = $bindable(new Set<string>()),
|
||||
activeKey = null,
|
||||
onactivate,
|
||||
storageKey,
|
||||
loading = false,
|
||||
empty,
|
||||
}: Props = $props();
|
||||
|
||||
const ROW_HEIGHT = 40;
|
||||
const HEADER_HEIGHT = 40;
|
||||
const OVERSCAN = 8;
|
||||
const CHECK_WIDTH = 44;
|
||||
|
||||
let scroller = $state<HTMLDivElement>();
|
||||
let scrollTop = $state(0);
|
||||
let viewportHeight = $state(600);
|
||||
let sortKey = $state<string | null>(null);
|
||||
let sortDir = $state<1 | -1>(1);
|
||||
|
||||
function loadWidths(): Record<string, number> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(`vu.columns.${storageKey}`) ?? "{}") as Record<string, number>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
let widths = $state<Record<string, number>>(loadWidths());
|
||||
|
||||
const widthOf = (col: Column<T>) => widths[col.key] ?? col.width;
|
||||
const template = $derived(
|
||||
`${CHECK_WIDTH}px ${columns.map((c) => `${widthOf(c)}px`).join(" ")}`,
|
||||
);
|
||||
const totalWidth = $derived(CHECK_WIDTH + columns.reduce((sum, c) => sum + widthOf(c), 0));
|
||||
|
||||
const sorted = $derived.by(() => {
|
||||
const col = columns.find((c) => c.key === sortKey);
|
||||
if (!col) return rows;
|
||||
return [...rows].sort((a, b) => sortDir * compareText(col.value(a), col.value(b)));
|
||||
});
|
||||
|
||||
const start = $derived(Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN));
|
||||
const end = $derived(Math.min(sorted.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + OVERSCAN));
|
||||
const visible = $derived(sorted.slice(start, end));
|
||||
const allSelected = $derived(sorted.length > 0 && sorted.every((r) => selected.has(rowKey(r))));
|
||||
|
||||
function toggleSort(key: string) {
|
||||
if (sortKey === key) {
|
||||
if (sortDir === 1) sortDir = -1;
|
||||
else {
|
||||
sortKey = null;
|
||||
sortDir = 1;
|
||||
}
|
||||
} else {
|
||||
sortKey = key;
|
||||
sortDir = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRow(row: T) {
|
||||
const next = new Set(selected);
|
||||
const key = rowKey(row);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
selected = next;
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
selected = allSelected ? new Set() : new Set(sorted.map(rowKey));
|
||||
}
|
||||
|
||||
function startResize(event: PointerEvent, col: Column<T>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const startX = event.clientX;
|
||||
const startWidth = widthOf(col);
|
||||
const min = col.min ?? 80;
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
target.setPointerCapture(event.pointerId);
|
||||
const move = (e: PointerEvent) => {
|
||||
widths = { ...widths, [col.key]: Math.max(min, startWidth + e.clientX - startX) };
|
||||
};
|
||||
const up = () => {
|
||||
target.removeEventListener("pointermove", move);
|
||||
target.removeEventListener("pointerup", up);
|
||||
localStorage.setItem(`vu.columns.${storageKey}`, JSON.stringify(widths));
|
||||
};
|
||||
target.addEventListener("pointermove", move);
|
||||
target.addEventListener("pointerup", up);
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||
if ((event.target as HTMLElement).closest("input, button")) return;
|
||||
event.preventDefault();
|
||||
const index = sorted.findIndex((r) => rowKey(r) === activeKey);
|
||||
const next = Math.min(sorted.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)));
|
||||
const row = sorted[next];
|
||||
if (!row) return;
|
||||
onactivate?.(row);
|
||||
const top = next * ROW_HEIGHT;
|
||||
if (scroller) {
|
||||
if (top < scroller.scrollTop) scroller.scrollTop = top;
|
||||
else if (top + ROW_HEIGHT > scroller.scrollTop + viewportHeight - HEADER_HEIGHT)
|
||||
scroller.scrollTop = top + ROW_HEIGHT - viewportHeight + HEADER_HEIGHT;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative h-full overflow-auto rounded-[12px] border border-border bg-surface outline-none"
|
||||
bind:this={scroller}
|
||||
bind:clientHeight={viewportHeight}
|
||||
onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)}
|
||||
onkeydown={onKeydown}
|
||||
role="grid"
|
||||
tabindex="0"
|
||||
aria-rowcount={sorted.length}
|
||||
>
|
||||
<div style="min-width: {totalWidth}px">
|
||||
<div
|
||||
class="sticky top-0 z-10 grid items-center border-b border-border bg-surface-2 text-[13px] font-semibold text-muted"
|
||||
style="grid-template-columns: {template}; height: {HEADER_HEIGHT}px"
|
||||
role="row"
|
||||
>
|
||||
<div class="flex justify-center">
|
||||
<input type="checkbox" checked={allSelected} onchange={toggleAll} aria-label={t("table.selectAll")} class="size-4 accent-[var(--accent)]" />
|
||||
</div>
|
||||
{#each columns as col (col.key)}
|
||||
<div class="relative flex h-full items-center" role="columnheader">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-full items-center gap-1 px-3 text-left hover:text-fg"
|
||||
onclick={() => toggleSort(col.key)}
|
||||
>
|
||||
<span class="truncate">{col.label}</span>
|
||||
{#if sortKey === col.key}
|
||||
{#if sortDir === 1}<ChevronUp size={14} />{:else}<ChevronDown size={14} />{/if}
|
||||
{/if}
|
||||
</button>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute right-0 top-2 h-6 w-2 cursor-col-resize rounded hover:bg-accent/40"
|
||||
onpointerdown={(e) => startResize(e, col)}
|
||||
title={t("table.resizeColumn")}
|
||||
></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading && rows.length === 0}
|
||||
{#each Array(12) as _, i (i)}
|
||||
<div class="flex items-center gap-4 px-4" style="height: {ROW_HEIGHT}px">
|
||||
<div class="skeleton h-4 w-4"></div>
|
||||
<div class="skeleton h-4" style="width: {30 + ((i * 17) % 35)}%"></div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else if sorted.length === 0}
|
||||
<div class="fade-in">{@render empty?.()}</div>
|
||||
{:else}
|
||||
<div class="relative" style="height: {sorted.length * ROW_HEIGHT}px">
|
||||
{#each visible as row, i (rowKey(row))}
|
||||
{@const index = start + i}
|
||||
{@const key = rowKey(row)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="absolute left-0 right-0 grid cursor-default items-center transition-colors duration-150
|
||||
{key === activeKey ? 'bg-accent/20' : index % 2 === 1 ? 'bg-surface-2/50 hover:bg-hover' : 'hover:bg-hover'}"
|
||||
style="grid-template-columns: {template}; height: {ROW_HEIGHT}px; transform: translateY({index * ROW_HEIGHT}px)"
|
||||
role="row"
|
||||
tabindex="-1"
|
||||
aria-selected={key === activeKey}
|
||||
onclick={() => onactivate?.(row)}
|
||||
>
|
||||
<div class="flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(key)}
|
||||
onchange={() => toggleRow(row)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
aria-label={t("table.selectRow")}
|
||||
class="size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
{#each columns as col (col.key)}
|
||||
<div class="truncate px-3 {col.muted ? 'text-muted' : ''}" title={col.value(row)} role="gridcell">
|
||||
{col.value(row)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,190 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "VitosUpdater",
|
||||
"tagline": "Software verwalten und aktuell halten"
|
||||
},
|
||||
"nav": {
|
||||
"discover": "Pakete entdecken",
|
||||
"updates": "Software-Updates",
|
||||
"installed": "Installierte Pakete",
|
||||
"bundles": "Paket-Bundles",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"common": {
|
||||
"search": "Suchen",
|
||||
"reload": "Neu laden",
|
||||
"close": "Schließen",
|
||||
"retry": "Erneut versuchen",
|
||||
"unknown": "Unbekannt",
|
||||
"loading": "Wird geladen …",
|
||||
"dismiss": "Ausblenden",
|
||||
"details": "Details"
|
||||
},
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"id": "ID",
|
||||
"version": "Version",
|
||||
"installedVersion": "Installiert",
|
||||
"availableVersion": "Verfügbar",
|
||||
"source": "Quelle",
|
||||
"manager": "Manager"
|
||||
},
|
||||
"table": {
|
||||
"selectAll": "Alle sichtbaren Zeilen auswählen",
|
||||
"selectRow": "Zeile auswählen",
|
||||
"selected": {
|
||||
"one": "{count} Paket ausgewählt",
|
||||
"other": "{count} Pakete ausgewählt"
|
||||
},
|
||||
"clearSelection": "Auswahl aufheben",
|
||||
"resizeColumn": "Spaltenbreite ändern"
|
||||
},
|
||||
"filters": {
|
||||
"manager": "Paketmanager",
|
||||
"source": "Quelle",
|
||||
"allManagers": "Alle Manager",
|
||||
"allSources": "Alle Quellen"
|
||||
},
|
||||
"discover": {
|
||||
"title": "Pakete entdecken",
|
||||
"placeholder": "Pakete suchen (Name, ID oder Stichwort)",
|
||||
"hintTitle": "Pakete finden",
|
||||
"hint": "Suchbegriff eingeben, um Pakete in allen aktivierten Quellen zu finden.",
|
||||
"noResultsTitle": "Keine Treffer",
|
||||
"noResults": "Für „{query}“ wurde kein Paket gefunden.",
|
||||
"results": {
|
||||
"one": "{count} Treffer",
|
||||
"other": "{count} Treffer"
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
"title": "Software-Updates",
|
||||
"emptyTitle": "Alles aktuell",
|
||||
"empty": "Alle Programme sind auf dem neuesten Stand.",
|
||||
"count": {
|
||||
"one": "{count} Aktualisierung verfügbar",
|
||||
"other": "{count} Aktualisierungen verfügbar"
|
||||
},
|
||||
"check": "Nach Updates suchen",
|
||||
"filterPlaceholder": "Aktualisierungen filtern …"
|
||||
},
|
||||
"installed": {
|
||||
"title": "Installierte Pakete",
|
||||
"filterPlaceholder": "Installierte Pakete filtern …",
|
||||
"emptyTitle": "Keine Pakete gefunden",
|
||||
"empty": "Es wurden keine installierten Pakete gefunden.",
|
||||
"noMatches": "Kein installiertes Paket passt zu „{query}“.",
|
||||
"count": {
|
||||
"one": "{count} Paket",
|
||||
"other": "{count} Pakete"
|
||||
}
|
||||
},
|
||||
"bundles": {
|
||||
"title": "Paket-Bundles",
|
||||
"emptyTitle": "Noch keine Bundles",
|
||||
"empty": "Bundles fassen mehrere Pakete zusammen, damit sie gemeinsam installiert werden können. Diese Funktion folgt in einer späteren Version."
|
||||
},
|
||||
"details": {
|
||||
"title": "Paketdetails",
|
||||
"selectHint": "Ein Paket auswählen, um Details anzuzeigen.",
|
||||
"none": "Für dieses Paket sind keine Details verfügbar.",
|
||||
"loadFailed": "Die Details konnten nicht geladen werden.",
|
||||
"description": "Beschreibung",
|
||||
"publisher": "Herausgeber",
|
||||
"license": "Lizenz",
|
||||
"homepage": "Webseite",
|
||||
"openHomepage": "Webseite öffnen",
|
||||
"installerType": "Installationstyp",
|
||||
"installerUrl": "Download-Adresse",
|
||||
"hash": "SHA-256-Prüfsumme",
|
||||
"versions": "Verfügbare Versionen",
|
||||
"moreVersions": {
|
||||
"one": "und {count} weitere Version",
|
||||
"other": "und {count} weitere Versionen"
|
||||
},
|
||||
"installedVersion": "Installierte Version",
|
||||
"availableVersion": "Verfügbare Version"
|
||||
},
|
||||
"setup": {
|
||||
"wingetMissingTitle": "WinGet wurde nicht gefunden",
|
||||
"wingetMissingText": "Für die Paketverwaltung wird WinGet (Windows-Paket-Manager) benötigt. Es ist Bestandteil des „App-Installers“ von Microsoft.",
|
||||
"installAppInstaller": "App-Installer installieren",
|
||||
"moduleMissingTitle": "PowerShell-Modul nicht installiert",
|
||||
"moduleMissingText": "Das Modul Microsoft.WinGet.Client ist optional. Ohne das Modul wertet VitosUpdater die Textausgabe von WinGet aus. Mit dem Modul stehen die Daten strukturiert zur Verfügung.",
|
||||
"installModule": "Modul installieren",
|
||||
"installing": "Installation läuft …",
|
||||
"installFailed": "Die Installation ist fehlgeschlagen.",
|
||||
"detecting": "Paketmanager werden erkannt …"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"appearance": "Erscheinungsbild",
|
||||
"theme": "Design",
|
||||
"themeDark": "Dunkel",
|
||||
"themeLight": "Hell",
|
||||
"themeSystem": "System folgen",
|
||||
"accent": "Akzentfarbe",
|
||||
"accentBlue": "Blau",
|
||||
"accentViolet": "Violett",
|
||||
"accentTeal": "Türkis",
|
||||
"accentGreen": "Grün",
|
||||
"accentOrange": "Orange",
|
||||
"accentPink": "Rosa",
|
||||
"managers": "Paketmanager",
|
||||
"statusFound": "Erkannt",
|
||||
"statusMissing": "Nicht gefunden",
|
||||
"path": "Pfad",
|
||||
"version": "Version",
|
||||
"module": "PowerShell-Modul",
|
||||
"moduleFound": "Version {version}",
|
||||
"moduleMissing": "Nicht installiert",
|
||||
"redetect": "Erneut erkennen",
|
||||
"sources": "Quellen",
|
||||
"sourceExplicit": "nur auf Anfrage",
|
||||
"noSources": "Keine Quellen gefunden.",
|
||||
"updates": {
|
||||
"title": "Programm-Updates",
|
||||
"currentVersion": "Installierte Version",
|
||||
"lastCheck": "Letzte Prüfung",
|
||||
"never": "Noch nicht geprüft",
|
||||
"lastCheckValue": "{date}, {time} Uhr",
|
||||
"interval": "Prüfintervall",
|
||||
"intervalMinutes": "{count} Minuten",
|
||||
"intervalHour": "1 Stunde",
|
||||
"intervalHours": "{count} Stunden",
|
||||
"check": "Nach Updates suchen",
|
||||
"checking": "Es wird gesucht …",
|
||||
"upToDate": "VitosUpdater ist auf dem neuesten Stand.",
|
||||
"skippedVersion": "Übersprungene Version",
|
||||
"resetSkipped": "Zurücksetzen",
|
||||
"lastError": "Letzter Fehler"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"loadFailed": "Die Daten konnten nicht geladen werden.",
|
||||
"unexpected": "Ein unerwarteter Fehler ist aufgetreten."
|
||||
},
|
||||
"updater": {
|
||||
"title": "Update verfügbar",
|
||||
"versionChange": "Version {current} → {next}",
|
||||
"released": "Veröffentlicht am {date}",
|
||||
"size": "Downloadgröße: {size}",
|
||||
"notes": "Neuerungen",
|
||||
"noNotes": "Für diese Version liegen keine Release-Notizen vor.",
|
||||
"now": "Jetzt aktualisieren",
|
||||
"later": "Später erinnern",
|
||||
"skip": "Diese Version überspringen",
|
||||
"downloading": "Wird heruntergeladen …",
|
||||
"installing": "Wird installiert …",
|
||||
"restarting": "Neustart …",
|
||||
"of": "von",
|
||||
"installFailed": "Das Update konnte nicht installiert werden.",
|
||||
"checkFailed": "Die Update-Prüfung ist fehlgeschlagen.",
|
||||
"updatedTo": "Aktualisiert auf Version {version}",
|
||||
"changes": "Änderungen",
|
||||
"ok": "OK"
|
||||
},
|
||||
"status": {
|
||||
"lastUpdated": "Zuletzt aktualisiert: {time} Uhr"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import de from "./de.json";
|
||||
|
||||
type Params = Record<string, string | number>;
|
||||
|
||||
function lookup(key: string): unknown {
|
||||
return key.split(".").reduce<unknown>((node, part) => {
|
||||
return node && typeof node === "object" ? (node as Record<string, unknown>)[part] : undefined;
|
||||
}, de);
|
||||
}
|
||||
|
||||
function fill(template: string, params?: Params): string {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? `{${name}}`));
|
||||
}
|
||||
|
||||
/** Liefert den deutschen Text zu einem Schlüssel; Platzhalter wie `{count}` werden ersetzt. */
|
||||
export function t(key: string, params?: Params): string {
|
||||
const value = lookup(key);
|
||||
if (typeof value !== "string") {
|
||||
console.warn(`Fehlender Textschlüssel: ${key}`);
|
||||
return `⟦${key}⟧`;
|
||||
}
|
||||
return fill(value, params);
|
||||
}
|
||||
|
||||
/** Liefert die Singular-/Pluralform eines Textes (`one` bei genau 1, sonst `other`). */
|
||||
export function tp(key: string, count: number, params?: Params): string {
|
||||
const value = lookup(key) as { one?: string; other?: string } | undefined;
|
||||
const template = count === 1 ? value?.one : value?.other;
|
||||
if (typeof template !== "string") {
|
||||
console.warn(`Fehlender Pluralschlüssel: ${key}`);
|
||||
return `⟦${key}⟧`;
|
||||
}
|
||||
return fill(template, { count: formatNumber(count), ...params });
|
||||
}
|
||||
|
||||
const numberFormat = new Intl.NumberFormat("de-DE");
|
||||
|
||||
/** Zahl mit Tausenderpunkt und Dezimalkomma, z. B. `1.234,5`. */
|
||||
export function formatNumber(value: number, fractionDigits = 0): string {
|
||||
if (fractionDigits === 0) return numberFormat.format(value);
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** Dateigröße, z. B. `1.234,5 MB`. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${formatNumber(value, unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/** Datum im Format `dd.MM.yyyy`. */
|
||||
export function formatDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }).format(date);
|
||||
}
|
||||
|
||||
/** Uhrzeit im 24-Stunden-Format `HH:mm`. */
|
||||
export function formatTime(date: Date): string {
|
||||
return new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", hour12: false }).format(date);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
CheckOutcome,
|
||||
InstallEvent,
|
||||
ManagerId,
|
||||
ManagerInfo,
|
||||
Package,
|
||||
PackageDetails,
|
||||
Source,
|
||||
UpdateInfo,
|
||||
UpdateNotice,
|
||||
UpdaterSettings,
|
||||
UpdaterStatus,
|
||||
} from "./types";
|
||||
|
||||
/** Typisierte Aufrufe der Tauri-Befehle. Fehler sind bereits deutsche Texte aus dem Backend. */
|
||||
export const api = {
|
||||
detectManagers: () => invoke<ManagerInfo[]>("detect_managers"),
|
||||
searchPackages: (manager: ManagerId, query: string) =>
|
||||
invoke<Package[]>("search_packages", { manager, query }),
|
||||
listInstalled: (manager: ManagerId) => invoke<Package[]>("list_installed", { manager }),
|
||||
listUpdates: (manager: ManagerId) => invoke<Package[]>("list_updates", { manager }),
|
||||
getPackageDetails: (pkg: Package) => invoke<PackageDetails>("get_package_details", { package: pkg }),
|
||||
listSources: (manager: ManagerId) => invoke<Source[]>("list_sources", { manager }),
|
||||
installWingetModule: () => invoke<ManagerInfo[]>("install_winget_module"),
|
||||
installAppInstaller: () => invoke<ManagerInfo[]>("install_app_installer"),
|
||||
|
||||
updaterReady: () => invoke<UpdaterStatus>("updater_ready"),
|
||||
getUpdaterStatus: () => invoke<UpdaterStatus>("get_updater_status"),
|
||||
getUpdateOffer: () => invoke<UpdateInfo | null>("get_update_offer"),
|
||||
checkForUpdate: (manual: boolean) => invoke<CheckOutcome>("check_for_update", { manual }),
|
||||
snoozeUpdate: () => invoke<void>("snooze_update"),
|
||||
skipUpdate: () => invoke<void>("skip_update"),
|
||||
clearSkippedVersion: () => invoke<UpdaterSettings>("clear_skipped_version"),
|
||||
setUpdateInterval: (minutes: number) => invoke<UpdaterSettings>("set_update_interval", { minutes }),
|
||||
installUpdate: (onEvent: (event: InstallEvent) => void) => {
|
||||
const channel = new Channel<InstallEvent>();
|
||||
channel.onmessage = onEvent;
|
||||
return invoke<void>("install_update", { onEvent: channel });
|
||||
},
|
||||
takeUpdateNotice: () => invoke<UpdateNotice | null>("take_update_notice"),
|
||||
};
|
||||
|
||||
/** Wandelt einen abgefangenen Fehler in einen anzeigbaren Text um. */
|
||||
export function errorText(error: unknown, fallback: string): string {
|
||||
if (typeof error === "string" && error) return error;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Column } from "./table";
|
||||
import type { Package } from "./types";
|
||||
import { t } from "../i18n/t";
|
||||
|
||||
const text = (value: string | null | undefined) => value ?? "";
|
||||
|
||||
/** Spalten der Suchansicht. */
|
||||
export const searchColumns = (): Column<Package>[] => [
|
||||
{ key: "name", label: t("columns.name"), width: 280, value: (p) => p.name },
|
||||
{ key: "id", label: t("columns.id"), width: 260, value: (p) => p.id, muted: true },
|
||||
{ key: "version", label: t("columns.version"), width: 140, value: (p) => p.version },
|
||||
{ key: "source", label: t("columns.source"), width: 120, value: (p) => text(p.source), muted: true },
|
||||
];
|
||||
|
||||
/** Spalten der Ansicht „Installierte Pakete“. */
|
||||
export const installedColumns = (): Column<Package>[] => [
|
||||
{ key: "name", label: t("columns.name"), width: 280, value: (p) => p.name },
|
||||
{ key: "id", label: t("columns.id"), width: 260, value: (p) => p.id, muted: true },
|
||||
{ key: "version", label: t("columns.installedVersion"), width: 150, value: (p) => p.version },
|
||||
{ key: "available", label: t("columns.availableVersion"), width: 150, value: (p) => text(p.availableVersion) },
|
||||
{ key: "source", label: t("columns.source"), width: 120, value: (p) => text(p.source), muted: true },
|
||||
];
|
||||
|
||||
/** Spalten der Ansicht „Software-Updates“. */
|
||||
export const updateColumns = (): Column<Package>[] => installedColumns();
|
||||
@@ -0,0 +1,14 @@
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: true });
|
||||
|
||||
/** Wandelt Markdown (z. B. Release-Notizen) in bereinigtes HTML um. Links öffnen nie im App-Fenster. */
|
||||
export function renderMarkdown(source: string): string {
|
||||
const html = marked.parse(source, { async: false }) as string;
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ["p", "br", "ul", "ol", "li", "strong", "em", "code", "pre", "h1", "h2", "h3", "h4", "blockquote", "a", "hr"],
|
||||
ALLOWED_ATTR: ["href"],
|
||||
ALLOWED_URI_REGEXP: /^https?:/i,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Spaltendefinition der virtualisierten Tabelle. */
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Startbreite in Pixeln. */
|
||||
width: number;
|
||||
/** Mindestbreite in Pixeln (Standard: 80). */
|
||||
min?: number;
|
||||
/** Zellentext; wird auch zum Sortieren verwendet. */
|
||||
value: (row: T) => string;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator("de", { numeric: true, sensitivity: "base" });
|
||||
|
||||
/** Vergleicht zwei Texte natürlich (Zahlen als Zahlen, deutsche Sortierung). */
|
||||
export function compareText(a: string, b: string): number {
|
||||
return collator.compare(a, b);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export type ManagerId =
|
||||
| "winget"
|
||||
| "scoop"
|
||||
| "choco"
|
||||
| "pip"
|
||||
| "npm"
|
||||
| "dotnet"
|
||||
| "psgallery"
|
||||
| "cargo"
|
||||
| "vcpkg";
|
||||
|
||||
export interface Package {
|
||||
key: string;
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
availableVersion: string | null;
|
||||
manager: ManagerId;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
export interface PackageDetails {
|
||||
description: string | null;
|
||||
publisher: string | null;
|
||||
license: string | null;
|
||||
licenseUrl: string | null;
|
||||
homepage: string | null;
|
||||
installerUrl: string | null;
|
||||
installerType: string | null;
|
||||
hash: string | null;
|
||||
versions: string[];
|
||||
}
|
||||
|
||||
export interface ExtraInfo {
|
||||
name: string;
|
||||
available: boolean;
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
export interface ManagerInfo {
|
||||
id: ManagerId;
|
||||
name: string;
|
||||
available: boolean;
|
||||
path: string | null;
|
||||
version: string | null;
|
||||
extras: ExtraInfo[];
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
name: string;
|
||||
url: string;
|
||||
explicit: boolean;
|
||||
}
|
||||
|
||||
export type ViewId = "discover" | "updates" | "installed" | "bundles" | "settings";
|
||||
|
||||
export interface UpdateInfo {
|
||||
version: string;
|
||||
currentVersion: string;
|
||||
/** Veröffentlichungsdatum als Unix-Sekunden. */
|
||||
date: number | null;
|
||||
notes: string;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
export interface UpdaterSettings {
|
||||
checkIntervalMinutes: number;
|
||||
skippedVersion: string | null;
|
||||
snoozedVersion: string | null;
|
||||
snoozedUntil: number | null;
|
||||
}
|
||||
|
||||
export interface UpdaterStatus {
|
||||
currentVersion: string;
|
||||
settings: UpdaterSettings;
|
||||
lastCheck: number | null;
|
||||
lastError: string | null;
|
||||
offer: UpdateInfo | null;
|
||||
}
|
||||
|
||||
export type CheckOutcome =
|
||||
| { status: "available" | "skipped" | "snoozed"; info: UpdateInfo }
|
||||
| { status: "upToDate" };
|
||||
|
||||
export type InstallEvent =
|
||||
| { stage: "started"; total: number | null }
|
||||
| { stage: "progress"; downloaded: number; total: number | null }
|
||||
| { stage: "installing" };
|
||||
|
||||
export interface UpdateNotice {
|
||||
version: string;
|
||||
notes: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import "@fontsource-variable/inter";
|
||||
import "./app.css";
|
||||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
import "./stores/theme.svelte";
|
||||
|
||||
const target = document.getElementById("app");
|
||||
if (!target) throw new Error("Wurzelelement #app fehlt.");
|
||||
|
||||
export default mount(App, { target });
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Package } from "../lib/types";
|
||||
import { errorText } from "../lib/api";
|
||||
import { t } from "../i18n/t";
|
||||
|
||||
/** Lade-Zustand einer Paketliste (Suche, installiert, Aktualisierungen). */
|
||||
export class ListStore {
|
||||
items = $state<Package[]>([]);
|
||||
loading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
loadedAt = $state<Date | null>(null);
|
||||
loaded = $state(false);
|
||||
query = $state("");
|
||||
|
||||
#loader: (query: string) => Promise<Package[]>;
|
||||
#token = 0;
|
||||
|
||||
constructor(loader: (query: string) => Promise<Package[]>) {
|
||||
this.#loader = loader;
|
||||
}
|
||||
|
||||
/** Lädt die Liste neu. Ältere, noch laufende Anfragen werden verworfen. */
|
||||
async load(query = ""): Promise<void> {
|
||||
const token = ++this.#token;
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.query = query;
|
||||
try {
|
||||
const items = await this.#loader(query);
|
||||
if (token !== this.#token) return;
|
||||
this.items = items;
|
||||
this.loadedAt = new Date();
|
||||
this.loaded = true;
|
||||
} catch (err) {
|
||||
if (token !== this.#token) return;
|
||||
this.items = [];
|
||||
this.error = errorText(err, t("errors.loadFailed"));
|
||||
} finally {
|
||||
if (token === this.#token) this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { api, errorText } from "../lib/api";
|
||||
import type { ManagerInfo } from "../lib/types";
|
||||
import { t } from "../i18n/t";
|
||||
|
||||
/** Erkennungszustand der Paketmanager. */
|
||||
class ManagersStore {
|
||||
infos = $state<ManagerInfo[]>([]);
|
||||
detecting = $state(true);
|
||||
error = $state<string | null>(null);
|
||||
installing = $state<"module" | "appInstaller" | null>(null);
|
||||
installError = $state<string | null>(null);
|
||||
moduleHintDismissed = $state(false);
|
||||
|
||||
winget = $derived(this.infos.find((m) => m.id === "winget") ?? null);
|
||||
wingetMissing = $derived(!this.detecting && !this.error && !(this.winget?.available ?? false));
|
||||
moduleMissing = $derived(
|
||||
!!this.winget?.available && !(this.winget.extras.find((e) => e.name === "Microsoft.WinGet.Client")?.available ?? false),
|
||||
);
|
||||
|
||||
async detect(): Promise<void> {
|
||||
this.detecting = true;
|
||||
this.error = null;
|
||||
try {
|
||||
this.infos = await api.detectManagers();
|
||||
} catch (err) {
|
||||
this.error = errorText(err, t("errors.unexpected"));
|
||||
} finally {
|
||||
this.detecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async installModule(): Promise<void> {
|
||||
await this.#install("module", api.installWingetModule);
|
||||
}
|
||||
|
||||
async installAppInstaller(): Promise<void> {
|
||||
await this.#install("appInstaller", api.installAppInstaller);
|
||||
}
|
||||
|
||||
async #install(kind: "module" | "appInstaller", run: () => Promise<ManagerInfo[]>): Promise<void> {
|
||||
this.installing = kind;
|
||||
this.installError = null;
|
||||
try {
|
||||
this.infos = await run();
|
||||
} catch (err) {
|
||||
this.installError = errorText(err, t("setup.installFailed"));
|
||||
} finally {
|
||||
this.installing = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const managers = new ManagersStore();
|
||||
@@ -0,0 +1,14 @@
|
||||
import { api } from "../lib/api";
|
||||
import { ListStore } from "./list.svelte";
|
||||
import type { ViewId } from "../lib/types";
|
||||
|
||||
/** Gemeinsame Listen, damit Daten beim Wechseln der Ansicht erhalten bleiben. */
|
||||
export const searchStore = new ListStore((query) => api.searchPackages("winget", query));
|
||||
export const installedStore = new ListStore(() => api.listInstalled("winget"));
|
||||
export const updatesStore = new ListStore(() => api.listUpdates("winget"));
|
||||
|
||||
class UiState {
|
||||
view = $state<ViewId>("updates");
|
||||
}
|
||||
|
||||
export const ui = new UiState();
|
||||
@@ -0,0 +1,45 @@
|
||||
export type ThemeChoice = "dark" | "light" | "system";
|
||||
export type AccentChoice = "blue" | "violet" | "teal" | "green" | "orange" | "pink";
|
||||
|
||||
const THEME_KEY = "vu.theme";
|
||||
const ACCENT_KEY = "vu.accent";
|
||||
|
||||
function readStored<T extends string>(key: string, allowed: readonly T[], fallback: T): T {
|
||||
const value = localStorage.getItem(key);
|
||||
return allowed.includes(value as T) ? (value as T) : fallback;
|
||||
}
|
||||
|
||||
export const THEMES: readonly ThemeChoice[] = ["dark", "light", "system"];
|
||||
export const ACCENTS: readonly AccentChoice[] = ["blue", "violet", "teal", "green", "orange", "pink"];
|
||||
|
||||
/** Erscheinungsbild: Design (dunkel/hell/System) und Akzentfarbe. */
|
||||
class ThemeStore {
|
||||
theme = $state<ThemeChoice>(readStored(THEME_KEY, THEMES, "dark"));
|
||||
accent = $state<AccentChoice>(readStored(ACCENT_KEY, ACCENTS, "blue"));
|
||||
#media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
constructor() {
|
||||
this.#media.addEventListener("change", () => this.apply());
|
||||
this.apply();
|
||||
}
|
||||
|
||||
setTheme(theme: ThemeChoice): void {
|
||||
this.theme = theme;
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
this.apply();
|
||||
}
|
||||
|
||||
setAccent(accent: AccentChoice): void {
|
||||
this.accent = accent;
|
||||
localStorage.setItem(ACCENT_KEY, accent);
|
||||
this.apply();
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
const effective = this.theme === "system" ? (this.#media.matches ? "dark" : "light") : this.theme;
|
||||
document.documentElement.dataset.theme = effective;
|
||||
document.documentElement.dataset.accent = this.accent;
|
||||
}
|
||||
}
|
||||
|
||||
export const themeStore = new ThemeStore();
|
||||
@@ -0,0 +1,137 @@
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { api, errorText } from "../lib/api";
|
||||
import type { UpdateInfo, UpdateNotice, UpdaterStatus } from "../lib/types";
|
||||
import { t } from "../i18n/t";
|
||||
|
||||
export type UpdatePhase = "idle" | "downloading" | "installing" | "restarting" | "error";
|
||||
export type ManualResult = { kind: "upToDate" } | { kind: "error"; text: string } | null;
|
||||
|
||||
/** Zustand des Selbst-Updaters und des Update-Popups. */
|
||||
class UpdaterStore {
|
||||
status = $state<UpdaterStatus | null>(null);
|
||||
offer = $state<UpdateInfo | null>(null);
|
||||
dialogOpen = $state(false);
|
||||
phase = $state<UpdatePhase>("idle");
|
||||
downloaded = $state(0);
|
||||
total = $state<number | null>(null);
|
||||
error = $state<string | null>(null);
|
||||
checking = $state(false);
|
||||
manualResult = $state<ManualResult>(null);
|
||||
notice = $state<UpdateNotice | null>(null);
|
||||
|
||||
percent = $derived(this.total && this.total > 0 ? Math.min(100, Math.round((this.downloaded / this.total) * 100)) : null);
|
||||
busy = $derived(this.phase === "downloading" || this.phase === "installing" || this.phase === "restarting");
|
||||
|
||||
/** Startet Prüfung im Backend, registriert Ereignisse und holt den Hinweis zum letzten Update. */
|
||||
async init(): Promise<void> {
|
||||
await listen<UpdateInfo>("update-available", (event) => this.show(event.payload));
|
||||
await listen("update-restarting", () => {
|
||||
this.phase = "restarting";
|
||||
});
|
||||
const pull = () => void this.pullOffer();
|
||||
window.addEventListener("focus", pull);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden) pull();
|
||||
});
|
||||
try {
|
||||
this.notice = await api.takeUpdateNotice();
|
||||
this.status = await api.updaterReady();
|
||||
if (this.status.offer) this.show(this.status.offer);
|
||||
} catch (err) {
|
||||
console.error("Updater konnte nicht gestartet werden", err);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshStatus(): Promise<void> {
|
||||
this.status = await api.getUpdaterStatus();
|
||||
}
|
||||
|
||||
/** Fragt das Backend nach einem anzubietenden Update (z. B. nach Rückkehr aus dem Tray). */
|
||||
async pullOffer(): Promise<void> {
|
||||
if (this.dialogOpen) return;
|
||||
try {
|
||||
const offer = await api.getUpdateOffer();
|
||||
if (offer) this.show(offer);
|
||||
} catch {
|
||||
/* Ein fehlgeschlagener Abruf ist unkritisch; die nächste Prüfung versucht es erneut. */
|
||||
}
|
||||
}
|
||||
|
||||
/** Öffnet das Popup. Es wird nie mehr als ein Popup gleichzeitig angezeigt. */
|
||||
show(info: UpdateInfo): void {
|
||||
if (this.dialogOpen || this.busy) return;
|
||||
this.offer = info;
|
||||
this.phase = "idle";
|
||||
this.error = null;
|
||||
this.downloaded = 0;
|
||||
this.total = null;
|
||||
this.dialogOpen = true;
|
||||
}
|
||||
|
||||
async later(): Promise<void> {
|
||||
if (this.busy) return;
|
||||
this.dialogOpen = false;
|
||||
await api.snoozeUpdate().catch(() => undefined);
|
||||
await this.refreshStatus().catch(() => undefined);
|
||||
}
|
||||
|
||||
async skip(): Promise<void> {
|
||||
if (this.busy) return;
|
||||
this.dialogOpen = false;
|
||||
await api.skipUpdate().catch(() => undefined);
|
||||
await this.refreshStatus().catch(() => undefined);
|
||||
}
|
||||
|
||||
async install(): Promise<void> {
|
||||
if (this.busy) return;
|
||||
this.error = null;
|
||||
this.phase = "downloading";
|
||||
this.downloaded = 0;
|
||||
this.total = null;
|
||||
try {
|
||||
await api.installUpdate((event) => {
|
||||
if (event.stage === "started") this.total = event.total;
|
||||
else if (event.stage === "progress") {
|
||||
this.downloaded = event.downloaded;
|
||||
this.total = event.total ?? this.total;
|
||||
} else if (this.phase !== "restarting") this.phase = "installing";
|
||||
});
|
||||
} catch (err) {
|
||||
this.phase = "error";
|
||||
this.error = errorText(err, t("updater.installFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Manuelle Prüfung aus den Einstellungen. */
|
||||
async checkManually(): Promise<void> {
|
||||
if (this.checking) return;
|
||||
this.checking = true;
|
||||
this.manualResult = null;
|
||||
try {
|
||||
const outcome = await api.checkForUpdate(true);
|
||||
if (outcome.status === "upToDate") this.manualResult = { kind: "upToDate" };
|
||||
else this.show(outcome.info);
|
||||
} catch (err) {
|
||||
this.manualResult = { kind: "error", text: errorText(err, t("updater.checkFailed")) };
|
||||
} finally {
|
||||
this.checking = false;
|
||||
await this.refreshStatus().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async setInterval(minutes: number): Promise<void> {
|
||||
await api.setUpdateInterval(minutes);
|
||||
await this.refreshStatus();
|
||||
}
|
||||
|
||||
async clearSkipped(): Promise<void> {
|
||||
await api.clearSkippedVersion();
|
||||
await this.refreshStatus();
|
||||
}
|
||||
|
||||
dismissNotice(): void {
|
||||
this.notice = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const updater = new UpdaterStore();
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { Boxes } from "@lucide/svelte";
|
||||
import { t } from "../i18n/t";
|
||||
</script>
|
||||
|
||||
<section class="flex h-full flex-col gap-4 p-6">
|
||||
<h1 class="text-[22px] font-semibold leading-7">{t("bundles.title")}</h1>
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-2 rounded-[12px] border border-border bg-surface px-6 text-center">
|
||||
<Boxes size={40} class="text-muted" />
|
||||
<p class="text-[16px] font-semibold">{t("bundles.emptyTitle")}</p>
|
||||
<p class="max-w-[480px] text-muted">{t("bundles.empty")}</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { PackageSearch } from "@lucide/svelte";
|
||||
import PackageView from "../components/PackageView.svelte";
|
||||
import { searchStore } from "../stores/packages.svelte";
|
||||
import { searchColumns } from "../lib/columns";
|
||||
import { t } from "../i18n/t";
|
||||
</script>
|
||||
|
||||
<PackageView
|
||||
title={t("discover.title")}
|
||||
store={searchStore}
|
||||
mode="search"
|
||||
placeholder={t("discover.placeholder")}
|
||||
columns={searchColumns()}
|
||||
storageKey="discover"
|
||||
countKey="discover.results"
|
||||
emptyTitle={t("discover.noResultsTitle")}
|
||||
emptyText={(query) => t("discover.noResults", { query })}
|
||||
>
|
||||
{#snippet idle()}
|
||||
<div class="flex flex-col items-center gap-2 px-6 py-16 text-center">
|
||||
<PackageSearch size={40} class="text-muted" />
|
||||
<p class="text-[16px] font-semibold">{t("discover.hintTitle")}</p>
|
||||
<p class="max-w-[420px] text-muted">{t("discover.hint")}</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PackageView>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import PackageView from "../components/PackageView.svelte";
|
||||
import { installedStore } from "../stores/packages.svelte";
|
||||
import { installedColumns } from "../lib/columns";
|
||||
import { t } from "../i18n/t";
|
||||
</script>
|
||||
|
||||
<PackageView
|
||||
title={t("installed.title")}
|
||||
store={installedStore}
|
||||
mode="filter"
|
||||
placeholder={t("installed.filterPlaceholder")}
|
||||
columns={installedColumns()}
|
||||
storageKey="installed"
|
||||
countKey="installed.count"
|
||||
emptyTitle={t("installed.emptyTitle")}
|
||||
emptyText={(query) => (query ? t("installed.noMatches", { query }) : t("installed.empty"))}
|
||||
/>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { CircleCheck, CircleX, RefreshCw } from "@lucide/svelte";
|
||||
import { api } from "../lib/api";
|
||||
import type { Source } from "../lib/types";
|
||||
import { managers } from "../stores/managers.svelte";
|
||||
import { updater } from "../stores/updater.svelte";
|
||||
import { themeStore, THEMES, ACCENTS, type ThemeChoice, type AccentChoice } from "../stores/theme.svelte";
|
||||
import { t, formatDate, formatTime } from "../i18n/t";
|
||||
|
||||
const themeLabels: Record<ThemeChoice, string> = {
|
||||
dark: "settings.themeDark",
|
||||
light: "settings.themeLight",
|
||||
system: "settings.themeSystem",
|
||||
};
|
||||
const accentLabels: Record<AccentChoice, string> = {
|
||||
blue: "settings.accentBlue",
|
||||
violet: "settings.accentViolet",
|
||||
teal: "settings.accentTeal",
|
||||
green: "settings.accentGreen",
|
||||
orange: "settings.accentOrange",
|
||||
pink: "settings.accentPink",
|
||||
};
|
||||
const accentSwatch: Record<AccentChoice, string> = {
|
||||
blue: "#4c8dff",
|
||||
violet: "#8b7bff",
|
||||
teal: "#2cc4b8",
|
||||
green: "#4cc97a",
|
||||
orange: "#ff9a4d",
|
||||
pink: "#ff6fae",
|
||||
};
|
||||
|
||||
let sources = $state<Source[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (managers.winget?.available) {
|
||||
api.listSources("winget").then((s) => (sources = s)).catch(() => (sources = []));
|
||||
}
|
||||
});
|
||||
|
||||
const module = $derived(managers.winget?.extras.find((e) => e.name === "Microsoft.WinGet.Client"));
|
||||
|
||||
const INTERVAL_PRESETS = [15, 30, 60, 120, 240, 720, 1440];
|
||||
const intervalOptions = $derived.by(() => {
|
||||
const current = updater.status?.settings.checkIntervalMinutes;
|
||||
const values = current && !INTERVAL_PRESETS.includes(current) ? [...INTERVAL_PRESETS, current].sort((a, b) => a - b) : INTERVAL_PRESETS;
|
||||
return values.map((minutes) => ({ minutes, label: intervalLabel(minutes) }));
|
||||
});
|
||||
|
||||
function intervalLabel(minutes: number): string {
|
||||
if (minutes < 60 || minutes % 60 !== 0) return t("settings.updates.intervalMinutes", { count: minutes });
|
||||
const hours = minutes / 60;
|
||||
return hours === 1 ? t("settings.updates.intervalHour") : t("settings.updates.intervalHours", { count: hours });
|
||||
}
|
||||
|
||||
function lastCheckText(unix: number | null): string {
|
||||
if (!unix) return t("settings.updates.never");
|
||||
const date = new Date(unix * 1000);
|
||||
return t("settings.updates.lastCheckValue", { date: formatDate(date), time: formatTime(date) });
|
||||
}
|
||||
|
||||
onMount(() => void updater.refreshStatus().catch(() => undefined));
|
||||
</script>
|
||||
|
||||
<section class="h-full space-y-6 overflow-y-auto p-6">
|
||||
<h1 class="text-[22px] font-semibold leading-7">{t("settings.title")}</h1>
|
||||
|
||||
<div class="max-w-[820px] space-y-6">
|
||||
<div class="rounded-[12px] border border-border bg-surface p-5">
|
||||
<h2 class="mb-4 text-[16px] font-semibold">{t("settings.updates.title")}</h2>
|
||||
{#if updater.status}
|
||||
<dl class="grid grid-cols-[170px_1fr] items-center gap-x-4 gap-y-3">
|
||||
<dt class="text-muted">{t("settings.updates.currentVersion")}</dt>
|
||||
<dd>{updater.status.currentVersion}</dd>
|
||||
<dt class="text-muted">{t("settings.updates.lastCheck")}</dt>
|
||||
<dd>{lastCheckText(updater.status.lastCheck)}</dd>
|
||||
<dt class="text-muted"><label for="update-interval">{t("settings.updates.interval")}</label></dt>
|
||||
<dd>
|
||||
<select
|
||||
id="update-interval"
|
||||
class="h-10 rounded-[8px] border border-border bg-surface px-3"
|
||||
value={updater.status.settings.checkIntervalMinutes}
|
||||
onchange={(e) => updater.setInterval(Number(e.currentTarget.value))}
|
||||
>
|
||||
{#each intervalOptions as option (option.minutes)}<option value={option.minutes}>{option.label}</option>{/each}
|
||||
</select>
|
||||
</dd>
|
||||
{#if updater.status.settings.skippedVersion}
|
||||
<dt class="text-muted">{t("settings.updates.skippedVersion")}</dt>
|
||||
<dd class="flex items-center gap-3">
|
||||
{updater.status.settings.skippedVersion}
|
||||
<button type="button" class="rounded-[8px] border border-border px-3 py-1 hover:bg-hover" onclick={() => updater.clearSkipped()}>
|
||||
{t("settings.updates.resetSkipped")}
|
||||
</button>
|
||||
</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
{/if}
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 items-center gap-2 rounded-[8px] bg-accent px-4 font-semibold text-accent-fg transition-colors hover:bg-accent-strong disabled:opacity-60"
|
||||
onclick={() => updater.checkManually()}
|
||||
disabled={updater.checking}
|
||||
>
|
||||
<RefreshCw size={16} class={updater.checking ? "animate-spin" : ""} />
|
||||
{updater.checking ? t("settings.updates.checking") : t("settings.updates.check")}
|
||||
</button>
|
||||
{#if updater.manualResult?.kind === "upToDate"}
|
||||
<span class="flex items-center gap-2 text-success" role="status"><CircleCheck size={18} />{t("settings.updates.upToDate")}</span>
|
||||
{:else if updater.manualResult?.kind === "error"}
|
||||
<span class="flex items-start gap-2 text-danger" role="alert"><CircleX size={18} class="mt-0.5 shrink-0" />{updater.manualResult.text}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if updater.status?.lastError && updater.manualResult?.kind !== "error"}
|
||||
<p class="mt-3 flex items-start gap-2 text-[14px] text-warning" role="status">
|
||||
<CircleX size={16} class="mt-0.5 shrink-0" /><span><strong>{t("settings.updates.lastError")}:</strong> {updater.status.lastError}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="rounded-[12px] border border-border bg-surface p-5">
|
||||
<h2 class="mb-4 text-[16px] font-semibold">{t("settings.appearance")}</h2>
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<p class="mb-2 text-muted">{t("settings.theme")}</p>
|
||||
<div class="inline-flex rounded-[8px] border border-border p-1" role="radiogroup" aria-label={t("settings.theme")}>
|
||||
{#each THEMES as choice (choice)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={themeStore.theme === choice}
|
||||
class="rounded-[6px] px-4 py-1.5 transition-colors {themeStore.theme === choice ? 'bg-accent font-semibold text-accent-fg' : 'hover:bg-hover'}"
|
||||
onclick={() => themeStore.setTheme(choice)}
|
||||
>
|
||||
{t(themeLabels[choice])}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-muted">{t("settings.accent")}</p>
|
||||
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label={t("settings.accent")}>
|
||||
{#each ACCENTS as choice (choice)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={themeStore.accent === choice}
|
||||
class="flex items-center gap-2 rounded-[8px] border px-3 py-1.5 transition-colors {themeStore.accent === choice ? 'border-accent bg-accent/15' : 'border-border hover:bg-hover'}"
|
||||
onclick={() => themeStore.setAccent(choice)}
|
||||
>
|
||||
<span class="size-4 rounded-full" style="background: {accentSwatch[choice]}"></span>
|
||||
{t(accentLabels[choice])}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[12px] border border-border bg-surface p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-[16px] font-semibold">{t("settings.managers")}</h2>
|
||||
<button type="button" class="inline-flex items-center gap-2 rounded-[8px] border border-border px-3 py-1.5 hover:bg-hover disabled:opacity-50" onclick={() => managers.detect()} disabled={managers.detecting}>
|
||||
<RefreshCw size={16} class={managers.detecting ? "animate-spin" : ""} />{t("settings.redetect")}
|
||||
</button>
|
||||
</div>
|
||||
{#if managers.winget}
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
{#if managers.winget.available}<CircleCheck size={18} class="text-success" />{:else}<CircleX size={18} class="text-danger" />{/if}
|
||||
{managers.winget.name}
|
||||
<span class="font-normal text-muted">– {managers.winget.available ? t("settings.statusFound") : t("settings.statusMissing")}</span>
|
||||
</div>
|
||||
<dl class="mt-3 grid grid-cols-[140px_1fr] gap-x-4 gap-y-2">
|
||||
<dt class="text-muted">{t("settings.version")}</dt>
|
||||
<dd>{managers.winget.version ?? t("common.unknown")}</dd>
|
||||
<dt class="text-muted">{t("settings.path")}</dt>
|
||||
<dd class="break-all">{managers.winget.path ?? t("common.unknown")}</dd>
|
||||
<dt class="text-muted">{t("settings.module")}</dt>
|
||||
<dd>{module?.available ? t("settings.moduleFound", { version: module.version ?? "" }) : t("settings.moduleMissing")}</dd>
|
||||
</dl>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rounded-[12px] border border-border bg-surface p-5">
|
||||
<h2 class="mb-4 text-[16px] font-semibold">{t("settings.sources")}</h2>
|
||||
{#if sources.length === 0}
|
||||
<p class="text-muted">{t("settings.noSources")}</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border">
|
||||
{#each sources as source (source.name)}
|
||||
<li class="flex flex-wrap items-baseline gap-x-3 py-2">
|
||||
<span class="font-semibold">{source.name}</span>
|
||||
<span class="break-all text-[13px] text-muted">{source.url}</span>
|
||||
{#if source.explicit}<span class="rounded-[6px] bg-surface-2 px-2 text-[12px]">{t("settings.sourceExplicit")}</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import PackageView from "../components/PackageView.svelte";
|
||||
import { updatesStore } from "../stores/packages.svelte";
|
||||
import { updateColumns } from "../lib/columns";
|
||||
import { t } from "../i18n/t";
|
||||
</script>
|
||||
|
||||
<PackageView
|
||||
title={t("updates.title")}
|
||||
store={updatesStore}
|
||||
mode="filter"
|
||||
placeholder={t("updates.filterPlaceholder")}
|
||||
columns={updateColumns()}
|
||||
storageKey="updates"
|
||||
countKey="updates.count"
|
||||
emptyTitle={t("updates.emptyTitle")}
|
||||
emptyText={() => t("updates.empty")}
|
||||
/>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": false,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["svelte", "vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), svelte()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
watch: { ignored: ["**/src-tauri/**"] },
|
||||
},
|
||||
build: {
|
||||
target: "chrome105",
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||