support config

This commit is contained in:
Julian Freeman
2026-04-04 13:22:40 -04:00
parent 9aa6f9cd1d
commit 04e4a510e5
6 changed files with 236 additions and 10 deletions

14
src-tauri/Cargo.lock generated
View File

@@ -859,7 +859,7 @@ dependencies = [
"rustc_version",
"toml 0.9.12+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -5117,6 +5117,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-opener",
"tokio",
"winreg 0.56.0",
]
[[package]]
@@ -5592,6 +5593,17 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winreg"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
dependencies = [
"cfg-if",
"serde",
"windows-sys 0.61.2",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"

View File

@@ -26,4 +26,5 @@ tokio = { version = "1.50.0", features = ["full"] }
chrono = "0.4.44"
regex = "1.12.3"
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
winreg = { version = "0.56.0", features = ["serde"] }

View File

@@ -8,8 +8,10 @@ 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};
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 {
@@ -180,6 +182,118 @@ fn get_logs_history() -> Vec<LogPayload> {
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<PostInstallStep>) -> 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<String> = 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())
},
_ => 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 set {}: {}", 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,
@@ -207,7 +321,6 @@ pub fn run() {
let log_id = format!("install-{}", task_id);
// 1. 发送正在安装状态
let _ = handle.emit("install-status", InstallProgress {
id: task_id.clone(),
status: "installing".to_string(),
@@ -291,7 +404,6 @@ pub fn run() {
Ok(mut child_proc) => {
if let Some(stdout) = child_proc.stdout.take() {
let reader = BufReader::new(stdout);
// 使用 split(b'\r') 是为了捕捉 PowerShell 中的动态进度行
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();
@@ -328,7 +440,48 @@ pub fn run() {
}
}
let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false);
if exit_status { "success" } else { "error" }
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::<Vec<PostInstallStep>>().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");
@@ -336,16 +489,13 @@ pub fn run() {
}
};
// 2. 发送最终完成/失败状态
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" });
// 3. 清理临时清单文件
if let Some(path) = temp_manifest_path {
let _ = fs::remove_file(path);
}

View File

@@ -1,9 +1,36 @@
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::os::windows::process::CommandExt;
use std::collections::HashMap;
use tauri::AppHandle;
use crate::emit_log;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RegistryValue {
pub v_type: String, // "String", "Dword", "Qword", "MultiString", "ExpandString"
pub data: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum PostInstallStep {
#[serde(rename = "registry_batch")]
RegistryBatch {
root: String, // "HKCU", "HKLM"
base_path: String,
values: HashMap<String, RegistryValue>,
},
#[serde(rename = "file_replace")]
FileReplace {
url: String,
target: String, // 支持 %AppData% 等环境变量占位符
},
#[serde(rename = "command")]
Command {
run: String,
},
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Software {
pub id: String,
@@ -13,12 +40,14 @@ pub struct Software {
pub available_version: Option<String>,
pub icon_url: Option<String>,
#[serde(default = "default_status")]
pub status: String, // "idle", "pending", "installing", "success", "error"
pub status: String, // "idle", "pending", "installing", "configuring", "success", "error"
#[serde(default = "default_progress")]
pub progress: f32,
#[serde(default = "default_false")]
pub use_manifest: bool,
pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
}
fn default_status() -> String { "idle".to_string() }
@@ -283,5 +312,7 @@ fn map_package(p: WingetPackage) -> Software {
progress: 0.0,
use_manifest: false,
manifest_url: None,
post_install: None,
post_install_url: None,
}
}

View File

@@ -86,6 +86,12 @@
<span class="wait-text">等待中</span>
</div>
<!-- 配置中状态 -->
<div v-else-if="software.status === 'configuring'" class="status-configuring">
<div class="mini-spinner"></div>
<span class="config-text">正在配置...</span>
</div>
<!-- 安装中状态显示进度环和百分比 -->
<div v-else-if="software.status === 'installing'" class="progress-status">
<div class="progress-ring-container">
@@ -373,6 +379,30 @@ const handleCardClick = () => {
color: #AEAEB2;
}
.status-configuring {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 90px;
justify-content: center;
color: var(--primary-color);
}
.mini-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 122, 255, 0.1);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.config-text {
font-size: 11px;
font-weight: 600;
}
.wait-text {
font-size: 12px;
font-weight: 600;

View File

@@ -283,9 +283,11 @@ export const useSoftwareStore = defineStore('software', {
listen('install-status', (event: any) => {
const { id, status, progress } = event.payload
const task = this.activeTasks[id];
// 更新任务状态
this.activeTasks[id] = { status, progress, targetVersion: task?.targetVersion };
// 当任务达到终态(成功或失败)时
// 当任务达到终态(成功或失败)时。注意:'configuring' 不是终态。
if (status === 'success' || status === 'error') {
if (status === 'success') {
this.lastFetched = 0;