//! 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, module: Option, } /// WinGet-Implementierung von [`PackageManager`]. #[derive(Default)] pub struct WingetManager { state: RwLock, } /// Sucht `winget.exe` im PATH und danach in `%LOCALAPPDATA%\Microsoft\WindowsApps`. pub fn locate_winget() -> Option { 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 { 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 { 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 { 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> { 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> { 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> { 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 { 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> { 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()) } }