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

+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.