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_installed_software, list_updates, ensure_winget_dependencies, PostInstallStep}; use regex::Regex; use winreg::RegKey; use winreg::enums::*; #[derive(Clone, Serialize, Deserialize)] pub struct AppSettings { pub repo_url: String, } #[derive(Clone, Serialize, Deserialize)] pub struct EssentialsRepo { pub version: String, pub essentials: Vec, } #[derive(Clone, Serialize, Deserialize)] pub struct InstallTask { pub id: String, pub version: Option, #[serde(default)] pub use_manifest: bool, pub manifest_url: Option, } 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_installed_software(app: AppHandle) -> Vec { tokio::task::spawn_blocking(move || list_installed_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( task: InstallTask, state: State<'_, AppState> ) -> Result<(), String> { state.install_tx.send(task).await.map_err(|e| e.to_string()) } #[tauri::command] fn get_logs_history() -> Vec { vec![] } fn expand_win_path(path: &str) -> PathBuf { let mut expanded = path.to_string(); let env_vars = [ "AppData", "LocalAppData", "ProgramData", "SystemRoot", "SystemDrive", "TEMP", "USERPROFILE" ]; for var in env_vars { let placeholder = format!("%{}%", var); if expanded.contains(&placeholder) { if let Ok(val) = std::env::var(var) { expanded = expanded.replace(&placeholder, &val); } } } PathBuf::from(expanded) } async fn execute_post_install(handle: &AppHandle, log_id: &str, steps: Vec) -> Result<(), String> { let steps_len = steps.len(); for (i, step) in steps.into_iter().enumerate() { let step_prefix = format!("Step {}/{}: ", i + 1, steps_len); match step { PostInstallStep::RegistryBatch { root, base_path, values } => { emit_log(handle, log_id, "Registry Update", &format!("{}Applying batch registry settings to {}...", step_prefix, base_path), "info"); let hive = match root.as_str() { "HKCU" => RegKey::predef(HKEY_CURRENT_USER), "HKLM" => RegKey::predef(HKEY_LOCAL_MACHINE), _ => { emit_log(handle, log_id, "Registry Error", &format!("Unknown root hive: {}", root), "error"); continue; } }; match hive.create_subkey(&base_path) { Ok((key, _)) => { for (name, val) in values { let res = match val.v_type.as_str() { "String" => key.set_value(&name, &val.data.as_str().unwrap_or_default()), "Dword" => key.set_value(&name, &(val.data.as_u64().unwrap_or(0) as u32)), "Qword" => key.set_value(&name, &(val.data.as_u64().unwrap_or(0))), "MultiString" => { let strings: Vec = val.data.as_array() .map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) .unwrap_or_default(); key.set_value(&name, &strings) }, "ExpandString" => { key.set_value(&name, &val.data.as_str().unwrap_or_default()) }, "Delete" => { key.delete_value(&name) }, _ => Err(std::io::Error::new(std::io::ErrorKind::Other, "Unsupported type")), }; if let Err(e) = res { emit_log(handle, log_id, "Registry Error", &format!("Failed to apply {}: {}", name, e), "error"); } } }, Err(e) => { emit_log(handle, log_id, "Registry Error", &format!("Failed to create/open key {}: {}", base_path, e), "error"); } } }, PostInstallStep::FileReplace { url, target } => { let target_path = expand_win_path(&target); emit_log(handle, log_id, "File Replace", &format!("{}Downloading configuration file to {:?}...", step_prefix, target_path), "info"); let client = reqwest::Client::new(); match client.get(&url).timeout(std::time::Duration::from_secs(30)).send().await { Ok(resp) if resp.status().is_success() => { if let Ok(bytes) = resp.bytes().await { if let Some(parent) = target_path.parent() { let _ = fs::create_dir_all(parent); } if let Err(e) = fs::write(&target_path, bytes) { emit_log(handle, log_id, "File Error", &format!("Failed to write to {:?}: {}", target_path, e), "error"); } else { emit_log(handle, log_id, "Success", "File replaced successfully.", "success"); } } }, Ok(resp) => { emit_log(handle, log_id, "Download Error", &format!("HTTP Status: {}", resp.status()), "error"); }, Err(e) => { emit_log(handle, log_id, "Download Error", &e.to_string(), "error"); } } }, PostInstallStep::Command { run } => { emit_log(handle, log_id, "Command Execution", &format!("{}Executing: {}", step_prefix, run), "info"); let output = Command::new("cmd") .args(["/C", &run]) .creation_flags(0x08000000) .output(); match output { Ok(out) => { if !out.status.success() { let err = String::from_utf8_lossy(&out.stderr); emit_log(handle, log_id, "Command Failed", &err, "error"); } else { emit_log(handle, log_id, "Success", "Command executed successfully.", "success"); } }, Err(e) => { emit_log(handle, log_id, "Execution Error", &e.to_string(), "error"); } } } } } Ok(()) } #[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(task) = rx.recv().await { let task_id = task.id.clone(); let task_version = task.version.clone(); let use_manifest = task.use_manifest; let manifest_url = task.manifest_url.clone(); let log_id = format!("install-{}", task_id); let _ = handle.emit("install-status", InstallProgress { id: task_id.clone(), status: "installing".to_string(), progress: 0.0, }); let mut args = vec!["install".to_string()]; let display_cmd: String; let mut temp_manifest_path: Option = None; if use_manifest && manifest_url.is_some() { let url = manifest_url.unwrap(); display_cmd = format!("Winget Install (Manifest): {} from {}", task_id, url); emit_log(&handle, &log_id, &display_cmd, "Downloading remote manifest...", "info"); let client = reqwest::Client::new(); match client.get(&url).timeout(std::time::Duration::from_secs(15)).send().await { Ok(resp) if resp.status().is_success() => { if let Ok(content) = resp.text().await { let temp_dir = std::env::temp_dir(); let file_name = format!("winget_tmp_{}.yaml", chrono::Local::now().timestamp_millis()); let local_path = temp_dir.join(file_name); if fs::write(&local_path, content).is_ok() { args.push("--manifest".to_string()); args.push(local_path.to_string_lossy().to_string()); temp_manifest_path = Some(local_path); } } } _ => {} } if temp_manifest_path.is_none() { emit_log(&handle, &log_id, "Error", "Failed to download or save manifest.", "error"); let _ = handle.emit("install-status", InstallProgress { id: task_id.clone(), status: "error".to_string(), progress: 0.0, }); continue; } } else { args.push("--id".to_string()); args.push(task_id.clone()); args.push("-e".to_string()); if let Some(v) = &task_version { if !v.is_empty() { args.push("--version".to_string()); args.push(v.clone()); } } display_cmd = match &task_version { Some(v) if !v.is_empty() => format!("Winget Install: {} (v{})", task_id, v), _ => format!("Winget Install: {}", task_id), }; } args.extend([ "--silent".to_string(), "--accept-package-agreements".to_string(), "--accept-source-agreements".to_string(), "--disable-interactivity".to_string(), ]); 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()) .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: 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); let status_result = if exit_status { "success" } else { "error" }; if status_result == "success" { let essentials = get_essentials(handle.clone()); let software_info = essentials.and_then(|repo| { repo.essentials.into_iter().find(|s| s.id == task_id) }); if let Some(sw) = software_info { let mut final_steps = None; if let Some(steps) = sw.post_install { if !steps.is_empty() { final_steps = Some(steps); } } else if let Some(url) = sw.post_install_url { emit_log(&handle, &log_id, "Post-Install", "Local config not found, fetching remote config...", "info"); let client = reqwest::Client::new(); if let Ok(resp) = client.get(&url).timeout(std::time::Duration::from_secs(10)).send().await { if resp.status().is_success() { if let Ok(steps) = resp.json::>().await { final_steps = Some(steps); } } } } if let Some(steps) = final_steps { let _ = handle.emit("install-status", InstallProgress { id: task_id.clone(), status: "configuring".to_string(), progress: 1.0, }); emit_log(&handle, &log_id, "Post-Install", "Starting post-installation configuration...", "info"); if let Err(e) = execute_post_install(&handle, &log_id, steps).await { emit_log(&handle, &log_id, "Post-Install Error", &e, "error"); } else { emit_log(&handle, &log_id, "Post-Install", "Post-installation configuration completed.", "success"); } } } } status_result }, Err(e) => { emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error"); "error" } }; let _ = handle.emit("install-status", InstallProgress { id: task_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" }); if let Some(path) = temp_manifest_path { let _ = fs::remove_file(path); } } }); Ok(()) }) .invoke_handler(tauri::generate_handler![ initialize_app, get_settings, save_settings, sync_essentials, get_essentials, get_installed_software, get_updates, install_software, get_logs_history ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }