From fe86431899257ec50db36d8e8ddaea292b6e77c0 Mon Sep 17 00:00:00 2001 From: Julian Freeman Date: Sat, 18 Apr 2026 15:27:00 -0400 Subject: [PATCH] op1 --- src-tauri/.gitignore | 1 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 163 +++++++++++++++++++++++---------- src-tauri/src/winget.rs | 197 +++++++++++++++++++++++++--------------- src/store/software.ts | 21 ++++- src/views/Settings.vue | 4 +- 7 files changed, 266 insertions(+), 123 deletions(-) diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index b21bd68..5467e21 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -1,6 +1,7 @@ # Generated by Cargo # will have compiled files and executables /target/ +/target*/ # Generated by Tauri # will have schema files for capabilities auto-completion diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1da46d9..458da3c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5108,6 +5108,7 @@ dependencies = [ name = "win-softmgr" version = "0.1.0" dependencies = [ + "base64 0.22.1", "chrono", "regex", "reqwest 0.12.28", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 43e6b49..b7aefc5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,4 +27,4 @@ chrono = "0.4.44" regex = "1.12.3" reqwest = { version = "0.12", features = ["json", "rustls-tls"] } winreg = { version = "0.56.0", features = ["serde"] } - +base64 = "0.22.1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fdc404e..86b7008 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,14 +1,16 @@ pub mod winget; use std::fs; +use std::io::Read; use std::process::{Command, Stdio}; use std::os::windows::process::CommandExt; use std::io::{BufRead, BufReader}; use std::path::PathBuf; +use std::thread; use tokio::sync::mpsc; use tauri::{AppHandle, Manager, State, Emitter}; use serde::{Serialize, Deserialize}; -use winget::{Software, list_installed_software, list_updates, ensure_winget_dependencies, PostInstallStep}; +use winget::{Software, list_installed_software, list_updates, ensure_winget_dependencies, PostInstallStep, get_cached_or_extract_icon}; use regex::Regex; use winreg::RegKey; use winreg::enums::*; @@ -49,6 +51,12 @@ struct AppState { install_tx: mpsc::Sender, } +#[derive(Clone, Serialize, Deserialize)] +pub struct SyncEssentialsResult { + pub status: String, + pub message: String, +} + #[derive(Clone, Serialize)] pub struct LogPayload { pub id: String, @@ -105,9 +113,10 @@ fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> { } #[tauri::command] -async fn sync_essentials(app: AppHandle) -> Result { +async fn sync_essentials(app: AppHandle) -> Result { let settings = get_settings(app.clone()); let url = format!("{}/setup-essentials.json", settings.repo_url.trim_end_matches('/')); + let cache_path = get_essentials_path(&app); emit_log(&app, "sync-essentials", "Syncing Essentials", &format!("Downloading from {}...", url), "info"); @@ -122,10 +131,12 @@ async fn sync_essentials(app: AppHandle) -> Result { let content = response.text().await.map_err(|e| e.to_string())?; let validation: Result = serde_json::from_str(&content); if validation.is_ok() { - let path = get_essentials_path(&app); - fs::write(path, content).map_err(|e| e.to_string())?; + fs::write(cache_path, content).map_err(|e| e.to_string())?; emit_log(&app, "sync-essentials", "Result", "Essentials list updated successfully.", "success"); - Ok(true) + Ok(SyncEssentialsResult { + status: "updated".to_string(), + message: "清单同步成功".to_string(), + }) } else { emit_log(&app, "sync-essentials", "Error", "Invalid JSON format from repository. Expected { version, essentials }.", "error"); Err("Invalid JSON format".to_string()) @@ -137,8 +148,17 @@ async fn sync_essentials(app: AppHandle) -> Result { } } Err(e) => { - emit_log(&app, "sync-essentials", "Skipped", &format!("Network issue: {}. Using local cache.", e), "info"); - Ok(false) + if cache_path.exists() { + emit_log(&app, "sync-essentials", "Skipped", &format!("Network issue: {}. Using local cache.", e), "info"); + Ok(SyncEssentialsResult { + status: "cache_used".to_string(), + message: "网络不可用,已继续使用本地缓存".to_string(), + }) + } else { + let err_msg = format!("Network issue: {}", e); + emit_log(&app, "sync-essentials", "Error", &err_msg, "error"); + Err(err_msg) + } } } } @@ -172,6 +192,11 @@ async fn get_updates(app: AppHandle) -> Vec { tokio::task::spawn_blocking(move || list_updates(&app)).await.unwrap_or_default() } +#[tauri::command] +async fn get_software_icon(app: AppHandle, id: String, name: String) -> Option { + tokio::task::spawn_blocking(move || get_cached_or_extract_icon(&app, &id, &name)).await.unwrap_or(None) +} + #[tauri::command] async fn install_software( task: InstallTask, @@ -190,6 +215,60 @@ fn get_logs_history() -> Vec { vec![] } +fn spawn_install_stream_reader( + reader: R, + handle: AppHandle, + log_id: String, + task_id: String, + stream_name: &'static str, + perc_re: Regex, + size_re: Regex, +) -> thread::JoinHandle<()> { + thread::spawn(move || { + let reader = BufReader::new(reader); + for line_res in reader.split(b'\r') { + if let Ok(line_bytes) = line_res { + let line_str = String::from_utf8_lossy(&line_bytes).to_string(); + let clean_line = line_str.trim(); + if clean_line.is_empty() { + continue; + } + + if stream_name == "stdout" { + let mut is_progress = false; + if let Some(caps) = perc_re.captures(clean_line) { + if let Ok(p_val) = caps[1].parse::() { + let _ = handle.emit("install-status", InstallProgress { + id: task_id.clone(), + status: "installing".to_string(), + progress: p_val / 100.0, + }); + is_progress = true; + } + } else if let Some(caps) = size_re.captures(clean_line) { + let current = caps[1].parse::().unwrap_or(0.0); + let total = caps[2].parse::().unwrap_or(1.0); + if total > 0.0 { + let _ = handle.emit("install-status", InstallProgress { + id: task_id.clone(), + status: "installing".to_string(), + progress: (current / total).min(1.0), + }); + is_progress = true; + } + } + + if !is_progress && clean_line.chars().count() > 1 { + emit_log(&handle, &log_id, "", clean_line, "info"); + } + } else { + emit_log(&handle, &log_id, stream_name, clean_line, "error"); + } + } + } + }) +} + fn expand_win_path(path: &str) -> PathBuf { let mut expanded = path.to_string(); let env_vars = [ @@ -442,9 +521,6 @@ pub fn run() { let full_command = format!("winget {}", args.join(" ")); emit_log(&handle, &log_id, &display_cmd, &format!("Executing: {}\n---", full_command), "info"); - let h = handle.clone(); - let current_id = task_id.clone(); - let child = Command::new("winget") .args(&args) .stdout(Stdio::piped()) @@ -454,44 +530,36 @@ pub fn run() { let status_result = match child { Ok(mut child_proc) => { - if let Some(stdout) = child_proc.stdout.take() { - let reader = BufReader::new(stdout); - for line_res in reader.split(b'\r') { - if let Ok(line_bytes) = line_res { - let line_str = String::from_utf8_lossy(&line_bytes).to_string(); - let clean_line = line_str.trim(); - if clean_line.is_empty() { continue; } + let stdout_handle = child_proc.stdout.take().map(|stdout| { + spawn_install_stream_reader( + stdout, + handle.clone(), + log_id.clone(), + task_id.clone(), + "stdout", + perc_re.clone(), + size_re.clone(), + ) + }); + let stderr_handle = child_proc.stderr.take().map(|stderr| { + spawn_install_stream_reader( + stderr, + handle.clone(), + log_id.clone(), + task_id.clone(), + "stderr", + perc_re.clone(), + size_re.clone(), + ) + }); - let mut is_progress = false; - if let Some(caps) = perc_re.captures(clean_line) { - if let Ok(p_val) = caps[1].parse::() { - let _ = h.emit("install-status", InstallProgress { - id: current_id.clone(), - status: "installing".to_string(), - progress: p_val / 100.0, - }); - is_progress = true; - } - } else if let Some(caps) = size_re.captures(clean_line) { - let current = caps[1].parse::().unwrap_or(0.0); - let total = caps[2].parse::().unwrap_or(1.0); - if total > 0.0 { - let _ = h.emit("install-status", InstallProgress { - id: current_id.clone(), - status: "installing".to_string(), - progress: (current / total).min(1.0), - }); - is_progress = true; - } - } - - if !is_progress && clean_line.chars().count() > 1 { - emit_log(&h, &log_id, "", clean_line, "info"); - } - } - } - } let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false); + if let Some(join_handle) = stdout_handle { + let _ = join_handle.join(); + } + if let Some(join_handle) = stderr_handle { + let _ = join_handle.join(); + } let status_result = if exit_status { "success" } else { "error" }; if status_result == "success" && enable_post_install_flag { @@ -546,7 +614,7 @@ pub fn run() { status_result }, Err(e) => { - emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error"); + emit_log(&handle, &log_id, "Fatal Error", &e.to_string(), "error"); "error" } }; @@ -574,6 +642,7 @@ pub fn run() { get_essentials, get_installed_software, get_updates, + get_software_icon, get_software_info, install_software, get_logs_history diff --git a/src-tauri/src/winget.rs b/src-tauri/src/winget.rs index d2ae47e..d29db5d 100644 --- a/src-tauri/src/winget.rs +++ b/src-tauri/src/winget.rs @@ -1,8 +1,11 @@ use serde::{Deserialize, Serialize}; +use base64::Engine; +use std::fs; use std::process::Command; use std::os::windows::process::CommandExt; use std::collections::HashMap; -use tauri::AppHandle; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; use crate::emit_log; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -176,84 +179,18 @@ pub fn list_updates(handle: &AppHandle) -> Vec { let script = r#" $OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $ErrorActionPreference = 'SilentlyContinue' - Add-Type -AssemblyName System.Drawing Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue $pkgs = Get-WinGetPackage -ErrorAction SilentlyContinue | Where-Object { $_.IsUpdateAvailable } if ($pkgs) { - $wshShell = New-Object -ComObject WScript.Shell - - # 预加载开始菜单快捷方式 - $startMenuPaths = @( - "$env:ProgramData\Microsoft\Windows\Start Menu\Programs", - "$env:AppData\Microsoft\Windows\Start Menu\Programs" - ) - $lnkFiles = Get-ChildItem -Path $startMenuPaths -Filter "*.lnk" -Recurse -File - - # 预加载注册表项 - $registryPaths = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) - $regItems = Get-ItemProperty $registryPaths - $pkgs | ForEach-Object { - $p = $_ - $iconUrl = $null - $foundPath = "" - - # 策略 1: 寻找并解析开始菜单快捷方式 (去除箭头关键点) - $matchedLnk = $lnkFiles | Where-Object { $_.BaseName -eq $p.Name -or $p.Name -like "*$($_.BaseName)*" } | Select-Object -First 1 - if ($matchedLnk) { - try { - # 解析快捷方式指向的真实目标 - $target = $wshShell.CreateShortcut($matchedLnk.FullName).TargetPath - if ($target -and (Test-Path $target) -and $target.EndsWith(".exe")) { - $foundPath = $target - } else { - # 如果目标不可读或是 UWP 快捷方式,仍使用快捷方式文件提取 - $foundPath = $matchedLnk.FullName - } - } catch { - $foundPath = $matchedLnk.FullName - } - } - - # 策略 2: 注册表 DisplayIcon - if (-not $foundPath) { - $matchedReg = $regItems | Where-Object { $_.DisplayName -eq $p.Name -or $_.PSChildName -eq $p.Id } | Select-Object -First 1 - if ($matchedReg.DisplayIcon) { - $foundPath = $matchedReg.DisplayIcon.Split(',')[0].Trim('"') - } elseif ($matchedReg.InstallLocation) { - $loc = $matchedReg.InstallLocation.Trim('"') - if (Test-Path $loc) { - $exe = Get-ChildItem -Path $loc -Filter "*.exe" -File | Select-Object -First 1 - if ($exe) { $foundPath = $exe.FullName } - } - } - } - - # 提取并转 Base64 - if ($foundPath -and (Test-Path $foundPath)) { - try { - $icon = [System.Drawing.Icon]::ExtractAssociatedIcon($foundPath) - $bitmap = $icon.ToBitmap() - $ms = New-Object System.IO.MemoryStream - $bitmap.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) - $base64 = [Convert]::ToBase64String($ms.ToArray()) - $iconUrl = "data:image/png;base64,$base64" - $ms.Dispose(); $bitmap.Dispose(); $icon.Dispose() - } catch {} - } - [PSCustomObject]@{ - Name = [string]$p.Name; - Id = [string]$p.Id; - InstalledVersion = [string]$p.InstalledVersion; - AvailableVersions = $p.AvailableVersions; - IconUrl = $iconUrl + Name = [string]$_.Name; + Id = [string]$_.Id; + InstalledVersion = [string]$_.InstalledVersion; + AvailableVersions = $_.AvailableVersions; + IconUrl = $null } } | ConvertTo-Json -Compress } else { @@ -285,6 +222,111 @@ pub fn get_software_info(handle: &AppHandle, id: &str) -> Option { res.into_iter().next() } +pub fn get_cached_or_extract_icon(handle: &AppHandle, id: &str, name: &str) -> Option { + let cache_key = sanitize_cache_key(id); + let icon_dir = get_icon_cache_dir(handle); + let icon_path = icon_dir.join(format!("{}.png", cache_key)); + + if let Ok(bytes) = fs::read(&icon_path) { + return Some(format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + )); + } + + let log_id = format!("icon-{}", cache_key); + emit_log(handle, &log_id, "Icon Lookup", &format!("Resolving icon for {}...", id), "info"); + + let script = format!(r#" + $OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $ErrorActionPreference = 'SilentlyContinue' + Add-Type -AssemblyName System.Drawing + Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue + + $packageId = @' +{id} +'@ + $packageName = @' +{name} +'@ + + $foundPath = "" + $wshShell = New-Object -ComObject WScript.Shell + $startMenuPaths = @( + "$env:ProgramData\Microsoft\Windows\Start Menu\Programs", + "$env:AppData\Microsoft\Windows\Start Menu\Programs" + ) + + $lnkFiles = Get-ChildItem -Path $startMenuPaths -Filter "*.lnk" -Recurse -File + $matchedLnk = $lnkFiles | Where-Object {{ $_.BaseName -eq $packageName -or $packageName -like "*$($_.BaseName)*" }} | Select-Object -First 1 + if ($matchedLnk) {{ + try {{ + $target = $wshShell.CreateShortcut($matchedLnk.FullName).TargetPath + if ($target -and (Test-Path $target) -and $target.EndsWith(".exe")) {{ + $foundPath = $target + }} else {{ + $foundPath = $matchedLnk.FullName + }} + }} catch {{ + $foundPath = $matchedLnk.FullName + }} + }} + + if (-not $foundPath) {{ + $registryPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + $regItems = Get-ItemProperty $registryPaths + $matchedReg = $regItems | Where-Object {{ $_.DisplayName -eq $packageName -or $_.PSChildName -eq $packageId }} | Select-Object -First 1 + if ($matchedReg.DisplayIcon) {{ + $foundPath = $matchedReg.DisplayIcon.Split(',')[0].Trim('"') + }} elseif ($matchedReg.InstallLocation) {{ + $loc = $matchedReg.InstallLocation.Trim('"') + if (Test-Path $loc) {{ + $exe = Get-ChildItem -Path $loc -Filter "*.exe" -File | Select-Object -First 1 + if ($exe) {{ $foundPath = $exe.FullName }} + }} + }} + }} + + if ($foundPath -and (Test-Path $foundPath)) {{ + try {{ + $icon = [System.Drawing.Icon]::ExtractAssociatedIcon($foundPath) + if ($icon) {{ + $bitmap = $icon.ToBitmap() + $ms = New-Object System.IO.MemoryStream + $bitmap.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + [Convert]::ToBase64String($ms.ToArray()) + $ms.Dispose() + $bitmap.Dispose() + $icon.Dispose() + }} + }} catch {{}} + }} + "#, id = id, name = name); + + let output = Command::new("powershell") + .args(["-NoProfile", "-Command", &script]) + .creation_flags(0x08000000) + .output() + .ok()?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let encoded = stdout.trim_start_matches('\u{feff}').trim(); + if encoded.is_empty() { + return None; + } + + let bytes = base64::engine::general_purpose::STANDARD.decode(encoded).ok()?; + if fs::create_dir_all(&icon_dir).is_err() { + return Some(format!("data:image/png;base64,{}", encoded)); + } + let _ = fs::write(&icon_path, &bytes); + Some(format!("data:image/png;base64,{}", encoded)) +} + fn execute_powershell(handle: &AppHandle, log_id: &str, cmd_title: &str, script: &str) -> Vec { emit_log(handle, log_id, cmd_title, "Fetching data from Winget...", "info"); @@ -329,6 +371,17 @@ fn parse_json_output(json_str: String) -> Vec { vec![] } +fn get_icon_cache_dir(handle: &AppHandle) -> PathBuf { + let app_data_dir = handle.path().app_data_dir().unwrap_or_default(); + app_data_dir.join("icons") +} + +fn sanitize_cache_key(id: &str) -> String { + id.chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect() +} + fn map_package(p: WingetPackage) -> Software { Software { id: p.id, diff --git a/src/store/software.ts b/src/store/software.ts index 071fe1d..916fe51 100644 --- a/src/store/software.ts +++ b/src/store/software.ts @@ -29,6 +29,11 @@ export interface LogEntry { status: 'info' | 'success' | 'error'; } +interface SyncEssentialsResult { + status: 'updated' | 'cache_used'; + message: string; +} + export const useSoftwareStore = defineStore('software', { state: () => ({ essentials: [] as any[], @@ -140,8 +145,9 @@ export const useSoftwareStore = defineStore('software', { async syncEssentials() { this.loading = true; try { - await invoke('sync_essentials'); + const result = await invoke('sync_essentials') as SyncEssentialsResult; await this.fetchEssentials(); + return result; } finally { this.loading = false; } @@ -200,6 +206,7 @@ export const useSoftwareStore = defineStore('software', { try { const res = await invoke('get_updates') this.updates = res as any[] + await this.loadIconsForUpdates() if (this.selectedUpdateIds.length === 0) this.selectAll('update'); } finally { this.loading = false @@ -225,6 +232,7 @@ export const useSoftwareStore = defineStore('software', { ]); this.allSoftware = all as any[]; this.updates = updates as any[]; + await this.loadIconsForUpdates() this.lastFetched = Date.now(); if (this.selectedEssentialIds.length === 0) this.selectAll('essential'); } finally { @@ -284,6 +292,17 @@ export const useSoftwareStore = defineStore('software', { this.updates.find(s => s.id === id) || this.allSoftware.find(s => s.id === id) }, + async loadIconsForUpdates() { + const targets = this.updates.filter(item => !item.icon_url && item.id && item.name); + await Promise.allSettled(targets.map(async (item) => { + const iconUrl = await invoke('get_software_icon', { id: item.id, name: item.name }) as string | null; + if (!iconUrl) return; + const target = this.updates.find(update => update.id === item.id); + if (target) { + target.icon_url = iconUrl; + } + })); + }, initListener() { if ((window as any).__tauri_listener_init) return; (window as any).__tauri_listener_init = true; diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 7a1d9af..f803705 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -107,8 +107,8 @@ const handleSave = async () => { const handleSync = async () => { try { - await store.syncEssentials() - showToast('清单同步成功') + const result = await store.syncEssentials() + showToast(result.message, result.status === 'updated' ? 'success' : 'error') } catch (err) { showToast('同步失败,请检查网络或地址', 'error') }