pub mod winget; use std::fs; use std::process::{Command, Stdio}; use std::os::windows::process::CommandExt; use std::io::{BufRead, BufReader}; use std::path::PathBuf; use tokio::sync::mpsc; use tauri::{AppHandle, Manager, State, Emitter}; use serde::{Serialize, Deserialize}; use winget::{Software, list_all_software, list_updates, ensure_winget_dependencies}; use regex::Regex; #[derive(Clone, Serialize, Deserialize)] pub struct AppSettings { pub repo_url: String, } #[derive(Clone, Serialize, Deserialize)] pub struct EssentialsRepo { pub version: String, pub essentials: Vec, } impl Default for AppSettings { fn default() -> Self { Self { repo_url: "https://karlblue.github.io/winget-repo".to_string(), } } } struct AppState { install_tx: mpsc::Sender, } #[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, 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(), status: status.to_string(), }); } fn get_settings_path(app: &AppHandle) -> PathBuf { let app_data_dir = app.path().app_data_dir().unwrap_or_default(); if !app_data_dir.exists() { let _ = fs::create_dir_all(&app_data_dir); } app_data_dir.join("settings.json") } fn get_essentials_path(app: &AppHandle) -> PathBuf { let app_data_dir = app.path().app_data_dir().unwrap_or_default(); if !app_data_dir.exists() { let _ = fs::create_dir_all(&app_data_dir); } app_data_dir.join("setup-essentials.json") } #[tauri::command] fn get_settings(app: AppHandle) -> AppSettings { let path = get_settings_path(&app); if !path.exists() { let default_settings = AppSettings::default(); let _ = fs::write(&path, serde_json::to_string_pretty(&default_settings).unwrap()); return default_settings; } let content = fs::read_to_string(path).unwrap_or_default(); serde_json::from_str(&content).unwrap_or_default() } #[tauri::command] fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> { let path = get_settings_path(&app); let content = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?; fs::write(path, content).map_err(|e| e.to_string()) } #[tauri::command] 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('/')); emit_log(&app, "sync-essentials", "Syncing Essentials", &format!("Downloading from {}...", url), "info"); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| e.to_string())?; match client.get(&url).send().await { Ok(response) => { if response.status().is_success() { let content = response.text().await.map_err(|e| e.to_string())?; // 验证 JSON 格式(新格式:{ version: string, essentials: Vec }) 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())?; emit_log(&app, "sync-essentials", "Result", "Essentials list updated successfully.", "success"); Ok(true) } else { emit_log(&app, "sync-essentials", "Error", "Invalid JSON format from repository. Expected { version, essentials }.", "error"); Err("Invalid JSON format".to_string()) } } else { let err_msg = format!("HTTP Error: {}", response.status()); emit_log(&app, "sync-essentials", "Error", &err_msg, "error"); Err(err_msg) } } Err(e) => { emit_log(&app, "sync-essentials", "Skipped", &format!("Network issue: {}. Using local cache.", e), "info"); Ok(false) } } } #[tauri::command] async fn initialize_app(app: AppHandle) -> Result { let app_clone = app.clone(); tokio::task::spawn_blocking(move || { ensure_winget_dependencies(&app_clone).map(|_| true) }).await.unwrap_or(Err("Initialization Task Panicked".to_string())) } #[tauri::command] fn get_essentials(app: AppHandle) -> Option { let file_path = get_essentials_path(&app); if !file_path.exists() { return None; } let content = fs::read_to_string(file_path).unwrap_or_default(); serde_json::from_str(&content).ok() } #[tauri::command] 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(app: AppHandle) -> Vec { tokio::task::spawn_blocking(move || list_updates(&app)).await.unwrap_or_default() } #[tauri::command] async fn install_software(id: String, state: State<'_, AppState>) -> Result<(), String> { 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, status: String, progress: f32, } pub fn run() { tauri::Builder::default() .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 }); 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.0, }); emit_log(&handle, &log_id, &format!("Winget Install: {}", id), "Starting...", "info"); let id_for_cmd = id.clone(); let h = handle.clone(); 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(); 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 && 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 exit_status { "success" } else { "error" } }, Err(e) => { emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error"); "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" }); } }); Ok(()) }) .invoke_handler(tauri::generate_handler![ initialize_app, get_settings, save_settings, sync_essentials, get_essentials, get_all_software, get_updates, install_software, get_logs_history ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }