diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fa737ba..8330e33 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -451,8 +451,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -4620,6 +4622,7 @@ dependencies = [ name = "win-softmgr" version = "0.1.0" dependencies = [ + "chrono", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9e24809..728d1fe 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,4 +23,5 @@ tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1.50.0", features = ["full"] } +chrono = "0.4.44" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 79c11fe..cc0f3c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,24 @@ struct AppState { install_tx: mpsc::Sender, } +#[derive(Clone, Serialize)] +pub struct LogPayload { + pub timestamp: String, + pub command: String, + pub output: String, + pub status: String, // "info", "success", "error" +} + +pub fn emit_log(handle: &AppHandle, command: &str, output: &str, status: &str) { + let now = chrono::Local::now().format("%H:%M:%S").to_string(); + let _ = handle.emit("log-event", LogPayload { + timestamp: now, + command: command.to_string(), + output: output.to_string(), + status: status.to_string(), + }); +} + #[tauri::command] fn get_essentials(app: AppHandle) -> Vec { let app_data_dir = app.path().app_data_dir().unwrap_or_default(); @@ -28,12 +46,12 @@ fn get_essentials(app: AppHandle) -> Vec { description: Some("Microsoft PowerToys 是一组实用程序,供高级用户调整和简化其 Windows 10 和 11 体验。".to_string()), version: None, available_version: None, - icon_url: Some("https://raw.githubusercontent.com/microsoft/PowerToys/main/doc/images/icons/PowerToys icon/PNG/PowerToysAppList.targetsize-48.png".to_string()), + icon_url: Some("https://raw.githubusercontent.com/microsoft/PowerToys/master/doc/images/logo.png".to_string()), status: "idle".to_string(), progress: 0.0, }, Software { - id: "Google.Chrome.EXE".to_string(), + id: "Google.Chrome".to_string(), name: "Google Chrome".to_string(), description: Some("Google Chrome 是一款快速、安全且免费的浏览器。".to_string()), version: None, @@ -52,13 +70,13 @@ fn get_essentials(app: AppHandle) -> Vec { } #[tauri::command] -async fn get_all_software() -> Vec { - tokio::task::spawn_blocking(move || list_all_software()).await.unwrap_or_default() +async fn get_all_software(app: AppHandle) -> Vec { + tokio::task::spawn_blocking(move || list_all_software(&app)).await.unwrap_or_default() } #[tauri::command] -async fn get_updates() -> Vec { - tokio::task::spawn_blocking(move || list_updates()).await.unwrap_or_default() +async fn get_updates(app: AppHandle) -> Vec { + tokio::task::spawn_blocking(move || list_updates(&app)).await.unwrap_or_default() } #[tauri::command] @@ -66,6 +84,12 @@ async fn install_software(id: String, state: State<'_, AppState>) -> Result<(), state.install_tx.send(id).await.map_err(|e| e.to_string()) } +#[tauri::command] +fn get_logs_history() -> Vec { + // 暂时返回空,后续可以考虑存储 + vec![] +} + #[derive(Clone, Serialize)] struct InstallProgress { id: String, @@ -78,21 +102,18 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .setup(move |app| { let handle = app.handle().clone(); + let (tx, mut rx) = mpsc::channel::(100); + app.manage(AppState { install_tx: tx }); - // 确保依赖项已安装 (这是一个耗时的过程,建议在异步任务中运行) + // 环境初始化逻辑 + let init_handle = handle.clone(); tauri::async_runtime::spawn(async move { - let _ = tokio::task::spawn_blocking(|| { - let _ = ensure_winget_dependencies(); + let _ = tokio::task::spawn_blocking(move || { + let _ = ensure_winget_dependencies(&init_handle); }).await; }); - // 在 setup 闭包中初始化,此时运行时已就绪 - let (tx, mut rx) = mpsc::channel::(100); - - // 托管状态 - app.manage(AppState { install_tx: tx }); - - // 后台安装队列 + // 安装队列 tauri::async_runtime::spawn(async move { while let Some(id) = rx.recv().await { let _ = handle.emit("install-status", InstallProgress { @@ -101,7 +122,10 @@ pub fn run() { progress: 0.5, }); + emit_log(&handle, &format!("winget install --id {}", id), "Starting installation...", "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([ @@ -113,8 +137,16 @@ pub fn run() { .output(); match output { - Ok(out) if out.status.success() => "success", - _ => "error", + 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" + }, } }).await.unwrap_or("error"); @@ -132,7 +164,8 @@ pub fn run() { get_essentials, get_all_software, get_updates, - install_software + install_software, + get_logs_history ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/winget.rs b/src-tauri/src/winget.rs index eb2e0e5..5cae794 100644 --- a/src-tauri/src/winget.rs +++ b/src-tauri/src/winget.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use std::process::Command; use std::os::windows::process::CommandExt; +use tauri::AppHandle; +use crate::emit_log; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Software { @@ -23,30 +25,60 @@ struct WingetPackage { pub available_versions: Option>, } -pub fn ensure_winget_dependencies() -> Result<(), String> { - // 确保执行权限和模块存在 +pub fn ensure_winget_dependencies(handle: &AppHandle) -> Result<(), String> { + emit_log(handle, "Check Environment", "Checking Winget and Microsoft.WinGet.Client...", "info"); + let setup_script = r#" - $ErrorActionPreference = 'SilentlyContinue' + $ErrorActionPreference = 'Stop' + Write-Output "Step 1: Enabling TLS 1.2" + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + + Write-Output "Step 2: Installing NuGet provider" + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Confirm:$false + + Write-Output "Step 3: Trusting PSGallery" + Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted + + Write-Output "Step 4: Setting Execution Policy" 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 + Write-Output "Step 5: Installing Microsoft.WinGet.Client module" + Install-Module -Name Microsoft.WinGet.Client -Force -AllowClobber -Scope CurrentUser -Confirm:$false + } else { + Write-Output "Step 5: Module already installed" } "#; - let _ = Command::new("powershell") + let output = Command::new("powershell") .args(["-NoProfile", "-Command", setup_script]) .creation_flags(0x08000000) - .status(); + .output(); - Ok(()) + match output { + Ok(out) => { + let msg = String::from_utf8_lossy(&out.stdout).to_string(); + let err = String::from_utf8_lossy(&out.stderr).to_string(); + if out.status.success() { + emit_log(handle, "Environment Setup", &format!("Success: {}", msg), "success"); + Ok(()) + } else { + emit_log(handle, "Environment Setup Error", &format!("OUT: {}\nERR: {}", msg, err), "error"); + Err(format!("Setup failed: {}", err)) + } + }, + Err(e) => { + emit_log(handle, "Environment Setup Fatal", &e.to_string(), "error"); + Err(e.to_string()) + } + } } -pub fn list_all_software() -> Vec { - // 使用更健壮的脚本获取 JSON +pub fn list_all_software(handle: &AppHandle) -> Vec { let script = r#" $OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $ErrorActionPreference = 'SilentlyContinue' - Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue + Import-Module Microsoft.WinGet.Client $pkgs = Get-WinGetPackage if ($pkgs) { $pkgs | ForEach-Object { @@ -62,14 +94,14 @@ pub fn list_all_software() -> Vec { } "#; - execute_powershell(script) + execute_powershell(handle, "Get All Software", script) } -pub fn list_updates() -> Vec { +pub fn list_updates(handle: &AppHandle) -> Vec { let script = r#" $OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $ErrorActionPreference = 'SilentlyContinue' - Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue + Import-Module Microsoft.WinGet.Client $pkgs = Get-WinGetPackage | Where-Object { $_.IsUpdateAvailable } if ($pkgs) { $pkgs | ForEach-Object { @@ -85,10 +117,12 @@ pub fn list_updates() -> Vec { } "#; - execute_powershell(script) + execute_powershell(handle, "Get Updates", script) } -fn execute_powershell(script: &str) -> Vec { +fn execute_powershell(handle: &AppHandle, cmd_name: &str, script: &str) -> Vec { + emit_log(handle, cmd_name, "Executing PowerShell script...", "info"); + let output = Command::new("powershell") .args(["-NoProfile", "-Command", script]) .creation_flags(0x08000000) @@ -97,29 +131,37 @@ fn execute_powershell(script: &str) -> Vec { match output { Ok(out) => { let stdout = String::from_utf8_lossy(&out.stdout); - // 移除可能存在的 UTF-8 BOM + let stderr = String::from_utf8_lossy(&out.stderr); let clean_json = stdout.trim_start_matches('\u{feff}').trim(); - parse_json_output(clean_json.to_string()) + + if !out.status.success() || !stderr.is_empty() { + emit_log(handle, &format!("{} stderr", cmd_name), &stderr, "error"); + } + + if clean_json.is_empty() || clean_json == "[]" { + emit_log(handle, cmd_name, "No software found (Empty JSON)", "info"); + return vec![]; + } + + let result = parse_json_output(clean_json.to_string()); + emit_log(handle, cmd_name, &format!("Success: found {} items", result.len()), "success"); + result + }, + Err(e) => { + emit_log(handle, cmd_name, &format!("Execution error: {}", e), "error"); + vec![] }, - Err(_) => vec![], } } +// 修正:Rust 并没有 .length(),应使用 .len() fn parse_json_output(json_str: String) -> Vec { - if json_str.is_empty() || json_str == "[]" { - return vec![]; - } - - // 尝试解析数组 if let Ok(packages) = serde_json::from_str::>(&json_str) { return packages.into_iter().map(map_package).collect(); } - - // 尝试解析单个对象 if let Ok(package) = serde_json::from_str::(&json_str) { return vec![map_package(package)]; } - vec![] } diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue index dfc4a30..4943bc2 100644 --- a/src/components/Sidebar.vue +++ b/src/components/Sidebar.vue @@ -33,6 +33,20 @@ 全部软件 + + + + + + + + + + + + + 运行日志 + @@ -59,6 +73,7 @@ display: flex; flex-direction: column; gap: 8px; + flex: 1; /* 撑满空间 */ } .nav-item { @@ -90,4 +105,9 @@ align-items: center; justify-content: center; } + +.nav-logs { + margin-top: auto; /* 将日志推到底部 */ + margin-bottom: 20px; +} diff --git a/src/router/index.ts b/src/router/index.ts index 2656c9e..102c119 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -18,6 +18,10 @@ const router = createRouter({ { path: '/all', component: () => import('../views/AllSoftware.vue') + }, + { + path: '/logs', + component: () => import('../views/Logs.vue') } ] }) diff --git a/src/store/software.ts b/src/store/software.ts index 5f27da6..95beb7a 100644 --- a/src/store/software.ts +++ b/src/store/software.ts @@ -4,6 +4,13 @@ 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 { + timestamp: string; + command: string; + output: string; + status: 'info' | 'success' | 'error'; +} + export const useSoftwareStore = defineStore('software', { state: () => ({ essentials: [] as any[], @@ -11,6 +18,7 @@ export const useSoftwareStore = defineStore('software', { allSoftware: [] as any[], selectedEssentialIds: [] as string[], selectedUpdateIds: [] as string[], + logs: [] as LogEntry[], loading: false, lastFetched: 0 }), @@ -39,15 +47,11 @@ export const useSoftwareStore = defineStore('software', { sortedAllSoftware: (state) => [...state.allSoftware].sort(sortByName) }, actions: { - // 通用的勾选切换逻辑 toggleSelection(id: string, type: 'essential' | 'update') { const list = type === 'essential' ? this.selectedEssentialIds : this.selectedUpdateIds; const index = list.indexOf(id); - if (index === -1) { - list.push(id); - } else { - list.splice(index, 1); - } + if (index === -1) list.push(id); + else list.splice(index, 1); }, selectAll(type: 'essential' | 'update') { if (type === 'essential') { @@ -79,7 +83,6 @@ export const useSoftwareStore = defineStore('software', { try { const res = await invoke('get_updates') this.updates = res as any[] - // 刷新数据后,如果没选过,默认全选 if (this.selectedUpdateIds.length === 0) this.selectAll('update'); } finally { this.loading = false @@ -115,7 +118,6 @@ export const useSoftwareStore = defineStore('software', { this.allSoftware = all as any[]; this.updates = updates as any[]; this.lastFetched = Date.now(); - // 如果没选过,默认全选 if (this.selectedEssentialIds.length === 0) this.selectAll('essential'); } finally { this.loading = false; @@ -144,11 +146,17 @@ export const useSoftwareStore = defineStore('software', { } if (status === 'success') { this.lastFetched = 0; - // 安装成功后从选择列表中移除 this.selectedEssentialIds = this.selectedEssentialIds.filter(i => i !== id); this.selectedUpdateIds = this.selectedUpdateIds.filter(i => i !== id); } }) + + // 监听日志事件 + listen('log-event', (event: any) => { + this.logs.unshift(event.payload as LogEntry); + // 限制日志条数,防止内存溢出 + if (this.logs.length > 200) this.logs.pop(); + }) } } }) diff --git a/src/views/Logs.vue b/src/views/Logs.vue new file mode 100644 index 0000000..6958a53 --- /dev/null +++ b/src/views/Logs.vue @@ -0,0 +1,183 @@ + + + + +