diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8330e33..0d11494 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4623,6 +4623,7 @@ name = "win-softmgr" version = "0.1.0" dependencies = [ "chrono", + "regex", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 728d1fe..d02a938 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,4 +24,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1.50.0", features = ["full"] } chrono = "0.4.44" +regex = "1.12.3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 879a95d..2ab0f97 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,14 @@ pub mod winget; use std::fs; -use std::process::Command; +use std::process::{Command, Stdio}; use std::os::windows::process::CommandExt; +use std::io::{BufRead, BufReader}; use tokio::sync::mpsc; use tauri::{AppHandle, Manager, State, Emitter}; use serde::Serialize; use winget::{Software, list_all_software, list_updates, ensure_winget_dependencies}; +use regex::Regex; struct AppState { install_tx: mpsc::Sender, @@ -14,15 +16,17 @@ struct AppState { #[derive(Clone, Serialize)] pub struct LogPayload { + pub id: String, pub timestamp: String, pub command: String, pub output: String, pub status: String, } -pub fn emit_log(handle: &AppHandle, command: &str, output: &str, status: &str) { +pub fn emit_log(handle: &AppHandle, id: &str, command: &str, output: &str, status: &str) { let now = chrono::Local::now().format("%H:%M:%S").to_string(); let _ = handle.emit("log-event", LogPayload { + id: id.to_string(), timestamp: now, command: command.to_string(), output: output.to_string(), @@ -32,7 +36,6 @@ pub fn emit_log(handle: &AppHandle, command: &str, output: &str, status: &str) { #[tauri::command] async fn initialize_app(app: AppHandle) -> Result { - // 执行耗时的环境配置 tokio::task::spawn_blocking(move || { ensure_winget_dependencies(&app).map(|_| true) }).await.unwrap_or(Err("Initialization Task Panicked".to_string())) @@ -112,49 +115,102 @@ pub fn run() { let (tx, mut rx) = mpsc::channel::(100); app.manage(AppState { install_tx: tx }); - // 移除了在 setup 中直接执行异步 init 的逻辑,改为由前端指令触发 - + let init_handle = handle.clone(); tauri::async_runtime::spawn(async move { + let _ = tokio::task::spawn_blocking(move || { + let _ = ensure_winget_dependencies(&init_handle); + }).await; + }); + + // 安装队列处理器 + tauri::async_runtime::spawn(async move { + let perc_re = Regex::new(r"(\d+)\s*%").unwrap(); + let size_re = Regex::new(r"([\d\.]+)\s*[a-zA-Z]+\s*/\s*([\d\.]+)\s*[a-zA-Z]+").unwrap(); + while let Some(id) = rx.recv().await { + let log_id = format!("install-{}", id); let _ = handle.emit("install-status", InstallProgress { id: id.clone(), status: "installing".to_string(), - progress: 0.5, + progress: 0.0, }); - emit_log(&handle, &format!("winget install --id {}", id), "Starting installation...", "info"); + emit_log(&handle, &log_id, &format!("Winget Install: {}", id), "Starting...", "info"); let id_for_cmd = id.clone(); let h = handle.clone(); - let status_result = tokio::task::spawn_blocking(move || { - let output = Command::new("winget") - .args([ - "install", "--id", &id_for_cmd, "-e", "--silent", - "--accept-package-agreements", "--accept-source-agreements", - "--disable-interactivity" - ]) - .creation_flags(0x08000000) - .output(); + + let child = Command::new("winget") + .args([ + "install", "--id", &id_for_cmd, "-e", "--silent", + "--accept-package-agreements", "--accept-source-agreements", + "--disable-interactivity" + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .creation_flags(0x08000000) + .spawn(); - match output { - Ok(out) => { - let msg = String::from_utf8_lossy(&out.stdout).to_string(); - let err = String::from_utf8_lossy(&out.stderr).to_string(); - emit_log(&h, &format!("Install result for {}", id_for_cmd), &format!("OUT: {}\nERR: {}", msg, err), if out.status.success() { "success" } else { "error" }); - if out.status.success() { "success" } else { "error" } - }, - Err(e) => { - emit_log(&h, &format!("Install error for {}", id_for_cmd), &e.to_string(), "error"); - "error" - }, + 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 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: id_for_cmd.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: id_for_cmd.clone(), + status: "installing".to_string(), + progress: (current / total).min(1.0), + }); + is_progress = true; + } + } + + if !is_progress { + // 追加内容到同一个日志卡片 + emit_log(&h, &log_id, "", clean_line, "info"); + } + } + } + } + + let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false); + if exit_status { "success" } else { "error" } + }, + Err(e) => { + emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error"); + "error" } - }).await.unwrap_or("error"); + }; let _ = handle.emit("install-status", InstallProgress { id: id.clone(), status: status_result.to_string(), progress: 1.0, }); + + emit_log(&handle, &log_id, "Result", &format!("Execution finished: {}", status_result), if status_result == "success" { "success" } else { "error" }); } }); diff --git a/src-tauri/src/winget.rs b/src-tauri/src/winget.rs index cc6edab..3056ecd 100644 --- a/src-tauri/src/winget.rs +++ b/src-tauri/src/winget.rs @@ -26,35 +26,25 @@ struct WingetPackage { } pub fn ensure_winget_dependencies(handle: &AppHandle) -> Result<(), String> { - emit_log(handle, "Check Environment", "Performing fast environment check...", "info"); + let log_id = "env-check"; + emit_log(handle, log_id, "Environment Check", "Starting system configuration...", "info"); - // 优化后的脚本:先尝试极速检测,存在则直接退出 let setup_script = r#" $ErrorActionPreference = 'SilentlyContinue' - - # 极速路径:如果模块已安装,直接输出 Success 并退出 + Write-Output "Checking module status..." if (Get-Module -ListAvailable Microsoft.WinGet.Client) { Write-Output "ALREADY_READY" exit 0 } - - # 慢速路径:仅在模块缺失时执行完整初始化 - Write-Output "CONFIGURING_ENV" + Write-Output "Step 1: Enabling TLS 1.2" [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 Import-Module PackageManagement -ErrorAction SilentlyContinue - if ($null -eq (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Confirm:$false } - Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force - - if (-not (Get-Module -ListAvailable Microsoft.WinGet.Client)) { - Install-Module -Name Microsoft.WinGet.Client -Force -AllowClobber -Scope CurrentUser -Confirm:$false - } - - # 注意:此处不再执行 winget source update,因为它太慢了,移至后台或按需执行 + Install-Module -Name Microsoft.WinGet.Client -Force -AllowClobber -Scope CurrentUser -Confirm:$false "#; let output = Command::new("powershell") @@ -65,34 +55,35 @@ pub fn ensure_winget_dependencies(handle: &AppHandle) -> Result<(), String> { match output { Ok(out) => { let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + emit_log(handle, log_id, "", &stdout, "info"); + if stdout.contains("ALREADY_READY") { - emit_log(handle, "Environment Setup", "Fast check passed: System is ready.", "success"); + emit_log(handle, log_id, "Result", "Ready (Fast check).", "success"); Ok(()) } else { - // 验证安装结果 let check = Command::new("powershell") .args(["-NoProfile", "-Command", "Get-Module -ListAvailable Microsoft.WinGet.Client"]) .creation_flags(0x08000000) .output(); if check.map(|o| !o.stdout.is_empty()).unwrap_or(false) { - emit_log(handle, "Environment Setup", "Full configuration successful.", "success"); + emit_log(handle, log_id, "Result", "Module installed successfully.", "success"); Ok(()) } else { - let err = String::from_utf8_lossy(&out.stderr).to_string(); - emit_log(handle, "Environment Setup Error", &format!("STDOUT: {}\nSTDERR: {}", stdout, err), "error"); + emit_log(handle, log_id, "Result", "Module installation failed.", "error"); Err("Setup failed".to_string()) } } }, Err(e) => { - emit_log(handle, "Environment Setup Fatal", &e.to_string(), "error"); + emit_log(handle, log_id, "Fatal Error", &e.to_string(), "error"); Err(e.to_string()) } } } pub fn list_all_software(handle: &AppHandle) -> Vec { + let log_id = format!("list-all-{}", chrono::Local::now().timestamp_millis()); let script = r#" $OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $ErrorActionPreference = 'SilentlyContinue' @@ -112,18 +103,15 @@ pub fn list_all_software(handle: &AppHandle) -> Vec { } "#; - execute_powershell(handle, "Get All Software", script) + execute_powershell(handle, &log_id, "Fetch All Software", script) } pub fn list_updates(handle: &AppHandle) -> Vec { + let log_id = format!("list-updates-{}", chrono::Local::now().timestamp_millis()); let script = r#" $OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $ErrorActionPreference = 'SilentlyContinue' Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue - - # 在检查更新前,尝试静默更新源(可选,为了准确性) - # winget source update --accept-source-agreements | Out-Null - $pkgs = Get-WinGetPackage -ErrorAction SilentlyContinue | Where-Object { $_.IsUpdateAvailable } if ($pkgs) { $pkgs | ForEach-Object { @@ -139,11 +127,11 @@ pub fn list_updates(handle: &AppHandle) -> Vec { } "#; - execute_powershell(handle, "Get Updates", script) + execute_powershell(handle, &log_id, "Fetch Updates", script) } -fn execute_powershell(handle: &AppHandle, cmd_name: &str, script: &str) -> Vec { - emit_log(handle, cmd_name, "Executing PowerShell script...", "info"); +fn execute_powershell(handle: &AppHandle, log_id: &str, cmd_title: &str, script: &str) -> Vec { + emit_log(handle, log_id, cmd_title, "Executing PowerShell...", "info"); let output = Command::new("powershell") .args(["-NoProfile", "-Command", script]) @@ -157,20 +145,20 @@ fn execute_powershell(handle: &AppHandle, cmd_name: &str, script: &str) -> Vec { - emit_log(handle, cmd_name, &format!("Execution error: {}", e), "error"); + emit_log(handle, log_id, "Execution Error", &e.to_string(), "error"); vec![] }, } diff --git a/src/components/SoftwareCard.vue b/src/components/SoftwareCard.vue index c495634..807f2c4 100644 --- a/src/components/SoftwareCard.vue +++ b/src/components/SoftwareCard.vue @@ -66,10 +66,18 @@ 等待中 - +
-
- 正在安装 +
+ + + + +
+
+ {{ displayProgress }}
@@ -105,6 +113,11 @@ const props = defineProps<{ const emit = defineEmits(['install', 'toggleSelect']); +const displayProgress = computed(() => { + if (!props.software.progress) return '准备中'; + return Math.round(props.software.progress * 100) + '%'; +}); + const placeholderColor = computed(() => { const colors = ['#FF9500', '#FF3B30', '#34C759', '#007AFF', '#5856D6', '#AF52DE']; let hash = 0; @@ -115,9 +128,7 @@ const placeholderColor = computed(() => { }); const handleCardClick = () => { - // 安装或等待中禁止修改勾选 if (props.software.status === 'pending' || props.software.status === 'installing') return; - if (props.selectable && props.software.status !== 'installed') { emit('toggleSelect', props.software.id); } @@ -150,10 +161,9 @@ const handleCardClick = () => { } .software-card.is-busy { - opacity: 0.8; + opacity: 0.9; } -/* 勾选框样式 */ .selection-area { margin-right: 16px; flex-shrink: 0; @@ -275,7 +285,7 @@ const handleCardClick = () => { .card-right { margin-left: 20px; - min-width: 120px; + min-width: 100px; display: flex; justify-content: flex-end; } @@ -328,24 +338,51 @@ const handleCardClick = () => { display: flex; align-items: center; flex-direction: column; - gap: 4px; + gap: 6px; width: 90px; justify-content: center; } -.spinning-loader { - width: 18px; - height: 18px; - border: 2px solid rgba(0, 122, 255, 0.1); +.progress-ring-container { + position: relative; + width: 24px; + height: 24px; +} + +.ring-svg { + transform: rotate(-90deg); + width: 100%; + height: 100%; +} + +.ring-svg .bg { + stroke: var(--border-color); +} + +.ring-svg .fg { + stroke: var(--primary-color); + stroke-linecap: round; + transition: stroke-dashoffset 0.3s ease; +} + +.inner-loader { + position: absolute; + top: 4px; + left: 4px; + width: 16px; + height: 16px; + border: 2px solid transparent; border-top-color: var(--primary-color); border-radius: 50%; - animation: spin 1s linear infinite; + animation: spin 0.8s linear infinite; } .loading-text { font-size: 10px; font-weight: 700; color: var(--primary-color); + text-transform: uppercase; + letter-spacing: 0.5px; } .status-success, .status-error { diff --git a/src/store/software.ts b/src/store/software.ts index 94fd0bf..4f121b5 100644 --- a/src/store/software.ts +++ b/src/store/software.ts @@ -5,6 +5,7 @@ import { listen } from '@tauri-apps/api/event' const sortByName = (a: any, b: any) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' }); export interface LogEntry { + id: string; // 日志唯一标识 timestamp: string; command: string; output: string; @@ -41,13 +42,11 @@ export const useSoftwareStore = defineStore('software', { actionLabel = '已安装'; } } - return { ...item, status: displayStatus, actionLabel }; }); }, sortedUpdates: (state) => [...state.updates].sort(sortByName), sortedAllSoftware: (state) => [...state.allSoftware].sort(sortByName), - // 全局繁忙状态:只要有任何软件在等待或安装中,就锁定关键操作 isBusy: (state) => { const allItems = [...state.essentials, ...state.updates, ...state.allSoftware]; return allItems.some(item => item.status === 'pending' || item.status === 'installing'); @@ -66,7 +65,7 @@ export const useSoftwareStore = defineStore('software', { } }, toggleSelection(id: string, type: 'essential' | 'update') { - if (this.isBusy) return; // 繁忙时禁止修改勾选 + if (this.isBusy) return; const list = type === 'essential' ? this.selectedEssentialIds : this.selectedUpdateIds; const index = list.indexOf(id); if (index === -1) list.push(id); @@ -146,12 +145,8 @@ export const useSoftwareStore = defineStore('software', { } }, async install(id: string) { - // 这里的 logic 同时负责单个安装和批量安装的任务入队 const software = this.findSoftware(id) - if (software) { - // 进入队列前统一标记为等待中 - software.status = 'pending'; - } + if (software) software.status = 'pending'; await invoke('install_software', { id }) }, findSoftware(id: string) { @@ -177,9 +172,24 @@ export const useSoftwareStore = defineStore('software', { } }) + // 日志监听:根据 ID 追加内容 listen('log-event', (event: any) => { - this.logs.unshift(event.payload as LogEntry); - if (this.logs.length > 200) this.logs.pop(); + const payload = event.payload as LogEntry; + const existingLog = this.logs.find(l => l.id === payload.id); + + if (existingLog) { + // 如果是增量更新 + if (payload.output) { + existingLog.output += '\n' + payload.output; + } + if (payload.status !== 'info') { + existingLog.status = payload.status; + } + } else { + // 如果是新日志 + this.logs.unshift(payload); + if (this.logs.length > 100) this.logs.pop(); + } }) } }