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

+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());
}
}