Erste Version: Tauri-Grundgerüst, WinGet-Anbindung, Selbst-Updater und Release-Skript

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
DavitoandCopilot committed 2026-10-30 15:58:58 +01:00
commit e0773ba299
84 files changed
+12688

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
* text=auto eol=lf
*.ps1 text working-tree-encoding=UTF-8
*.png binary
*.ico binary
*.icns binary
+64
View File
@@ -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).
+22
View File
@@ -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
+64
View File
@@ -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).
+100
View File
@@ -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.
+47
View File
@@ -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')"
+12
View File
@@ -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>
+2159
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -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"
}
}
+35
View File
@@ -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();
+403
View File
@@ -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
}
+5409
View File
File diff suppressed because it is too large. Load diff
+39
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -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"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+126
View File
@@ -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)
}
+42
View File
@@ -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>;
+146
View File
@@ -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);
}
}
+37
View File
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
vitos_updater_lib::run();
}
+36
View File
@@ -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>>;
}
+190
View File
@@ -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())
}
}
+349
View File
@@ -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"]);
}
}
+128
View File
@@ -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))
}
+139
View File
@@ -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);
}
}
+68
View File
@@ -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),
})
}
+73
View File
@@ -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);
}
}
+430
View File
@@ -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(&notice) {
if let Err(err) = write_atomic(&notice_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(&notice_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)
}
+190
View File
@@ -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());
}
}
+48
View File
@@ -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" }
}
}
}
+133
View File
@@ -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
+12
View File
@@ -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
+35
View File
@@ -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
+7
View File
@@ -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.
+48
View File
@@ -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 />
+128
View File
@@ -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; }
}
+145
View File
@@ -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>
+23
View File
@@ -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>
+53
View File
@@ -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>
+162
View File
@@ -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>
+50
View File
@@ -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}
+59
View File
@@ -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>
+100
View File
@@ -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}
+29
View File
@@ -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}
+216
View File
@@ -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>
+190
View File
@@ -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"
}
}
+68
View File
@@ -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);
}
+49
View File
@@ -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;
}
+25
View File
@@ -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();
+14
View File
@@ -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,
});
}
+19
View File
@@ -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);
}
+93
View File
@@ -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;
}
+10
View File
@@ -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 });
+41
View File
@@ -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;
}
}
}
+53
View File
@@ -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();
+14
View File
@@ -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();
+45
View File
@@ -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();
+137
View File
@@ -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();
+13
View File
@@ -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>
+27
View File
@@ -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>
+18
View File
@@ -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"))}
/>
+201
View File
@@ -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>
+18
View File
@@ -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")}
/>
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};
+19
View File
@@ -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"]
}
+18
View File
@@ -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,
},
});