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:
commit
e0773ba299
84 files changed
+12688
No files matched your search
@@ -0,0 +1,126 @@
|
||||
//! Tauri-Befehle, die die Oberfläche aufruft.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::managers::winget::WingetManager;
|
||||
use crate::managers::PackageManager;
|
||||
use crate::models::{ManagerId, ManagerInfo, Package, PackageDetails, Source};
|
||||
use crate::process;
|
||||
|
||||
/// Registrierte Paketmanager.
|
||||
pub struct Registry {
|
||||
managers: HashMap<ManagerId, Arc<dyn PackageManager>>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
/// Erstellt die Registry mit allen verfügbaren Implementierungen.
|
||||
pub fn new() -> Self {
|
||||
let mut managers: HashMap<ManagerId, Arc<dyn PackageManager>> = HashMap::new();
|
||||
managers.insert(ManagerId::Winget, Arc::new(WingetManager::new()));
|
||||
Self { managers }
|
||||
}
|
||||
|
||||
fn get(&self, id: ManagerId) -> AppResult<Arc<dyn PackageManager>> {
|
||||
self.managers
|
||||
.get(&id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AppError::Command("Dieser Paketmanager wird noch nicht unterstützt.".to_owned()))
|
||||
}
|
||||
|
||||
async fn detect_all(&self) -> Vec<ManagerInfo> {
|
||||
let mut infos = Vec::new();
|
||||
for manager in self.managers.values() {
|
||||
infos.push(manager.detect().await);
|
||||
}
|
||||
infos
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Registry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn logged<T>(what: &str, result: AppResult<T>) -> AppResult<T> {
|
||||
if let Err(err) = &result {
|
||||
tracing::error!(%err, "{what} fehlgeschlagen");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Erkennt alle Paketmanager (Pfad, Version, Zusatzkomponenten).
|
||||
#[tauri::command]
|
||||
pub async fn detect_managers(registry: State<'_, Registry>) -> AppResult<Vec<ManagerInfo>> {
|
||||
Ok(registry.detect_all().await)
|
||||
}
|
||||
|
||||
/// Sucht Pakete.
|
||||
#[tauri::command]
|
||||
pub async fn search_packages(registry: State<'_, Registry>, manager: ManagerId, query: String) -> AppResult<Vec<Package>> {
|
||||
let query = query.trim().to_owned();
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
logged("Paketsuche", registry.get(manager)?.search(&query).await)
|
||||
}
|
||||
|
||||
/// Listet installierte Pakete.
|
||||
#[tauri::command]
|
||||
pub async fn list_installed(registry: State<'_, Registry>, manager: ManagerId) -> AppResult<Vec<Package>> {
|
||||
logged("Liste der installierten Pakete", registry.get(manager)?.list_installed().await)
|
||||
}
|
||||
|
||||
/// Listet verfügbare Aktualisierungen.
|
||||
#[tauri::command]
|
||||
pub async fn list_updates(registry: State<'_, Registry>, manager: ManagerId) -> AppResult<Vec<Package>> {
|
||||
logged("Suche nach Aktualisierungen", registry.get(manager)?.list_updates().await)
|
||||
}
|
||||
|
||||
/// Lädt Paketdetails.
|
||||
#[tauri::command]
|
||||
pub async fn get_package_details(registry: State<'_, Registry>, package: Package) -> AppResult<PackageDetails> {
|
||||
logged("Paketdetails", registry.get(package.manager)?.get_details(&package).await)
|
||||
}
|
||||
|
||||
/// Listet die Quellen eines Managers.
|
||||
#[tauri::command]
|
||||
pub async fn list_sources(registry: State<'_, Registry>, manager: ManagerId) -> AppResult<Vec<Source>> {
|
||||
logged("Quellenliste", registry.get(manager)?.list_sources().await)
|
||||
}
|
||||
|
||||
async fn run_powershell(script: &str, timeout: Duration) -> AppResult<()> {
|
||||
let shell = which::which("powershell")
|
||||
.map_err(|_| AppError::ManagerMissing("Windows PowerShell".to_owned()))?;
|
||||
let out = process::run(&shell, &["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], timeout).await?;
|
||||
if out.code == Some(0) {
|
||||
Ok(())
|
||||
} else {
|
||||
let detail = out.stderr.lines().find(|l| !l.trim().is_empty()).unwrap_or("unbekannter Fehler");
|
||||
Err(AppError::Command(format!("Die Installation ist fehlgeschlagen: {detail}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Installiert das PowerShell-Modul `Microsoft.WinGet.Client` für den aktuellen Benutzer.
|
||||
#[tauri::command]
|
||||
pub async fn install_winget_module(registry: State<'_, Registry>) -> AppResult<Vec<ManagerInfo>> {
|
||||
let script = "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Stop'; \
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Scope CurrentUser -Force | Out-Null; \
|
||||
Install-Module -Name Microsoft.WinGet.Client -Scope CurrentUser -Repository PSGallery -Force";
|
||||
logged("Modulinstallation", run_powershell(script, Duration::from_secs(300)).await)?;
|
||||
Ok(registry.detect_all().await)
|
||||
}
|
||||
|
||||
/// Registriert bzw. installiert den „App-Installer“ (enthält WinGet).
|
||||
#[tauri::command]
|
||||
pub async fn install_app_installer(registry: State<'_, Registry>) -> AppResult<Vec<ManagerInfo>> {
|
||||
let script = "$ProgressPreference='SilentlyContinue'; $ErrorActionPreference='Stop'; \
|
||||
Add-AppxPackage -RegisterByFamilyName -MainPackage Microsoft.DesktopAppInstaller_8wekyb3d8bbwe";
|
||||
logged("Installation des App-Installers", run_powershell(script, Duration::from_secs(300)).await)?;
|
||||
Ok(registry.detect_all().await)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Zentrale Fehlertypen. Die Meldungen sind für die Anzeige in der Oberfläche gedacht und daher deutsch.
|
||||
|
||||
use serde::{Serialize, Serializer};
|
||||
|
||||
/// Fehler, die an die Oberfläche weitergereicht werden.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
/// Der Paketmanager ist nicht installiert oder nicht gefunden worden.
|
||||
#[error("{0} wurde nicht gefunden.")]
|
||||
ManagerMissing(String),
|
||||
/// Ein externer Prozess konnte nicht gestartet werden.
|
||||
#[error("Der Prozess „{program}“ konnte nicht gestartet werden: {source}")]
|
||||
Spawn {
|
||||
program: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// Ein externer Prozess hat das Zeitlimit überschritten.
|
||||
#[error("Zeitüberschreitung nach {0} Sekunden.")]
|
||||
Timeout(u64),
|
||||
/// Ein Paketmanager hat mit einem Fehlercode geendet.
|
||||
#[error("{0}")]
|
||||
Command(String),
|
||||
/// Die Ausgabe eines Paketmanagers konnte nicht gelesen werden.
|
||||
#[error("Die Ausgabe konnte nicht gelesen werden: {0}")]
|
||||
Parse(String),
|
||||
/// Ein-/Ausgabefehler.
|
||||
#[error("Ein-/Ausgabefehler: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// JSON-Fehler.
|
||||
#[error("Ungültige JSON-Daten: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl Serialize for AppError {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Ergebnistyp der Anwendung.
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
@@ -0,0 +1,146 @@
|
||||
//! VitosUpdater – einheitliche Paketverwaltung für Windows.
|
||||
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
pub mod logging;
|
||||
pub mod managers;
|
||||
pub mod models;
|
||||
pub mod process;
|
||||
pub mod settings;
|
||||
pub mod updater;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{Manager, PhysicalPosition, PhysicalSize, WebviewWindow, WindowEvent};
|
||||
|
||||
use settings::WindowState;
|
||||
|
||||
/// Letzte normale (nicht maximierte) Geometrie und Zähler für das Entprellen des Speicherns.
|
||||
struct WindowTracker {
|
||||
dir: PathBuf,
|
||||
normal: Mutex<Option<WindowState>>,
|
||||
generation: AtomicU64,
|
||||
}
|
||||
|
||||
fn restore_window(window: &WebviewWindow, saved: Option<WindowState>) {
|
||||
if let Some(state) = saved {
|
||||
let monitors: Vec<(i32, i32, u32, u32)> = window
|
||||
.available_monitors()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|m| (m.position().x, m.position().y, m.size().width, m.size().height))
|
||||
.collect();
|
||||
if settings::is_visible_on(&state, &monitors) {
|
||||
let _ = window.set_size(PhysicalSize::new(state.width, state.height));
|
||||
let _ = window.set_position(PhysicalPosition::new(state.x, state.y));
|
||||
}
|
||||
if state.maximized {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
let _ = window.show();
|
||||
}
|
||||
|
||||
fn track_window(window: &WebviewWindow, tracker: &Arc<WindowTracker>) {
|
||||
let maximized = window.is_maximized().unwrap_or(false);
|
||||
let minimized = window.is_minimized().unwrap_or(false);
|
||||
if minimized {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut normal = tracker.normal.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !maximized {
|
||||
if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) {
|
||||
*normal = Some(WindowState { x: pos.x, y: pos.y, width: size.width, height: size.height, maximized: false });
|
||||
}
|
||||
} else if let Some(state) = normal.as_mut() {
|
||||
state.maximized = true;
|
||||
}
|
||||
}
|
||||
let generation = tracker.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let tracker = Arc::clone(tracker);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
if tracker.generation.load(Ordering::SeqCst) != generation {
|
||||
return;
|
||||
}
|
||||
let snapshot = *tracker.normal.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(mut state) = snapshot {
|
||||
state.maximized = maximized;
|
||||
if let Err(err) = settings::save_window_state(&tracker.dir, &state) {
|
||||
tracing::warn!(%err, "Fensterzustand konnte nicht gespeichert werden");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Startet die Anwendung.
|
||||
pub fn run() {
|
||||
let dir = logging::data_dir();
|
||||
let _guard = logging::init(&dir);
|
||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), "VitosUpdater startet");
|
||||
|
||||
let tracker = Arc::new(WindowTracker {
|
||||
dir: dir.clone(),
|
||||
normal: Mutex::new(settings::load_window_state(&dir)),
|
||||
generation: AtomicU64::new(0),
|
||||
});
|
||||
let saved = settings::load_window_state(&dir);
|
||||
let event_tracker = Arc::clone(&tracker);
|
||||
|
||||
let result = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(commands::Registry::new())
|
||||
.manage(updater::UpdaterState::new(dir.clone()))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::detect_managers,
|
||||
commands::search_packages,
|
||||
commands::list_installed,
|
||||
commands::list_updates,
|
||||
commands::get_package_details,
|
||||
commands::list_sources,
|
||||
commands::install_winget_module,
|
||||
commands::install_app_installer,
|
||||
updater::updater_ready,
|
||||
updater::get_updater_status,
|
||||
updater::get_update_offer,
|
||||
updater::check_for_update,
|
||||
updater::snooze_update,
|
||||
updater::skip_update,
|
||||
updater::clear_skipped_version,
|
||||
updater::set_update_interval,
|
||||
updater::install_update,
|
||||
updater::take_update_notice,
|
||||
])
|
||||
.setup(move |app| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
restore_window(&window, saved);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(move |window, event| {
|
||||
if matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) {
|
||||
if let Some(webview) = window.app_handle().get_webview_window(window.label()) {
|
||||
track_window(&webview, &event_tracker);
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!());
|
||||
|
||||
if let Err(err) = result {
|
||||
tracing::error!(%err, "Anwendung wurde mit einem Fehler beendet");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Protokollierung in rotierende Logdateien unter `%APPDATA%\VitosUpdater\logs`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_appender::rolling;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// Basisverzeichnis für Konfiguration und Zustand (`%APPDATA%\VitosUpdater`).
|
||||
pub fn data_dir() -> PathBuf {
|
||||
let base = std::env::var_os("APPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
base.join("VitosUpdater")
|
||||
}
|
||||
|
||||
/// Initialisiert die Protokollierung. Der Guard muss bis zum Programmende gehalten werden.
|
||||
pub fn init(dir: &Path) -> Option<WorkerGuard> {
|
||||
let logs = dir.join("logs");
|
||||
std::fs::create_dir_all(&logs).ok()?;
|
||||
let appender = rolling::Builder::new()
|
||||
.rotation(rolling::Rotation::DAILY)
|
||||
.filename_prefix("vitosupdater")
|
||||
.filename_suffix("log")
|
||||
.max_log_files(14)
|
||||
.build(&logs)
|
||||
.ok()?;
|
||||
let (writer, guard) = tracing_appender::non_blocking(appender);
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_writer(writer)
|
||||
.with_ansi(false)
|
||||
.try_init()
|
||||
.ok()?;
|
||||
Some(guard)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
vitos_updater_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Gemeinsame Schnittstelle aller Paketmanager.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::AppResult;
|
||||
use crate::models::{ManagerId, ManagerInfo, Package, PackageDetails, Source};
|
||||
|
||||
pub mod winget;
|
||||
|
||||
/// Einheitliche Schnittstelle für Paketmanager.
|
||||
///
|
||||
/// Die Operationen `install`, `update` und `uninstall` kommen mit der Warteschlange in Phase 2 hinzu,
|
||||
/// weil sie den Ausgabestrom und den Prozess-Lebenszyklus der Warteschlange benötigen.
|
||||
#[async_trait]
|
||||
pub trait PackageManager: Send + Sync {
|
||||
/// Kennung des Managers.
|
||||
fn id(&self) -> ManagerId;
|
||||
|
||||
/// Erkennt Installation, Pfad und Version.
|
||||
async fn detect(&self) -> ManagerInfo;
|
||||
|
||||
/// Sucht Pakete.
|
||||
async fn search(&self, query: &str) -> AppResult<Vec<Package>>;
|
||||
|
||||
/// Listet installierte Pakete.
|
||||
async fn list_installed(&self) -> AppResult<Vec<Package>>;
|
||||
|
||||
/// Listet Pakete mit verfügbarer Aktualisierung.
|
||||
async fn list_updates(&self) -> AppResult<Vec<Package>>;
|
||||
|
||||
/// Lädt Detailinformationen zu einem Paket.
|
||||
async fn get_details(&self, package: &Package) -> AppResult<PackageDetails>;
|
||||
|
||||
/// Listet die konfigurierten Quellen.
|
||||
async fn list_sources(&self) -> AppResult<Vec<Source>>;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! WinGet-Manager: PowerShell-Modul (JSON) wenn vorhanden, sonst Tabellenparser der Kommandozeile.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::PackageManager;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{ExtraInfo, ManagerId, ManagerInfo, Package, PackageDetails, Source};
|
||||
use crate::process::{self, CmdOutput};
|
||||
|
||||
pub mod parser;
|
||||
pub mod ps;
|
||||
|
||||
/// Exit-Code von WinGet bei „keine Treffer“ (0x8A150014), per Test mit WinGet 1.29 ermittelt.
|
||||
const EXIT_NO_MATCH: i32 = 0x8A15_0014_u32 as i32;
|
||||
/// Gemeinsame Argumente, damit WinGet nie auf Eingaben wartet.
|
||||
const COMMON_ARGS: [&str; 2] = ["--accept-source-agreements", "--disable-interactivity"];
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct Detected {
|
||||
path: Option<PathBuf>,
|
||||
module: Option<ps::ModuleInfo>,
|
||||
}
|
||||
|
||||
/// WinGet-Implementierung von [`PackageManager`].
|
||||
#[derive(Default)]
|
||||
pub struct WingetManager {
|
||||
state: RwLock<Detected>,
|
||||
}
|
||||
|
||||
/// Sucht `winget.exe` im PATH und danach in `%LOCALAPPDATA%\Microsoft\WindowsApps`.
|
||||
pub fn locate_winget() -> Option<PathBuf> {
|
||||
if let Ok(path) = which::which("winget") {
|
||||
return Some(path);
|
||||
}
|
||||
let local = std::env::var_os("LOCALAPPDATA")?;
|
||||
let candidate = Path::new(&local).join("Microsoft").join("WindowsApps").join("winget.exe");
|
||||
candidate.is_file().then_some(candidate)
|
||||
}
|
||||
|
||||
/// Formatiert einen WinGet-Exit-Code als hexadezimale Zahl, z. B. `0x8A150014`.
|
||||
pub fn format_exit_code(code: i32) -> String {
|
||||
format!("0x{:08X}", code as u32)
|
||||
}
|
||||
|
||||
impl WingetManager {
|
||||
/// Erstellt einen Manager ohne Erkennung. [`PackageManager::detect`] füllt den Zustand.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn exe(&self) -> AppResult<PathBuf> {
|
||||
if let Some(path) = self.state.read().await.path.clone() {
|
||||
return Ok(path);
|
||||
}
|
||||
self.detect().await;
|
||||
self.state
|
||||
.read()
|
||||
.await
|
||||
.path
|
||||
.clone()
|
||||
.ok_or_else(|| AppError::ManagerMissing("WinGet".to_owned()))
|
||||
}
|
||||
|
||||
async fn module_shell(&self) -> Option<PathBuf> {
|
||||
self.state.read().await.module.as_ref().map(|m| m.shell.clone())
|
||||
}
|
||||
|
||||
/// Führt winget aus; „keine Treffer“ ergibt eine leere Ausgabe, andere Fehler einen Fehler.
|
||||
async fn table(&self, args: &[&str]) -> AppResult<String> {
|
||||
let exe = self.exe().await?;
|
||||
let out: CmdOutput = process::run(&exe, args, QUERY_TIMEOUT).await?;
|
||||
match out.code {
|
||||
Some(0) => Ok(out.stdout),
|
||||
Some(EXIT_NO_MATCH) => Ok(String::new()),
|
||||
// Manche Versionen liefern trotz Teilfehler (z. B. eine nicht erreichbare Quelle) eine Tabelle.
|
||||
Some(code) if !parser::parse_table(&out.stdout).is_empty() => {
|
||||
tracing::warn!(code = %format_exit_code(code), "WinGet meldete einen Fehler, Tabelle wird trotzdem verwendet");
|
||||
Ok(out.stdout)
|
||||
}
|
||||
Some(code) => Err(AppError::Command(format!(
|
||||
"WinGet wurde mit dem Fehlercode {} beendet.",
|
||||
format_exit_code(code)
|
||||
))),
|
||||
None => Err(AppError::Command("WinGet wurde unerwartet beendet.".to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PackageManager for WingetManager {
|
||||
fn id(&self) -> ManagerId {
|
||||
ManagerId::Winget
|
||||
}
|
||||
|
||||
async fn detect(&self) -> ManagerInfo {
|
||||
let path = locate_winget();
|
||||
let version = match &path {
|
||||
Some(p) => process::run(p, &["--version"], Duration::from_secs(20))
|
||||
.await
|
||||
.ok()
|
||||
.filter(|o| o.code == Some(0))
|
||||
.map(|o| o.stdout.trim().trim_start_matches('v').to_owned())
|
||||
.filter(|v| !v.is_empty()),
|
||||
None => None,
|
||||
};
|
||||
let module = ps::detect_module().await;
|
||||
let info = ManagerInfo {
|
||||
id: ManagerId::Winget,
|
||||
name: "WinGet".to_owned(),
|
||||
available: path.is_some() && version.is_some(),
|
||||
path: path.as_ref().map(|p| p.display().to_string()),
|
||||
version: version.clone(),
|
||||
extras: vec![ExtraInfo {
|
||||
name: "Microsoft.WinGet.Client".to_owned(),
|
||||
available: module.is_some(),
|
||||
version: module.as_ref().map(|m| m.version.clone()),
|
||||
}],
|
||||
};
|
||||
*self.state.write().await = Detected { path: path.filter(|_| info.available), module };
|
||||
tracing::info!(available = info.available, version = ?info.version, module = info.extras[0].available, "WinGet erkannt");
|
||||
info
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> AppResult<Vec<Package>> {
|
||||
if let Some(shell) = self.module_shell().await {
|
||||
match ps::search(&shell, query).await {
|
||||
Ok(list) => return Ok(list),
|
||||
Err(err) => tracing::warn!(%err, "Modulsuche fehlgeschlagen, Kommandozeile wird verwendet"),
|
||||
}
|
||||
}
|
||||
let text = self
|
||||
.table(&["search", "--query", query, "--count", "200", COMMON_ARGS[0], COMMON_ARGS[1]])
|
||||
.await?;
|
||||
Ok(parser::parse_packages(&text))
|
||||
}
|
||||
|
||||
async fn list_installed(&self) -> AppResult<Vec<Package>> {
|
||||
if let Some(shell) = self.module_shell().await {
|
||||
match ps::list_installed(&shell).await {
|
||||
Ok(list) => return Ok(list),
|
||||
Err(err) => tracing::warn!(%err, "Modulabfrage fehlgeschlagen, Kommandozeile wird verwendet"),
|
||||
}
|
||||
}
|
||||
let text = self.table(&["list", COMMON_ARGS[0], COMMON_ARGS[1]]).await?;
|
||||
Ok(parser::parse_packages(&text))
|
||||
}
|
||||
|
||||
async fn list_updates(&self) -> AppResult<Vec<Package>> {
|
||||
if let Some(shell) = self.module_shell().await {
|
||||
match ps::list_updates(&shell).await {
|
||||
Ok(list) => return Ok(list),
|
||||
Err(err) => tracing::warn!(%err, "Modulabfrage fehlgeschlagen, Kommandozeile wird verwendet"),
|
||||
}
|
||||
}
|
||||
let text = self.table(&["upgrade", COMMON_ARGS[0], COMMON_ARGS[1]]).await?;
|
||||
Ok(parser::parse_packages(&text))
|
||||
}
|
||||
|
||||
async fn get_details(&self, package: &Package) -> AppResult<PackageDetails> {
|
||||
let mut base: Vec<&str> = vec!["show", "--id", &package.id, "--exact"];
|
||||
if let Some(source) = package.source.as_deref() {
|
||||
base.extend(["--source", source]);
|
||||
}
|
||||
base.extend(COMMON_ARGS);
|
||||
let mut with_versions = base.clone();
|
||||
with_versions.push("--versions");
|
||||
|
||||
let (show, versions) = tokio::join!(self.table(&base), self.table(&with_versions));
|
||||
let mut details = parser::parse_details(&show?);
|
||||
details.versions = parser::parse_versions(&versions.unwrap_or_default());
|
||||
Ok(details)
|
||||
}
|
||||
|
||||
async fn list_sources(&self) -> AppResult<Vec<Source>> {
|
||||
let text = self.table(&["source", "list"]).await?;
|
||||
Ok(parser::parse_table(&text)
|
||||
.into_iter()
|
||||
.map(|cells| Source {
|
||||
name: cells.first().cloned().unwrap_or_default(),
|
||||
url: cells.get(1).cloned().unwrap_or_default(),
|
||||
explicit: cells.get(2).is_some_and(|v| v.eq_ignore_ascii_case("true")),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Parser für die lokalisierte Textausgabe von `winget`.
|
||||
//!
|
||||
//! WinGet richtet Tabellenspalten nach der *Anzeigebreite* aus (breite Zeichen belegen zwei Zellen).
|
||||
//! Spaltenüberschriften sind lokalisiert; die Spalten werden daher aus den Positionen der Überschrift
|
||||
//! abgeleitet und nach ihrem Inhalt (nicht nach ihrem Namen) zugeordnet.
|
||||
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use crate::models::{ensure_unique_keys, ManagerId, Package, PackageDetails};
|
||||
|
||||
/// Eine Zeile als Zellenfolge, bei der breite Zeichen zwei Zellen belegen (zweite Zelle = `'\0'`).
|
||||
fn to_cells(line: &str) -> Vec<char> {
|
||||
let mut cells = Vec::with_capacity(line.len());
|
||||
for ch in line.chars() {
|
||||
let width = ch.width().unwrap_or(0);
|
||||
match width {
|
||||
0 => {}
|
||||
1 => cells.push(ch),
|
||||
_ => {
|
||||
cells.push(ch);
|
||||
cells.extend(std::iter::repeat_n('\0', width - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
fn slice_cells(cells: &[char], start: usize, end: Option<usize>) -> String {
|
||||
if start >= cells.len() {
|
||||
return String::new();
|
||||
}
|
||||
let end = end.map_or(cells.len(), |e| e.min(cells.len()));
|
||||
cells[start..end].iter().filter(|c| **c != '\0').collect::<String>().trim().to_owned()
|
||||
}
|
||||
|
||||
/// Bereinigt Rohausgabe: entfernt Fortschrittsanzeigen (`\r`-Überschreibungen) und Steuerzeichen.
|
||||
pub fn clean_lines(text: &str) -> Vec<String> {
|
||||
text.split('\n')
|
||||
.map(|line| {
|
||||
let line = line.trim_end_matches('\r');
|
||||
let last = line.rsplit('\r').next().unwrap_or(line);
|
||||
last.chars().filter(|c| !c.is_control() || *c == '\t').collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_separator(line: &str) -> bool {
|
||||
let t = line.trim();
|
||||
t.len() >= 3 && t.chars().all(|c| c == '-')
|
||||
}
|
||||
|
||||
/// Zerlegt die erste Tabelle der Ausgabe in Zellen (ohne Kopfzeile).
|
||||
///
|
||||
/// Zeilen nach der Tabelle (Zusammenfassungen wie „4 Aktualisierungen verfügbar.“) werden verworfen.
|
||||
pub fn parse_table(text: &str) -> Vec<Vec<String>> {
|
||||
let lines = clean_lines(text);
|
||||
let Some(sep_idx) = lines.iter().position(|l| is_separator(l)).filter(|i| *i > 0) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let header = to_cells(&lines[sep_idx - 1]);
|
||||
|
||||
// Kandidaten für Zeilen: bis zur ersten Leerzeile.
|
||||
let body: Vec<Vec<char>> = lines[sep_idx + 1..]
|
||||
.iter()
|
||||
.take_while(|l| !l.trim().is_empty())
|
||||
.map(|l| to_cells(l))
|
||||
.collect();
|
||||
if body.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Spaltenanfänge aus der Überschrift (Wortanfänge nach einem Leerzeichen).
|
||||
let mut starts = vec![0usize];
|
||||
for i in 1..header.len() {
|
||||
if header[i] != ' ' && header[i - 1] == ' ' && header[i] != '\0' {
|
||||
starts.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Mehrwörtrige Überschriften: ein Anfang gilt nur, wenn davor in den meisten Zeilen eine Lücke liegt.
|
||||
let gap_ok = |row: &[char], start: usize| row.len() < start || row.get(start - 1).is_none_or(|c| *c == ' ');
|
||||
let threshold = (body.len() * 4).div_ceil(5);
|
||||
starts.retain(|&s| s == 0 || body.iter().filter(|r| gap_ok(r, s)).count() >= threshold);
|
||||
|
||||
let mut rows = Vec::new();
|
||||
for row in &body {
|
||||
// Zeilen, die eine Spaltenlücke verletzen, sind Fließtext (Zusammenfassung) und beenden die Tabelle.
|
||||
if starts.iter().skip(1).any(|&s| !gap_ok(row, s)) {
|
||||
break;
|
||||
}
|
||||
let mut cells = Vec::with_capacity(starts.len());
|
||||
for (i, &s) in starts.iter().enumerate() {
|
||||
cells.push(slice_cells(row, s, starts.get(i + 1).copied()));
|
||||
}
|
||||
if cells.get(1).is_none_or(|id| id.is_empty()) {
|
||||
break;
|
||||
}
|
||||
rows.push(cells);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn looks_like_source(value: &str) -> bool {
|
||||
let mut chars = value.chars();
|
||||
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& value.chars().all(|c| c.is_ascii_alphabetic() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ColumnKind {
|
||||
Available,
|
||||
Match,
|
||||
Source,
|
||||
}
|
||||
|
||||
fn classify(rows: &[Vec<String>], col: usize) -> ColumnKind {
|
||||
let values: Vec<&str> = rows.iter().filter_map(|r| r.get(col)).map(String::as_str).filter(|v| !v.is_empty()).collect();
|
||||
if values.iter().any(|v| v.contains(':')) {
|
||||
ColumnKind::Match
|
||||
} else if !values.is_empty() && values.iter().all(|v| looks_like_source(v)) {
|
||||
ColumnKind::Source
|
||||
} else if values.is_empty() {
|
||||
// Nur leere Zellen: letzte Spalte ist praktisch immer die Quelle.
|
||||
ColumnKind::Source
|
||||
} else {
|
||||
ColumnKind::Available
|
||||
}
|
||||
}
|
||||
|
||||
/// Wandelt eine WinGet-Tabelle (`list`, `upgrade`, `search`) in Pakete um.
|
||||
pub fn parse_packages(text: &str) -> Vec<Package> {
|
||||
let rows = parse_table(text);
|
||||
if rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let width = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
let kinds: Vec<(usize, ColumnKind)> = (3..width).map(|c| (c, classify(&rows, c))).collect();
|
||||
let available_col = kinds.iter().find(|(_, k)| *k == ColumnKind::Available).map(|(c, _)| *c);
|
||||
let source_col = kinds.iter().rev().find(|(_, k)| *k == ColumnKind::Source).map(|(c, _)| *c);
|
||||
|
||||
let mut packages: Vec<Package> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let cell = |i: usize| r.get(i).cloned().unwrap_or_default();
|
||||
let non_empty = |s: String| if s.is_empty() { None } else { Some(s) };
|
||||
let version = cell(2).trim_start_matches(['<', '>', ' ']).to_owned();
|
||||
Package::new(
|
||||
ManagerId::Winget,
|
||||
cell(1),
|
||||
cell(0),
|
||||
version,
|
||||
available_col.map(cell).and_then(non_empty),
|
||||
source_col.map(cell).and_then(non_empty),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
ensure_unique_keys(&mut packages);
|
||||
packages
|
||||
}
|
||||
|
||||
/// Liest die Versionsliste aus `winget show --versions`.
|
||||
pub fn parse_versions(text: &str) -> Vec<String> {
|
||||
let lines = clean_lines(text);
|
||||
let Some(sep) = lines.iter().position(|l| is_separator(l)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
lines[sep + 1..]
|
||||
.iter()
|
||||
.map(|l| l.trim().to_owned())
|
||||
.take_while(|l| !l.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn split_key(line: &str) -> Option<(&str, &str)> {
|
||||
let idx = line.find(':')?;
|
||||
let (key, rest) = line.split_at(idx);
|
||||
let rest = &rest[1..];
|
||||
// Schlüssel enthalten keine URL-Teile und sind kurz; URLs wie „https://…“ werden so nicht zerlegt.
|
||||
if key.is_empty() || key.contains("//") || key.len() > 48 || !(rest.is_empty() || rest.starts_with(' ')) {
|
||||
return None;
|
||||
}
|
||||
Some((key.trim(), rest.trim()))
|
||||
}
|
||||
|
||||
fn opt(s: &str) -> Option<String> {
|
||||
if s.is_empty() { None } else { Some(s.to_owned()) }
|
||||
}
|
||||
|
||||
/// Liest die Ausgabe von `winget show`. Bekannte Bezeichnungen (de/en) werden direkt erkannt;
|
||||
/// der Installer-Block wird zusätzlich anhand seines Inhalts erkannt.
|
||||
pub fn parse_details(text: &str) -> PackageDetails {
|
||||
let mut details = PackageDetails::default();
|
||||
let lines = clean_lines(text);
|
||||
let mut block_key: Option<String> = None;
|
||||
let mut block_lines: Vec<String> = Vec::new();
|
||||
let mut blocks: Vec<(String, Vec<String>)> = Vec::new();
|
||||
|
||||
let mut flush = |key: &mut Option<String>, lines: &mut Vec<String>| {
|
||||
if let Some(k) = key.take() {
|
||||
blocks.push((k, std::mem::take(lines)));
|
||||
}
|
||||
};
|
||||
|
||||
for line in &lines {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line.starts_with(' ') || line.starts_with('\t') {
|
||||
if block_key.is_some() {
|
||||
block_lines.push(line.trim_end().to_owned());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
flush(&mut block_key, &mut block_lines);
|
||||
let Some((key, value)) = split_key(line) else { continue };
|
||||
let k = key.to_lowercase();
|
||||
if value.is_empty() {
|
||||
block_key = Some(k);
|
||||
continue;
|
||||
}
|
||||
match k.as_str() {
|
||||
"herausgeber" | "publisher" => details.publisher = opt(value),
|
||||
"startseite" | "homepage" => details.homepage = opt(value),
|
||||
"lizenz" | "license" => details.license = opt(value),
|
||||
"lizenz-url" | "license url" => details.license_url = opt(value),
|
||||
"beschreibung" | "description" => details.description = opt(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flush(&mut block_key, &mut block_lines);
|
||||
|
||||
for (key, body) in blocks {
|
||||
let is_description = matches!(key.as_str(), "beschreibung" | "description");
|
||||
if is_description {
|
||||
let text = body.iter().map(|l| l.trim()).collect::<Vec<_>>().join("\n");
|
||||
details.description = opt(text.trim());
|
||||
continue;
|
||||
}
|
||||
// Installer-Block: Einträge mit URL und Hash erkennen.
|
||||
let pairs: Vec<(String, String)> = body
|
||||
.iter()
|
||||
.filter_map(|l| split_key(l.trim()).map(|(k, v)| (k.to_lowercase(), v.to_owned())))
|
||||
.collect();
|
||||
for (k, v) in pairs {
|
||||
if v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if k.contains("sha256") || k.contains("hash") {
|
||||
details.hash.get_or_insert(v);
|
||||
} else if k.contains("url") && v.starts_with("http") {
|
||||
details.installer_url.get_or_insert(v);
|
||||
} else if k.contains("typ") || k.contains("type") {
|
||||
details.installer_type.get_or_insert(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
details
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const LIST: &str = include_str!("../../../tests/fixtures/winget_list_de.txt");
|
||||
const UPGRADE: &str = include_str!("../../../tests/fixtures/winget_upgrade_de.txt");
|
||||
const SEARCH: &str = include_str!("../../../tests/fixtures/winget_search_de.txt");
|
||||
const SHOW: &str = include_str!("../../../tests/fixtures/winget_show_de.txt");
|
||||
|
||||
#[test]
|
||||
fn upgrade_table_from_real_output() {
|
||||
let pkgs = parse_packages(UPGRADE);
|
||||
assert_eq!(pkgs.len(), 4, "Zusammenfassungszeile darf kein Paket ergeben");
|
||||
let vlc = pkgs.iter().find(|p| p.id == "VideoLAN.VLC").expect("VLC fehlt");
|
||||
assert_eq!(vlc.name, "VLC media player");
|
||||
assert_eq!(vlc.version, "3.0.23");
|
||||
assert_eq!(vlc.available_version.as_deref(), Some("3.0.24"));
|
||||
assert_eq!(vlc.source.as_deref(), Some("winget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_table_from_real_output() {
|
||||
let pkgs = parse_packages(LIST);
|
||||
assert!(pkgs.len() > 50, "erwartet viele Pakete, erhalten {}", pkgs.len());
|
||||
let arp = pkgs.iter().find(|p| p.id.starts_with("ARP\\")).expect("ARP-Paket fehlt");
|
||||
assert!(arp.source.is_none());
|
||||
let betterbird = pkgs.iter().filter(|p| p.id == "Betterbird.Betterbird").count();
|
||||
assert_eq!(betterbird, 2);
|
||||
let keys: std::collections::HashSet<_> = pkgs.iter().map(|p| &p.key).collect();
|
||||
assert_eq!(keys.len(), pkgs.len(), "Schlüssel müssen eindeutig sein");
|
||||
assert!(pkgs.iter().all(|p| !p.id.is_empty() && !p.name.is_empty()));
|
||||
let anti = pkgs.iter().find(|p| p.id == "Google.Antigravity").expect("Antigravity fehlt");
|
||||
assert!(anti.available_version.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_table_has_match_column() {
|
||||
let pkgs = parse_packages(SEARCH);
|
||||
assert!(!pkgs.is_empty());
|
||||
let vlc = &pkgs[0];
|
||||
assert_eq!(vlc.id, "VideoLAN.VLC");
|
||||
assert_eq!(vlc.version, "3.0.24");
|
||||
assert_eq!(vlc.available_version, None, "Spalte „Übereinstimmung“ ist keine Version");
|
||||
assert_eq!(vlc.source.as_deref(), Some("winget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_noise_is_ignored() {
|
||||
let noisy = format!(" - \r \\ \r | \r\r\n{UPGRADE}");
|
||||
assert_eq!(parse_packages(&noisy).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_message_only_output() {
|
||||
assert!(parse_packages("Es wurde kein Paket gefunden, das den Eingabekriterien entspricht.\n").is_empty());
|
||||
assert!(parse_packages("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_characters_keep_columns_aligned() {
|
||||
// Synthetische Zeile (nicht aus echter Ausgabe): CJK-Zeichen belegen zwei Anzeigezellen.
|
||||
let text = "Name ID Version Quelle\n\
|
||||
----------------------------------------\n\
|
||||
微信 Tencent.WeChat 3.9.1 winget\n\
|
||||
Foo Foo.Bar 1.0 winget\n";
|
||||
let pkgs = parse_packages(text);
|
||||
assert_eq!(pkgs.len(), 2);
|
||||
assert_eq!(pkgs[0].name, "微信");
|
||||
assert_eq!(pkgs[0].id, "Tencent.WeChat");
|
||||
assert_eq!(pkgs[1].id, "Foo.Bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_from_real_output() {
|
||||
let d = parse_details(SHOW);
|
||||
assert_eq!(d.publisher.as_deref(), Some("VideoLAN"));
|
||||
assert_eq!(d.license.as_deref(), Some("GPL-2.0"));
|
||||
assert_eq!(d.homepage.as_deref(), Some("https://www.videolan.org/vlc/"));
|
||||
assert_eq!(d.installer_type.as_deref(), Some("wix"));
|
||||
assert!(d.installer_url.as_deref().is_some_and(|u| u.ends_with(".msi")));
|
||||
assert_eq!(d.hash.as_deref().map(str::len), Some(64));
|
||||
assert!(d.description.as_deref().is_some_and(|t| t.starts_with("VLC is a free")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_list() {
|
||||
let text = "Gefunden VLC media player [VideoLAN.VLC]\nVersion\n--------\n3.0.24\n3.0.23\n";
|
||||
assert_eq!(parse_versions(text), vec!["3.0.24", "3.0.23"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Zugriff auf das PowerShell-Modul `Microsoft.WinGet.Client` (Ausgabe als JSON).
|
||||
//!
|
||||
//! Hinweis: Dieser Pfad ist nur aktiv, wenn das Modul installiert ist. Bei jedem Fehler fällt der
|
||||
//! WinGet-Manager auf die Kommandozeile zurück.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{ManagerId, Package};
|
||||
use crate::process;
|
||||
|
||||
const PS_PREAMBLE: &str = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; \
|
||||
$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'; \
|
||||
Import-Module Microsoft.WinGet.Client; ";
|
||||
|
||||
/// Ergebnis der Modulerkennung.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModuleInfo {
|
||||
/// PowerShell-Programm, in dem das Modul gefunden wurde.
|
||||
pub shell: PathBuf,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
fn shells() -> Vec<PathBuf> {
|
||||
["pwsh", "powershell"].iter().filter_map(|n| which::which(n).ok()).collect()
|
||||
}
|
||||
|
||||
/// Sucht das Modul in PowerShell 7 und Windows PowerShell.
|
||||
pub async fn detect_module() -> Option<ModuleInfo> {
|
||||
let script = "$m = Get-Module -ListAvailable -Name Microsoft.WinGet.Client | \
|
||||
Sort-Object Version -Descending | Select-Object -First 1; \
|
||||
if ($m) { $m.Version.ToString() }";
|
||||
for shell in shells() {
|
||||
let Ok(out) = process::run(
|
||||
&shell,
|
||||
&["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
||||
Duration::from_secs(30),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let version = out.stdout.trim().to_owned();
|
||||
if out.code == Some(0) && !version.is_empty() {
|
||||
return Some(ModuleInfo { shell, version });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PsPackage {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
version: Option<String>,
|
||||
available: Option<String>,
|
||||
update: Option<bool>,
|
||||
source: Option<String>,
|
||||
}
|
||||
|
||||
async fn run_json(shell: &Path, body: &str, query: &str) -> AppResult<Vec<PsPackage>> {
|
||||
let script = format!("{PS_PREAMBLE}{body}");
|
||||
let out = process::run_env(
|
||||
shell,
|
||||
&["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", &script],
|
||||
&[("VU_QUERY", query)],
|
||||
Duration::from_secs(180),
|
||||
)
|
||||
.await?;
|
||||
if out.code != Some(0) {
|
||||
return Err(AppError::Command(format!(
|
||||
"PowerShell-Modul fehlgeschlagen: {}",
|
||||
out.stderr.lines().next().unwrap_or("unbekannter Fehler")
|
||||
)));
|
||||
}
|
||||
let text = out.stdout.trim();
|
||||
if text.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(serde_json::from_str(text)?)
|
||||
}
|
||||
|
||||
fn convert(items: Vec<PsPackage>, only_updates: bool) -> Vec<Package> {
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|p| !only_updates || p.update.unwrap_or(false))
|
||||
.filter_map(|p| {
|
||||
let id = p.id.filter(|s| !s.is_empty())?;
|
||||
let non_empty = |s: Option<String>| s.filter(|v| !v.is_empty());
|
||||
Some(Package::new(
|
||||
ManagerId::Winget,
|
||||
id.clone(),
|
||||
non_empty(p.name).unwrap_or(id),
|
||||
p.version.unwrap_or_default(),
|
||||
if only_updates || p.update.unwrap_or(false) { non_empty(p.available) } else { None },
|
||||
non_empty(p.source),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const INSTALLED_BODY: &str = "$r = @(Get-WinGetPackage | ForEach-Object { [pscustomobject]@{ \
|
||||
id = $_.Id; name = $_.Name; version = $_.InstalledVersion; \
|
||||
available = ($_.AvailableVersions | Select-Object -First 1); \
|
||||
update = [bool]$_.IsUpdateAvailable; source = $_.Source } }); \
|
||||
ConvertTo-Json -InputObject $r -Compress -Depth 3";
|
||||
|
||||
const SEARCH_BODY: &str = "$r = @(Find-WinGetPackage -Query $env:VU_QUERY | ForEach-Object { [pscustomobject]@{ \
|
||||
id = $_.Id; name = $_.Name; version = $_.Version; source = $_.Source } }); \
|
||||
ConvertTo-Json -InputObject $r -Compress -Depth 3";
|
||||
|
||||
/// Installierte Pakete über das Modul.
|
||||
pub async fn list_installed(shell: &Path) -> AppResult<Vec<Package>> {
|
||||
Ok(convert(run_json(shell, INSTALLED_BODY, "").await?, false))
|
||||
}
|
||||
|
||||
/// Verfügbare Aktualisierungen über das Modul.
|
||||
pub async fn list_updates(shell: &Path) -> AppResult<Vec<Package>> {
|
||||
Ok(convert(run_json(shell, INSTALLED_BODY, "").await?, true))
|
||||
}
|
||||
|
||||
/// Suche über das Modul.
|
||||
pub async fn search(shell: &Path, query: &str) -> AppResult<Vec<Package>> {
|
||||
Ok(convert(run_json(shell, SEARCH_BODY, query).await?, false))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Gemeinsame Datentypen, die zwischen Backend und Oberfläche ausgetauscht werden.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Kennung eines Paketmanagers.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ManagerId {
|
||||
Winget,
|
||||
Scoop,
|
||||
Choco,
|
||||
Pip,
|
||||
Npm,
|
||||
Dotnet,
|
||||
Psgallery,
|
||||
Cargo,
|
||||
Vcpkg,
|
||||
}
|
||||
|
||||
/// Ein Paket in einer Liste (Suche, installiert, Aktualisierung).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Package {
|
||||
/// Stabile Kennung innerhalb der Oberfläche (`manager:id:quelle`).
|
||||
pub key: String,
|
||||
/// Paket-ID des Managers.
|
||||
pub id: String,
|
||||
/// Anzeigename.
|
||||
pub name: String,
|
||||
/// Installierte oder (bei Suche) aktuelle Version.
|
||||
pub version: String,
|
||||
/// Verfügbare neue Version, falls vorhanden.
|
||||
pub available_version: Option<String>,
|
||||
/// Zugehöriger Manager.
|
||||
pub manager: ManagerId,
|
||||
/// Quelle (z. B. `winget`, `msstore`).
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
/// Erzeugt ein Paket und berechnet den eindeutigen Schlüssel.
|
||||
pub fn new(
|
||||
manager: ManagerId,
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
available_version: Option<String>,
|
||||
source: Option<String>,
|
||||
) -> Self {
|
||||
let key = format!(
|
||||
"{}:{}:{}:{}",
|
||||
serde_json::to_value(manager)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or_default(),
|
||||
id,
|
||||
source.as_deref().unwrap_or(""),
|
||||
version
|
||||
);
|
||||
Self { key, id, name, version, available_version, manager, source }
|
||||
}
|
||||
}
|
||||
|
||||
/// Macht die Schlüssel einer Liste eindeutig (z. B. bei Paketen mit gleicher ID, aber verschiedenem Namen).
|
||||
pub fn ensure_unique_keys(packages: &mut [Package]) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for pkg in packages.iter_mut() {
|
||||
let mut candidate = pkg.key.clone();
|
||||
let mut n = 2;
|
||||
while !seen.insert(candidate.clone()) {
|
||||
candidate = format!("{}#{n}", pkg.key);
|
||||
n += 1;
|
||||
}
|
||||
pkg.key = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/// Detailinformationen zu einem Paket.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackageDetails {
|
||||
pub description: Option<String>,
|
||||
pub publisher: Option<String>,
|
||||
pub license: Option<String>,
|
||||
pub license_url: Option<String>,
|
||||
pub homepage: Option<String>,
|
||||
pub installer_url: Option<String>,
|
||||
pub installer_type: Option<String>,
|
||||
pub hash: Option<String>,
|
||||
pub versions: Vec<String>,
|
||||
}
|
||||
|
||||
/// Ergebnis der Erkennung eines Paketmanagers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManagerInfo {
|
||||
pub id: ManagerId,
|
||||
pub name: String,
|
||||
/// Ob die ausführbare Datei gefunden wurde.
|
||||
pub available: bool,
|
||||
pub path: Option<String>,
|
||||
pub version: Option<String>,
|
||||
/// Zusatzkomponenten, z. B. das PowerShell-Modul bei WinGet.
|
||||
pub extras: Vec<ExtraInfo>,
|
||||
}
|
||||
|
||||
/// Eine optionale Zusatzkomponente eines Managers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExtraInfo {
|
||||
pub name: String,
|
||||
pub available: bool,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
/// Eine Paketquelle.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Source {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub explicit: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duplicate_keys_are_made_unique() {
|
||||
let make = |name: &str| {
|
||||
Package::new(ManagerId::Winget, "A.B".into(), name.into(), "1.0".into(), None, Some("winget".into()))
|
||||
};
|
||||
let mut list = vec![make("x"), make("y"), make("z")];
|
||||
ensure_unique_keys(&mut list);
|
||||
let keys: std::collections::HashSet<_> = list.iter().map(|p| p.key.clone()).collect();
|
||||
assert_eq!(keys.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Start externer Prozesse ohne Konsolenfenster, mit Zeitlimit und UTF-8-Dekodierung.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Ergebnis eines beendeten Prozesses.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CmdOutput {
|
||||
/// Exit-Code (`None`, wenn der Prozess beendet wurde).
|
||||
pub code: Option<i32>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
/// Dekodiert Prozessausgabe als UTF-8 und entfernt ein führendes BOM.
|
||||
pub fn decode(bytes: &[u8]) -> String {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
text.trim_start_matches('\u{feff}').to_owned()
|
||||
}
|
||||
|
||||
/// Erzeugt ein Kommando, das unter Windows kein Konsolenfenster öffnet.
|
||||
pub fn command(program: &Path) -> Command {
|
||||
let mut cmd = Command::new(program);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd.kill_on_drop(true)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Führt ein Programm aus und wartet höchstens `timeout`.
|
||||
pub async fn run(program: &Path, args: &[&str], timeout: Duration) -> AppResult<CmdOutput> {
|
||||
run_env(program, args, &[], timeout).await
|
||||
}
|
||||
|
||||
/// Wie [`run`], setzt zusätzlich Umgebungsvariablen (z. B. um Benutzereingaben sicher zu übergeben).
|
||||
pub async fn run_env(
|
||||
program: &Path,
|
||||
args: &[&str],
|
||||
envs: &[(&str, &str)],
|
||||
timeout: Duration,
|
||||
) -> AppResult<CmdOutput> {
|
||||
let mut cmd = command(program);
|
||||
cmd.args(args).envs(envs.iter().copied());
|
||||
tracing::debug!(program = %program.display(), ?args, "Prozess wird gestartet");
|
||||
let child = cmd.spawn().map_err(|source| AppError::Spawn {
|
||||
program: program.display().to_string(),
|
||||
source,
|
||||
})?;
|
||||
let output = tokio::time::timeout(timeout, child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| AppError::Timeout(timeout.as_secs()))??;
|
||||
Ok(CmdOutput {
|
||||
code: output.status.code(),
|
||||
stdout: decode(&output.stdout),
|
||||
stderr: decode(&output.stderr),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Persistenter Zustand unter `%APPDATA%\VitosUpdater` (Phase 1: Fensterposition und -größe).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Gespeicherte Fenstergeometrie in physischen Pixeln.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WindowState {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub maximized: bool,
|
||||
}
|
||||
|
||||
fn file(dir: &Path) -> PathBuf {
|
||||
dir.join("window.json")
|
||||
}
|
||||
|
||||
/// Lädt die gespeicherte Fenstergeometrie. Fehlt die Datei oder ist sie defekt, ist das Ergebnis `None`.
|
||||
pub fn load_window_state(dir: &Path) -> Option<WindowState> {
|
||||
let text = std::fs::read_to_string(file(dir)).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// Speichert die Fenstergeometrie atomar (Schreiben in temporäre Datei, dann Umbenennen).
|
||||
pub fn save_window_state(dir: &Path, state: &WindowState) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = dir.join("window.json.tmp");
|
||||
std::fs::write(&tmp, serde_json::to_string_pretty(state)?)?;
|
||||
std::fs::rename(tmp, file(dir))
|
||||
}
|
||||
|
||||
/// Prüft, ob ein Fensterrechteck ein Monitorrechteck `(x, y, breite, höhe)` ausreichend überlappt.
|
||||
pub fn is_visible_on(state: &WindowState, monitors: &[(i32, i32, u32, u32)]) -> bool {
|
||||
const MIN_VISIBLE: i64 = 120;
|
||||
monitors.iter().any(|&(mx, my, mw, mh)| {
|
||||
let left = i64::from(state.x.max(mx));
|
||||
let top = i64::from(state.y.max(my));
|
||||
let right = (i64::from(state.x) + i64::from(state.width)).min(i64::from(mx) + i64::from(mw));
|
||||
let bottom = (i64::from(state.y) + i64::from(state.height)).min(i64::from(my) + i64::from(mh));
|
||||
right - left >= MIN_VISIBLE && bottom - top >= MIN_VISIBLE
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn state(x: i32, y: i32) -> WindowState {
|
||||
WindowState { x, y, width: 1280, height: 800, maximized: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_on_primary_monitor() {
|
||||
assert!(is_visible_on(&state(100, 100), &[(0, 0, 1920, 1080)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offscreen_after_monitor_removed() {
|
||||
assert!(!is_visible_on(&state(2500, 100), &[(0, 0, 1920, 1080)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip() {
|
||||
let dir = std::env::temp_dir().join("vitosupdater-test-window");
|
||||
let s = state(10, 20);
|
||||
save_window_state(&dir, &s).unwrap();
|
||||
assert_eq!(load_window_state(&dir), Some(s));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! Selbst-Update von VitosUpdater über `tauri-plugin-updater` (statische `latest.json` auf Gitea).
|
||||
|
||||
pub mod policy;
|
||||
|
||||
use std::error::Error as StdError;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use policy::{Offer, UpdaterSettings};
|
||||
|
||||
const SETTINGS_FILE: &str = "updater.json";
|
||||
const NOTICE_FILE: &str = "update-notice.json";
|
||||
const MAIN_WINDOW: &str = "main";
|
||||
|
||||
/// Öffentliche Angaben zu einem verfügbaren Update.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateInfo {
|
||||
pub version: String,
|
||||
pub current_version: String,
|
||||
/// Veröffentlichungsdatum als Unix-Sekunden.
|
||||
pub date: Option<i64>,
|
||||
/// Release-Notizen (Markdown).
|
||||
pub notes: String,
|
||||
/// Größe des Installers in Bytes, falls in `latest.json` angegeben.
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
/// Ergebnis einer Update-Prüfung.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum CheckOutcome {
|
||||
Available { info: UpdateInfo },
|
||||
Skipped { info: UpdateInfo },
|
||||
Snoozed { info: UpdateInfo },
|
||||
UpToDate,
|
||||
}
|
||||
|
||||
/// Fortschrittsereignisse der Installation.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "stage", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum InstallEvent {
|
||||
Started { total: Option<u64> },
|
||||
Progress { downloaded: u64, total: Option<u64> },
|
||||
Installing,
|
||||
}
|
||||
|
||||
/// Zustand für die Oberfläche.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdaterStatus {
|
||||
pub current_version: String,
|
||||
pub settings: UpdaterSettings,
|
||||
pub last_check: Option<i64>,
|
||||
pub last_error: Option<String>,
|
||||
/// Aktuell anzubietendes Update (nicht übersprungen/zurückgestellt).
|
||||
pub offer: Option<UpdateInfo>,
|
||||
}
|
||||
|
||||
/// Hinweis, der nach dem Neustart den erfolgreichen Wechsel anzeigt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateNotice {
|
||||
pub version: String,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
settings: UpdaterSettings,
|
||||
pending: Option<Update>,
|
||||
last_check: Option<i64>,
|
||||
last_error: Option<String>,
|
||||
notified_version: Option<String>,
|
||||
}
|
||||
|
||||
/// Gemeinsamer Zustand des Updaters.
|
||||
pub struct UpdaterState {
|
||||
dir: PathBuf,
|
||||
inner: Mutex<Inner>,
|
||||
check_lock: tokio::sync::Mutex<()>,
|
||||
wake: Notify,
|
||||
started: AtomicBool,
|
||||
}
|
||||
|
||||
fn now_secs() -> i64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(tmp, path)
|
||||
}
|
||||
|
||||
/// Verkettet eine Fehlerkette zu einem Text.
|
||||
fn error_chain(err: &dyn StdError) -> String {
|
||||
let mut text = err.to_string();
|
||||
let mut source = err.source();
|
||||
while let Some(s) = source {
|
||||
text.push_str(": ");
|
||||
text.push_str(&s.to_string());
|
||||
source = s.source();
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn info_of(update: &Update) -> UpdateInfo {
|
||||
// Die Größe steht als zusätzliches Feld am Plattformeintrag, dessen URL zum Download passt.
|
||||
let size = update
|
||||
.raw_json
|
||||
.get("platforms")
|
||||
.and_then(|p| p.as_object())
|
||||
.and_then(|platforms| {
|
||||
platforms
|
||||
.values()
|
||||
.find(|p| p.get("url").and_then(|u| u.as_str()) == Some(update.download_url.as_str()))
|
||||
})
|
||||
.and_then(|p| p.get("size"))
|
||||
.and_then(serde_json::Value::as_u64);
|
||||
UpdateInfo {
|
||||
version: update.version.clone(),
|
||||
current_version: update.current_version.clone(),
|
||||
date: update.date.map(|d| d.unix_timestamp()),
|
||||
notes: update.body.clone().unwrap_or_default(),
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdaterState {
|
||||
/// Lädt gespeicherte Einstellungen aus dem Datenverzeichnis.
|
||||
pub fn new(dir: PathBuf) -> Self {
|
||||
let settings = std::fs::read_to_string(dir.join(SETTINGS_FILE))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str::<UpdaterSettings>(&t).ok())
|
||||
.unwrap_or_default()
|
||||
.sanitized();
|
||||
Self {
|
||||
dir,
|
||||
inner: Mutex::new(Inner { settings, pending: None, last_check: None, last_error: None, notified_version: None }),
|
||||
check_lock: tokio::sync::Mutex::new(()),
|
||||
wake: Notify::new(),
|
||||
started: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, Inner> {
|
||||
self.inner.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn persist(&self, settings: &UpdaterSettings) {
|
||||
match serde_json::to_string_pretty(settings) {
|
||||
Ok(text) => {
|
||||
if let Err(err) = write_atomic(&self.dir.join(SETTINGS_FILE), &text) {
|
||||
tracing::warn!(%err, "Updater-Einstellungen konnten nicht gespeichert werden");
|
||||
}
|
||||
}
|
||||
Err(err) => tracing::warn!(%err, "Updater-Einstellungen konnten nicht serialisiert werden"),
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self, current_version: &str) -> UpdaterStatus {
|
||||
let inner = self.lock();
|
||||
UpdaterStatus {
|
||||
current_version: current_version.to_owned(),
|
||||
settings: inner.settings.clone(),
|
||||
last_check: inner.last_check,
|
||||
last_error: inner.last_error.clone(),
|
||||
offer: Self::offer_of(&inner),
|
||||
}
|
||||
}
|
||||
|
||||
fn offer_of(inner: &Inner) -> Option<UpdateInfo> {
|
||||
let update = inner.pending.as_ref()?;
|
||||
(policy::decide(&inner.settings, &update.version, now_secs(), false) == Offer::Show).then(|| info_of(update))
|
||||
}
|
||||
}
|
||||
|
||||
fn main_window_visible(app: &AppHandle) -> bool {
|
||||
app.get_webview_window(MAIN_WINDOW)
|
||||
.is_some_and(|w| w.is_visible().unwrap_or(false) && !w.is_minimized().unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Meldet ein neues Update: Popup-Ereignis bei sichtbarem Fenster, sonst einmalig eine Windows-Benachrichtigung.
|
||||
fn announce(app: &AppHandle, info: &UpdateInfo) {
|
||||
if main_window_visible(app) {
|
||||
if let Err(err) = app.emit("update-available", info) {
|
||||
tracing::warn!(%err, "Ereignis „update-available“ konnte nicht gesendet werden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
let state = app.state::<UpdaterState>();
|
||||
{
|
||||
let mut inner = state.lock();
|
||||
if inner.notified_version.as_deref() == Some(info.version.as_str()) {
|
||||
return;
|
||||
}
|
||||
inner.notified_version = Some(info.version.clone());
|
||||
}
|
||||
let result = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Update verfügbar")
|
||||
.body(format!("VitosUpdater {} steht bereit. Zum Installieren die Anwendung öffnen.", info.version))
|
||||
.show();
|
||||
if let Err(err) = result {
|
||||
tracing::warn!(%err, "Benachrichtigung konnte nicht angezeigt werden");
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_check(app: &AppHandle, manual: bool) -> Result<CheckOutcome, String> {
|
||||
let state = app.state::<UpdaterState>();
|
||||
let _guard = state.check_lock.lock().await;
|
||||
|
||||
let restart_handle = app.clone();
|
||||
let checked = async {
|
||||
let updater = app
|
||||
.updater_builder()
|
||||
.on_before_exit(move || {
|
||||
let _ = restart_handle.emit("update-restarting", ());
|
||||
})
|
||||
.build()
|
||||
.map_err(|e| error_chain(&e))?;
|
||||
updater.check().await.map_err(|e| error_chain(&e))
|
||||
}
|
||||
.await;
|
||||
|
||||
let now = now_secs();
|
||||
let mut inner = state.lock();
|
||||
inner.last_check = Some(now);
|
||||
match checked {
|
||||
Ok(Some(update)) if policy::is_newer(&update.version, &update.current_version) => {
|
||||
inner.last_error = None;
|
||||
let info = info_of(&update);
|
||||
let offer = policy::decide(&inner.settings, &update.version, now, manual);
|
||||
inner.pending = Some(update);
|
||||
drop(inner);
|
||||
tracing::info!(version = %info.version, ?offer, manual, "Update gefunden");
|
||||
Ok(match offer {
|
||||
Offer::Show => {
|
||||
if !manual {
|
||||
announce(app, &info);
|
||||
}
|
||||
CheckOutcome::Available { info }
|
||||
}
|
||||
Offer::Skipped => CheckOutcome::Skipped { info },
|
||||
Offer::Snoozed => CheckOutcome::Snoozed { info },
|
||||
})
|
||||
}
|
||||
Ok(_) => {
|
||||
inner.last_error = None;
|
||||
inner.pending = None;
|
||||
tracing::info!("Kein neueres Update verfügbar");
|
||||
Ok(CheckOutcome::UpToDate)
|
||||
}
|
||||
Err(raw) => {
|
||||
let text = policy::friendly_error(&raw);
|
||||
inner.last_error = Some(text.clone());
|
||||
tracing::warn!(error = %raw, "Update-Prüfung fehlgeschlagen");
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn scheduler(app: AppHandle) {
|
||||
let _ = run_check(&app, false).await;
|
||||
loop {
|
||||
let state = app.state::<UpdaterState>();
|
||||
let minutes = state.lock().settings.check_interval_minutes;
|
||||
tokio::select! {
|
||||
() = tokio::time::sleep(Duration::from_secs(u64::from(minutes) * 60)) => {
|
||||
let _ = run_check(&app, false).await;
|
||||
}
|
||||
// Intervall wurde geändert: Wartezeit mit dem neuen Wert neu beginnen.
|
||||
() = state.wake.notified() => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wird von der Oberfläche aufgerufen, sobald sie bereit ist; startet die zyklische Prüfung (einmalig).
|
||||
#[tauri::command]
|
||||
pub fn updater_ready(app: AppHandle, state: State<'_, UpdaterState>) -> UpdaterStatus {
|
||||
if !state.started.swap(true, Ordering::SeqCst) {
|
||||
tauri::async_runtime::spawn(scheduler(app.clone()));
|
||||
}
|
||||
state.status(&app.package_info().version.to_string())
|
||||
}
|
||||
|
||||
/// Liefert Zustand und Einstellungen des Updaters.
|
||||
#[tauri::command]
|
||||
pub fn get_updater_status(app: AppHandle, state: State<'_, UpdaterState>) -> UpdaterStatus {
|
||||
state.status(&app.package_info().version.to_string())
|
||||
}
|
||||
|
||||
/// Liefert das aktuell anzubietende Update, falls vorhanden (z. B. nachdem das Fenster wieder sichtbar wurde).
|
||||
#[tauri::command]
|
||||
pub fn get_update_offer(state: State<'_, UpdaterState>) -> Option<UpdateInfo> {
|
||||
UpdaterState::offer_of(&state.lock())
|
||||
}
|
||||
|
||||
/// Prüft sofort auf Updates. Manuelle Prüfungen ignorieren „Überspringen“ und „Später erinnern“.
|
||||
#[tauri::command]
|
||||
pub async fn check_for_update(app: AppHandle, manual: bool) -> Result<CheckOutcome, String> {
|
||||
run_check(&app, manual).await
|
||||
}
|
||||
|
||||
/// Stellt das gefundene Update für vier Stunden zurück.
|
||||
#[tauri::command]
|
||||
pub fn snooze_update(state: State<'_, UpdaterState>) {
|
||||
let mut inner = state.lock();
|
||||
if let Some(version) = inner.pending.as_ref().map(|u| u.version.clone()) {
|
||||
inner.settings.snoozed_version = Some(version);
|
||||
inner.settings.snoozed_until = Some(now_secs() + policy::SNOOZE_SECONDS);
|
||||
let settings = inner.settings.clone();
|
||||
drop(inner);
|
||||
state.persist(&settings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Überspringt die gefundene Version dauerhaft (neuere Versionen werden weiterhin angeboten).
|
||||
#[tauri::command]
|
||||
pub fn skip_update(state: State<'_, UpdaterState>) {
|
||||
let mut inner = state.lock();
|
||||
if let Some(version) = inner.pending.as_ref().map(|u| u.version.clone()) {
|
||||
inner.settings.skipped_version = Some(version);
|
||||
let settings = inner.settings.clone();
|
||||
drop(inner);
|
||||
state.persist(&settings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hebt „Diese Version überspringen“ wieder auf.
|
||||
#[tauri::command]
|
||||
pub fn clear_skipped_version(state: State<'_, UpdaterState>) -> UpdaterSettings {
|
||||
let mut inner = state.lock();
|
||||
inner.settings.skipped_version = None;
|
||||
inner.settings.snoozed_version = None;
|
||||
inner.settings.snoozed_until = None;
|
||||
let settings = inner.settings.clone();
|
||||
drop(inner);
|
||||
state.persist(&settings);
|
||||
settings
|
||||
}
|
||||
|
||||
/// Ändert das Prüfintervall (in Minuten, wird begrenzt) und startet die Wartezeit neu.
|
||||
#[tauri::command]
|
||||
pub fn set_update_interval(state: State<'_, UpdaterState>, minutes: u32) -> UpdaterSettings {
|
||||
let mut inner = state.lock();
|
||||
inner.settings.check_interval_minutes = minutes;
|
||||
inner.settings = inner.settings.clone().sanitized();
|
||||
let settings = inner.settings.clone();
|
||||
drop(inner);
|
||||
state.persist(&settings);
|
||||
state.wake.notify_one();
|
||||
settings
|
||||
}
|
||||
|
||||
/// Lädt das Update herunter, prüft die Signatur, installiert es und startet die Anwendung neu.
|
||||
#[tauri::command]
|
||||
pub async fn install_update(
|
||||
app: AppHandle,
|
||||
state: State<'_, UpdaterState>,
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> Result<(), String> {
|
||||
let update = state.lock().pending.clone().ok_or_else(|| "Es liegt kein Update zur Installation vor.".to_owned())?;
|
||||
let notice = UpdateNotice { version: update.version.clone(), notes: update.body.clone().unwrap_or_default() };
|
||||
let notice_path = state.dir.join(NOTICE_FILE);
|
||||
if let Ok(text) = serde_json::to_string(¬ice) {
|
||||
if let Err(err) = write_atomic(¬ice_path, &text) {
|
||||
tracing::warn!(%err, "Update-Hinweis konnte nicht gespeichert werden");
|
||||
}
|
||||
}
|
||||
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut started = false;
|
||||
let progress = on_event.clone();
|
||||
let finished = on_event.clone();
|
||||
tracing::info!(version = %update.version, "Update wird heruntergeladen und installiert");
|
||||
let result = update
|
||||
.download_and_install(
|
||||
move |chunk, total| {
|
||||
if !started {
|
||||
started = true;
|
||||
let _ = progress.send(InstallEvent::Started { total });
|
||||
}
|
||||
downloaded += chunk as u64;
|
||||
let _ = progress.send(InstallEvent::Progress { downloaded, total });
|
||||
},
|
||||
move || {
|
||||
let _ = finished.send(InstallEvent::Installing);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
// Unter Windows beendet das Plugin die Anwendung selbst; der Installer startet sie neu.
|
||||
Ok(()) => app.restart(),
|
||||
Err(err) => {
|
||||
let _ = std::fs::remove_file(¬ice_path);
|
||||
let raw = error_chain(&err);
|
||||
tracing::error!(error = %raw, "Update-Installation fehlgeschlagen");
|
||||
let text = policy::friendly_error(&raw);
|
||||
state.lock().last_error = Some(text.clone());
|
||||
Err(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Liefert (und löscht) den Hinweis auf ein soeben installiertes Update, wenn die laufende Version dazu passt.
|
||||
#[tauri::command]
|
||||
pub fn take_update_notice(app: AppHandle, state: State<'_, UpdaterState>) -> Option<UpdateNotice> {
|
||||
let path = state.dir.join(NOTICE_FILE);
|
||||
let notice = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str::<UpdateNotice>(&t).ok());
|
||||
if notice.is_some() || path.exists() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
let current = app.package_info().version.to_string();
|
||||
notice.filter(|n| n.version == current)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Reine Entscheidungslogik des Selbst-Updaters (ohne Netzwerk, daher gut testbar).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Dauer, für die „Später erinnern“ ein Update zurückstellt (4 Stunden).
|
||||
pub const SNOOZE_SECONDS: i64 = 4 * 60 * 60;
|
||||
/// Standardintervall der Update-Prüfung in Minuten.
|
||||
pub const DEFAULT_INTERVAL_MINUTES: u32 = 30;
|
||||
/// Kleinstes erlaubtes Prüfintervall in Minuten.
|
||||
pub const MIN_INTERVAL_MINUTES: u32 = 5;
|
||||
/// Größtes erlaubtes Prüfintervall in Minuten (eine Woche).
|
||||
pub const MAX_INTERVAL_MINUTES: u32 = 7 * 24 * 60;
|
||||
|
||||
/// Gespeicherte Einstellungen des Selbst-Updaters.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UpdaterSettings {
|
||||
/// Prüfintervall in Minuten.
|
||||
pub check_interval_minutes: u32,
|
||||
/// Version, die der Nutzer übersprungen hat.
|
||||
pub skipped_version: Option<String>,
|
||||
/// Version, die zurückgestellt wurde.
|
||||
pub snoozed_version: Option<String>,
|
||||
/// Zeitpunkt (Unix-Sekunden), bis zu dem zurückgestellt wurde.
|
||||
pub snoozed_until: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for UpdaterSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_interval_minutes: DEFAULT_INTERVAL_MINUTES,
|
||||
skipped_version: None,
|
||||
snoozed_version: None,
|
||||
snoozed_until: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdaterSettings {
|
||||
/// Begrenzt das Intervall auf den erlaubten Bereich.
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
self.check_interval_minutes = self.check_interval_minutes.clamp(MIN_INTERVAL_MINUTES, MAX_INTERVAL_MINUTES);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Entscheidung, ob eine gefundene Version angeboten wird.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Offer {
|
||||
/// Popup zeigen.
|
||||
Show,
|
||||
/// Vom Nutzer übersprungen.
|
||||
Skipped,
|
||||
/// Noch zurückgestellt.
|
||||
Snoozed,
|
||||
}
|
||||
|
||||
/// Prüft per SemVer, ob `remote` neuer als `current` ist. Ungültige Versionen gelten nie als neuer.
|
||||
pub fn is_newer(remote: &str, current: &str) -> bool {
|
||||
match (semver::Version::parse(remote.trim_start_matches('v')), semver::Version::parse(current.trim_start_matches('v'))) {
|
||||
(Ok(r), Ok(c)) => r > c,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Entscheidet anhand der Einstellungen, ob `version` angeboten wird. Manuelle Prüfungen bieten immer an.
|
||||
pub fn decide(settings: &UpdaterSettings, version: &str, now: i64, manual: bool) -> Offer {
|
||||
if manual {
|
||||
return Offer::Show;
|
||||
}
|
||||
if settings.skipped_version.as_deref() == Some(version) {
|
||||
return Offer::Skipped;
|
||||
}
|
||||
if settings.snoozed_version.as_deref() == Some(version) && settings.snoozed_until.is_some_and(|until| until > now) {
|
||||
return Offer::Snoozed;
|
||||
}
|
||||
Offer::Show
|
||||
}
|
||||
|
||||
/// Art eines Update-Fehlers, abgeleitet aus der Fehlermeldung.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ErrorKind {
|
||||
Certificate,
|
||||
NotFound,
|
||||
Network,
|
||||
Signature,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Ordnet eine technische Fehlermeldung einer Fehlerart zu.
|
||||
pub fn classify_error(message: &str) -> ErrorKind {
|
||||
let m = message.to_lowercase();
|
||||
const CERT: [&str; 9] = [
|
||||
"certificate",
|
||||
"schannel",
|
||||
"sec_e_",
|
||||
"0x80090325",
|
||||
"0x80090326",
|
||||
"unknown ca",
|
||||
"untrusted",
|
||||
"invalid peer",
|
||||
"ssl",
|
||||
];
|
||||
if CERT.iter().any(|p| m.contains(p)) {
|
||||
ErrorKind::Certificate
|
||||
} else if m.contains("signature") || m.contains("minisign") || m.contains("signed version") {
|
||||
ErrorKind::Signature
|
||||
} else if m.contains("404") || m.contains("not found") {
|
||||
ErrorKind::NotFound
|
||||
} else if ["error sending request", "dns", "connect", "timed out", "timeout", "network", "unreachable"]
|
||||
.iter()
|
||||
.any(|p| m.contains(p))
|
||||
{
|
||||
ErrorKind::Network
|
||||
} else {
|
||||
ErrorKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Deutsche, für die Oberfläche bestimmte Fehlermeldung.
|
||||
pub fn friendly_error(message: &str) -> String {
|
||||
match classify_error(message) {
|
||||
ErrorKind::Certificate => "Die sichere Verbindung zum Update-Server wurde abgelehnt, weil Windows dem Zertifikat nicht vertraut. \
|
||||
Das Zertifikat der Zertifizierungsstelle muss in Windows als vertrauenswürdig installiert werden (siehe README, Abschnitt „Zertifikat“)."
|
||||
.to_owned(),
|
||||
ErrorKind::NotFound => "Die Update-Beschreibung (latest.json) wurde auf dem Server nicht gefunden. Möglicherweise ist noch kein Release veröffentlicht."
|
||||
.to_owned(),
|
||||
ErrorKind::Network => "Der Update-Server ist nicht erreichbar. Bitte die Netzwerkverbindung prüfen.".to_owned(),
|
||||
ErrorKind::Signature => "Die Signatur des Updates ist ungültig. Das Update wurde aus Sicherheitsgründen nicht installiert.".to_owned(),
|
||||
ErrorKind::Other => format!("Die Update-Prüfung ist fehlgeschlagen: {message}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn newer_uses_semver_not_string_order() {
|
||||
assert!(is_newer("0.10.0", "0.9.0"));
|
||||
assert!(is_newer("v1.0.0", "0.9.9"));
|
||||
assert!(!is_newer("0.9.0", "0.10.0"), "Downgrade darf nie angeboten werden");
|
||||
assert!(!is_newer("1.0.0", "1.0.0"));
|
||||
assert!(!is_newer("1.0.0-beta.1", "1.0.0"));
|
||||
assert!(!is_newer("kaputt", "1.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_version_is_not_offered_but_newer_is() {
|
||||
let s = UpdaterSettings { skipped_version: Some("1.2.0".into()), ..Default::default() };
|
||||
assert_eq!(decide(&s, "1.2.0", 0, false), Offer::Skipped);
|
||||
assert_eq!(decide(&s, "1.3.0", 0, false), Offer::Show);
|
||||
assert_eq!(decide(&s, "1.2.0", 0, true), Offer::Show, "manuelle Prüfung zeigt immer an");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snooze_expires() {
|
||||
let s = UpdaterSettings { snoozed_version: Some("2.0.0".into()), snoozed_until: Some(1_000), ..Default::default() };
|
||||
assert_eq!(decide(&s, "2.0.0", 999, false), Offer::Snoozed);
|
||||
assert_eq!(decide(&s, "2.0.0", 1_000, false), Offer::Show);
|
||||
assert_eq!(decide(&s, "2.1.0", 10, false), Offer::Show);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interval_is_clamped() {
|
||||
let low = UpdaterSettings { check_interval_minutes: 0, ..Default::default() }.sanitized();
|
||||
let high = UpdaterSettings { check_interval_minutes: u32::MAX, ..Default::default() }.sanitized();
|
||||
assert_eq!(low.check_interval_minutes, MIN_INTERVAL_MINUTES);
|
||||
assert_eq!(high.check_interval_minutes, MAX_INTERVAL_MINUTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_classified() {
|
||||
assert_eq!(classify_error("error sending request: invalid peer certificate: UnknownIssuer"), ErrorKind::Certificate);
|
||||
assert_eq!(classify_error("SEC_E_UNTRUSTED_ROOT (0x80090325)"), ErrorKind::Certificate);
|
||||
assert_eq!(classify_error("Download request failed with status: 404 Not Found"), ErrorKind::NotFound);
|
||||
assert_eq!(classify_error("error sending request for url: dns error"), ErrorKind::Network);
|
||||
assert_eq!(classify_error("minisign: signature verification failed"), ErrorKind::Signature);
|
||||
assert!(friendly_error("unknown ca").contains("Zertifikat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_tolerate_missing_fields() {
|
||||
let s: UpdaterSettings = serde_json::from_str("{\"checkIntervalMinutes\": 60}").unwrap();
|
||||
assert_eq!(s.check_interval_minutes, 60);
|
||||
assert!(s.skipped_version.is_none());
|
||||
let empty: UpdaterSettings = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(empty, UpdaterSettings::default());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user