Compare commits
12 Commits
9aa6f9cd1d
...
bba113e089
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba113e089 | ||
|
|
ff238eb534 | ||
|
|
886f513b5d | ||
|
|
8067cc870f | ||
|
|
fbdfcc8abe | ||
|
|
86df026091 | ||
|
|
fd8241fd43 | ||
|
|
66b6ac4738 | ||
|
|
1d53f42d10 | ||
|
|
c230847cc0 | ||
|
|
dac6f6cd62 | ||
|
|
04e4a510e5 |
118
scripts/convert-reg.ps1
Normal file
118
scripts/convert-reg.ps1
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
将 Windows .reg 文件转换为 win-softmgr 所需的 post_install JSON 格式。
|
||||||
|
.EXAMPLE
|
||||||
|
.\convert-reg.ps1 -Path .\adobe.reg
|
||||||
|
#>
|
||||||
|
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$Path,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$OutputPath
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path $Path)) {
|
||||||
|
Write-Error "文件不存在: $Path"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||||
|
$fileInfo = Get-Item $Path
|
||||||
|
$OutputPath = Join-Path $fileInfo.DirectoryName ($fileInfo.BaseName + ".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用 -Raw 读取并自动检测编码,然后按行拆分
|
||||||
|
$content = Get-Content $Path -Raw
|
||||||
|
$lines = $content -split "\r?\n"
|
||||||
|
|
||||||
|
$results = @()
|
||||||
|
$currentBatch = $null
|
||||||
|
|
||||||
|
foreach ($line in $lines) {
|
||||||
|
$line = $line.Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith("Windows Registry Editor")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# 匹配 [HKEY_...] 路径
|
||||||
|
if ($line.StartsWith("[") -and $line.EndsWith("]")) {
|
||||||
|
# 检查是否是删除整个 Key 的语法:[-HKEY_...]
|
||||||
|
$isDeleteKey = $line.StartsWith("[-")
|
||||||
|
$fullPath = if ($isDeleteKey) { $line.Substring(2, $line.Length - 3) } else { $line.Substring(1, $line.Length - 2) }
|
||||||
|
|
||||||
|
$root = ""
|
||||||
|
$basePath = ""
|
||||||
|
|
||||||
|
if ($fullPath -match "^HKEY_CURRENT_USER(\\.*)?$") {
|
||||||
|
$root = "HKCU"
|
||||||
|
$basePath = if ($fullPath.Length -gt 17) { $fullPath.Substring(18) } else { "" }
|
||||||
|
} elseif ($fullPath -match "^HKEY_LOCAL_MACHINE(\\.*)?$") {
|
||||||
|
$root = "HKLM"
|
||||||
|
$basePath = if ($fullPath.Length -gt 18) { $fullPath.Substring(19) } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($root -ne "") {
|
||||||
|
$currentBatch = [ordered]@{
|
||||||
|
type = "registry_batch"
|
||||||
|
root = $root
|
||||||
|
base_path = $basePath
|
||||||
|
# 如果是删除整个 Key,我们可以在这里记录或者扩展 schema
|
||||||
|
# 但目前我们先处理 Value 删除
|
||||||
|
values = [ordered]@{}
|
||||||
|
}
|
||||||
|
$results += $currentBatch
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# 匹配 "Name"=Value
|
||||||
|
if ($line -match '^"(.+)"\s*=\s*(.+)$') {
|
||||||
|
$name = $Matches[1]
|
||||||
|
$rawVal = $Matches[2]
|
||||||
|
$vType = ""
|
||||||
|
$data = $null
|
||||||
|
|
||||||
|
if ($rawVal -eq "-") {
|
||||||
|
# 处理删除 Value 的逻辑: "Key"=-
|
||||||
|
$vType = "Delete"
|
||||||
|
$data = $null
|
||||||
|
} elseif ($rawVal.StartsWith("dword:")) {
|
||||||
|
$vType = "Dword"
|
||||||
|
$hex = $rawVal.Substring(6)
|
||||||
|
$data = [Convert]::ToInt32($hex, 16)
|
||||||
|
} elseif ($rawVal.StartsWith('"') -and $rawVal.EndsWith('"')) {
|
||||||
|
$vType = "String"
|
||||||
|
$data = $rawVal.Substring(1, $rawVal.Length - 2).Replace("\\", "\")
|
||||||
|
} elseif ($rawVal.StartsWith("hex(7):")) {
|
||||||
|
$vType = "MultiString"
|
||||||
|
$hexBytes = $rawVal.Substring(7).Split(',') | ForEach-Object { [Convert]::ToByte($_, 16) }
|
||||||
|
$decoded = [System.Text.Encoding]::Unicode.GetString($hexBytes)
|
||||||
|
$data = $decoded.Split("`0", [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||||
|
} elseif ($rawVal.StartsWith("hex(b):")) {
|
||||||
|
$vType = "Qword"
|
||||||
|
$hexBytes = $rawVal.Substring(7).Split(',') | ForEach-Object { $_ }
|
||||||
|
if ($hexBytes.Count -ge 8) {
|
||||||
|
$hexStr = ($hexBytes[7,6,5,4,3,2,1,0] -join "")
|
||||||
|
$data = [Convert]::ToInt64($hexStr, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $currentBatch -and $vType -ne "") {
|
||||||
|
$currentBatch.values[$name] = [ordered]@{
|
||||||
|
v_type = $vType
|
||||||
|
data = $data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($results.Count -eq 0) {
|
||||||
|
Write-Warning "未在文件中识别到有效的注册表项。"
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsonOutput = ConvertTo-Json $results -Depth 10
|
||||||
|
[System.IO.File]::WriteAllText($OutputPath, $jsonOutput, [System.Text.Encoding]::UTF8)
|
||||||
|
|
||||||
|
Write-Host "转换成功!结果已保存至: $OutputPath" -ForegroundColor Green
|
||||||
14
src-tauri/Cargo.lock
generated
14
src-tauri/Cargo.lock
generated
@@ -859,7 +859,7 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg 0.55.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5117,6 +5117,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"winreg 0.56.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5592,6 +5593,17 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"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]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ tokio = { version = "1.50.0", features = ["full"] }
|
|||||||
chrono = "0.4.44"
|
chrono = "0.4.44"
|
||||||
regex = "1.12.3"
|
regex = "1.12.3"
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||||
|
winreg = { version = "0.56.0", features = ["serde"] }
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ use std::path::PathBuf;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tauri::{AppHandle, Manager, State, Emitter};
|
use tauri::{AppHandle, Manager, State, Emitter};
|
||||||
use serde::{Serialize, Deserialize};
|
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 regex::Regex;
|
||||||
|
use winreg::RegKey;
|
||||||
|
use winreg::enums::*;
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct AppSettings {
|
pub struct AppSettings {
|
||||||
@@ -29,8 +31,12 @@ pub struct InstallTask {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub use_manifest: bool,
|
pub use_manifest: bool,
|
||||||
pub manifest_url: Option<String>,
|
pub manifest_url: Option<String>,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub enable_post_install: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool { true }
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -114,7 +120,6 @@ async fn sync_essentials(app: AppHandle) -> Result<bool, String> {
|
|||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
let content = response.text().await.map_err(|e| e.to_string())?;
|
let content = response.text().await.map_err(|e| e.to_string())?;
|
||||||
// 验证 JSON 格式(新格式:{ version: string, essentials: Vec<Software> })
|
|
||||||
let validation: Result<EssentialsRepo, _> = serde_json::from_str(&content);
|
let validation: Result<EssentialsRepo, _> = serde_json::from_str(&content);
|
||||||
if validation.is_ok() {
|
if validation.is_ok() {
|
||||||
let path = get_essentials_path(&app);
|
let path = get_essentials_path(&app);
|
||||||
@@ -175,11 +180,171 @@ async fn install_software(
|
|||||||
state.install_tx.send(task).await.map_err(|e| e.to_string())
|
state.install_tx.send(task).await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_software_info(app: AppHandle, id: String) -> Option<Software> {
|
||||||
|
tokio::task::spawn_blocking(move || winget::get_software_info(&app, &id)).await.unwrap_or(None)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_logs_history() -> Vec<LogPayload> {
|
fn get_logs_history() -> Vec<LogPayload> {
|
||||||
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", "HOMEDRIVE", "HOMEPATH"
|
||||||
|
];
|
||||||
|
|
||||||
|
for var in env_vars {
|
||||||
|
let re = Regex::new(&format!(r"(?i)%{}%", var)).unwrap();
|
||||||
|
if re.is_match(&expanded) {
|
||||||
|
if let Ok(val) = std::env::var(var) {
|
||||||
|
expanded = re.replace_all(&expanded, val.as_str()).to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
|
let delay = match &step {
|
||||||
|
PostInstallStep::RegistryBatch { delay_ms, .. } => *delay_ms,
|
||||||
|
PostInstallStep::FileCopy { delay_ms, .. } => *delay_ms,
|
||||||
|
PostInstallStep::FileDelete { delay_ms, .. } => *delay_ms,
|
||||||
|
PostInstallStep::Command { delay_ms, .. } => *delay_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
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())
|
||||||
|
},
|
||||||
|
"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::FileCopy { src, dest, .. } => {
|
||||||
|
let dest_path = expand_win_path(&dest);
|
||||||
|
let src_is_url = src.starts_with("http://") || src.starts_with("https://");
|
||||||
|
|
||||||
|
if src_is_url {
|
||||||
|
emit_log(handle, log_id, "File Download", &format!("{}Downloading {:?} to {:?}...", step_prefix, src, dest_path), "info");
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
match client.get(&src).timeout(std::time::Duration::from_secs(60)).send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
if let Ok(bytes) = resp.bytes().await {
|
||||||
|
if let Some(parent) = dest_path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Err(e) = fs::write(&dest_path, bytes) {
|
||||||
|
emit_log(handle, log_id, "File Error", &format!("Failed to write to {:?}: {}", dest_path, e), "error");
|
||||||
|
} else {
|
||||||
|
emit_log(handle, log_id, "Success", "File downloaded and saved 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"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let src_path = expand_win_path(&src);
|
||||||
|
emit_log(handle, log_id, "File Copy", &format!("{}Copying {:?} to {:?}...", step_prefix, src_path, dest_path), "info");
|
||||||
|
if let Some(parent) = dest_path.parent() {
|
||||||
|
let _ = fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
if let Err(e) = fs::copy(&src_path, &dest_path) {
|
||||||
|
emit_log(handle, log_id, "File Error", &format!("Failed to copy file: {}", e), "error");
|
||||||
|
} else {
|
||||||
|
emit_log(handle, log_id, "Success", "File copied successfully.", "success");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PostInstallStep::FileDelete { path, .. } => {
|
||||||
|
let full_path = expand_win_path(&path);
|
||||||
|
emit_log(handle, log_id, "File Delete", &format!("{}Deleting {:?}...", step_prefix, full_path), "info");
|
||||||
|
if full_path.exists() {
|
||||||
|
if let Err(e) = fs::remove_file(&full_path) {
|
||||||
|
emit_log(handle, log_id, "File Error", &format!("Failed to delete file: {}", e), "error");
|
||||||
|
} else {
|
||||||
|
emit_log(handle, log_id, "Success", "File deleted successfully.", "success");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emit_log(handle, log_id, "File Info", "File does not exist, skipping.", "info");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PostInstallStep::Command { run, .. } => {
|
||||||
|
emit_log(handle, log_id, "Command Execution", &format!("{}Executing: {}", step_prefix, run), "info");
|
||||||
|
let output = Command::new("cmd")
|
||||||
|
.arg("/C")
|
||||||
|
.raw_arg(&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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ms) = delay {
|
||||||
|
if ms > 0 {
|
||||||
|
emit_log(handle, log_id, "Post-Install", &format!("Waiting for {}ms...", ms), "info");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
struct InstallProgress {
|
struct InstallProgress {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -204,10 +369,10 @@ pub fn run() {
|
|||||||
let task_version = task.version.clone();
|
let task_version = task.version.clone();
|
||||||
let use_manifest = task.use_manifest;
|
let use_manifest = task.use_manifest;
|
||||||
let manifest_url = task.manifest_url.clone();
|
let manifest_url = task.manifest_url.clone();
|
||||||
|
let enable_post_install_flag = task.enable_post_install;
|
||||||
|
|
||||||
let log_id = format!("install-{}", task_id);
|
let log_id = format!("install-{}", task_id);
|
||||||
|
|
||||||
// 1. 发送正在安装状态
|
|
||||||
let _ = handle.emit("install-status", InstallProgress {
|
let _ = handle.emit("install-status", InstallProgress {
|
||||||
id: task_id.clone(),
|
id: task_id.clone(),
|
||||||
status: "installing".to_string(),
|
status: "installing".to_string(),
|
||||||
@@ -291,7 +456,6 @@ pub fn run() {
|
|||||||
Ok(mut child_proc) => {
|
Ok(mut child_proc) => {
|
||||||
if let Some(stdout) = child_proc.stdout.take() {
|
if let Some(stdout) = child_proc.stdout.take() {
|
||||||
let reader = BufReader::new(stdout);
|
let reader = BufReader::new(stdout);
|
||||||
// 使用 split(b'\r') 是为了捕捉 PowerShell 中的动态进度行
|
|
||||||
for line_res in reader.split(b'\r') {
|
for line_res in reader.split(b'\r') {
|
||||||
if let Ok(line_bytes) = line_res {
|
if let Ok(line_bytes) = line_res {
|
||||||
let line_str = String::from_utf8_lossy(&line_bytes).to_string();
|
let line_str = String::from_utf8_lossy(&line_bytes).to_string();
|
||||||
@@ -328,7 +492,58 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false);
|
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" && enable_post_install_flag {
|
||||||
|
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(text) = resp.text().await {
|
||||||
|
match serde_json::from_str::<Vec<PostInstallStep>>(&text) {
|
||||||
|
Ok(steps) => {
|
||||||
|
emit_log(&handle, &log_id, "Post-Install", &format!("Successfully fetched remote config with {} steps.", steps.len()), "info");
|
||||||
|
final_steps = Some(steps);
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
emit_log(&handle, &log_id, "Post-Install Error", &format!("JSON Parse Error: {}. Raw Content: {}", e, text), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emit_log(&handle, &log_id, "Post-Install Error", &format!("Remote config HTTP Error: {}", resp.status()), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
Err(e) => {
|
||||||
emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error");
|
emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error");
|
||||||
@@ -336,16 +551,13 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. 发送最终完成/失败状态
|
|
||||||
let _ = handle.emit("install-status", InstallProgress {
|
let _ = handle.emit("install-status", InstallProgress {
|
||||||
id: task_id.clone(),
|
id: task_id.clone(),
|
||||||
status: status_result.to_string(),
|
status: status_result.to_string(),
|
||||||
progress: 1.0,
|
progress: 1.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
emit_log(&handle, &log_id, "Result", &format!("Execution finished: {}", status_result), if status_result == "success" { "success" } else { "error" });
|
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 {
|
if let Some(path) = temp_manifest_path {
|
||||||
let _ = fs::remove_file(path);
|
let _ = fs::remove_file(path);
|
||||||
}
|
}
|
||||||
@@ -362,6 +574,7 @@ pub fn run() {
|
|||||||
get_essentials,
|
get_essentials,
|
||||||
get_installed_software,
|
get_installed_software,
|
||||||
get_updates,
|
get_updates,
|
||||||
|
get_software_info,
|
||||||
install_software,
|
install_software,
|
||||||
get_logs_history
|
get_logs_history
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,9 +1,44 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
|
use std::collections::HashMap;
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use crate::emit_log;
|
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,
|
||||||
|
base_path: String,
|
||||||
|
values: HashMap<String, RegistryValue>,
|
||||||
|
delay_ms: Option<u64>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "file_copy")]
|
||||||
|
FileCopy {
|
||||||
|
src: String,
|
||||||
|
dest: String,
|
||||||
|
delay_ms: Option<u64>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "file_delete")]
|
||||||
|
FileDelete {
|
||||||
|
path: String,
|
||||||
|
delay_ms: Option<u64>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "command")]
|
||||||
|
Command {
|
||||||
|
run: String,
|
||||||
|
delay_ms: Option<u64>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Software {
|
pub struct Software {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -13,12 +48,14 @@ pub struct Software {
|
|||||||
pub available_version: Option<String>,
|
pub available_version: Option<String>,
|
||||||
pub icon_url: Option<String>,
|
pub icon_url: Option<String>,
|
||||||
#[serde(default = "default_status")]
|
#[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")]
|
#[serde(default = "default_progress")]
|
||||||
pub progress: f32,
|
pub progress: f32,
|
||||||
#[serde(default = "default_false")]
|
#[serde(default = "default_false")]
|
||||||
pub use_manifest: bool,
|
pub use_manifest: bool,
|
||||||
pub manifest_url: Option<String>,
|
pub manifest_url: Option<String>,
|
||||||
|
pub post_install: Option<Vec<PostInstallStep>>,
|
||||||
|
pub post_install_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_status() -> String { "idle".to_string() }
|
fn default_status() -> String { "idle".to_string() }
|
||||||
@@ -227,6 +264,27 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
|
|||||||
execute_powershell(handle, &log_id, "Fetch Updates", script)
|
execute_powershell(handle, &log_id, "Fetch Updates", script)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_software_info(handle: &AppHandle, id: &str) -> Option<Software> {
|
||||||
|
let log_id = format!("get-info-{}", chrono::Local::now().timestamp_millis());
|
||||||
|
let script = format!(r#"
|
||||||
|
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
|
||||||
|
$pkg = Get-WinGetPackage -Id "{}" -ErrorAction SilentlyContinue
|
||||||
|
if ($pkg) {{
|
||||||
|
[PSCustomObject]@{{
|
||||||
|
Name = [string]$pkg.Name;
|
||||||
|
Id = [string]$pkg.Id;
|
||||||
|
InstalledVersion = [string]$pkg.InstalledVersion;
|
||||||
|
AvailableVersions = @()
|
||||||
|
}} | ConvertTo-Json -Compress
|
||||||
|
}}
|
||||||
|
"#, id);
|
||||||
|
|
||||||
|
let res = execute_powershell(handle, &log_id, "Fetch Single Software Info", &script);
|
||||||
|
res.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
fn execute_powershell(handle: &AppHandle, log_id: &str, cmd_title: &str, script: &str) -> Vec<Software> {
|
fn execute_powershell(handle: &AppHandle, log_id: &str, cmd_title: &str, script: &str) -> Vec<Software> {
|
||||||
emit_log(handle, log_id, cmd_title, "Fetching data from Winget...", "info");
|
emit_log(handle, log_id, cmd_title, "Fetching data from Winget...", "info");
|
||||||
|
|
||||||
@@ -283,5 +341,7 @@ fn map_package(p: WingetPackage) -> Software {
|
|||||||
progress: 0.0,
|
progress: 0.0,
|
||||||
use_manifest: false,
|
use_manifest: false,
|
||||||
manifest_url: None,
|
manifest_url: None,
|
||||||
|
post_install: None,
|
||||||
|
post_install_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
width: 240px;
|
width: 240px;
|
||||||
background-color: var(--sidebar-bg);
|
background-color: var(--sidebar-bg);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
padding: 40px 20px;
|
padding: 40px 20px 5px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
padding-left: 20px;
|
padding-left: 10px;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
<span class="id-badge">{{ software.id }}</span>
|
<span class="id-badge">{{ software.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="version-info">
|
<div class="version-info">
|
||||||
<!-- 情况 1: 已安装且有推荐/最新版本 -->
|
|
||||||
<template v-if="software.version">
|
<template v-if="software.version">
|
||||||
<span class="version-tag">当前: {{ software.version }}</span>
|
<span class="version-tag">当前: {{ software.version }}</span>
|
||||||
<span v-if="software.recommended_version && software.actionLabel === '更新'" class="version-tag recommended">
|
<span v-if="software.recommended_version && software.actionLabel === '更新'" class="version-tag recommended">
|
||||||
@@ -46,7 +45,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 情况 2: 未安装 -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span v-if="software.recommended_version" class="version-tag recommended">
|
<span v-if="software.recommended_version" class="version-tag recommended">
|
||||||
推荐: {{ software.recommended_version }}
|
推荐: {{ software.recommended_version }}
|
||||||
@@ -64,6 +62,19 @@
|
|||||||
|
|
||||||
<div class="card-right" v-if="actionLabel || software.status !== 'idle'">
|
<div class="card-right" v-if="actionLabel || software.status !== 'idle'">
|
||||||
<div class="action-wrapper">
|
<div class="action-wrapper">
|
||||||
|
<!-- 后安装配置开关 -->
|
||||||
|
<div
|
||||||
|
v-if="software.status === 'idle' && (software.post_install || software.post_install_url)"
|
||||||
|
class="post-install-toggle"
|
||||||
|
@click.stop="$emit('togglePostInstall', software.id)"
|
||||||
|
:title="software.enablePostInstall ? '已开启安装后自动配置' : '已关闭安装后自动配置'"
|
||||||
|
>
|
||||||
|
<span class="toggle-label">自动配置</span>
|
||||||
|
<div class="toggle-switch" :class="{ 'is-active': software.enablePostInstall }">
|
||||||
|
<div class="toggle-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="software.status === 'idle'"
|
v-if="software.status === 'idle'"
|
||||||
@click.stop="$emit('install', software.id, (software as any).targetVersion)"
|
@click.stop="$emit('install', software.id, (software as any).targetVersion)"
|
||||||
@@ -81,12 +92,15 @@
|
|||||||
已安装
|
已安装
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 等待中状态 -->
|
|
||||||
<div v-else-if="software.status === 'pending'" class="status-pending">
|
<div v-else-if="software.status === 'pending'" class="status-pending">
|
||||||
<span class="wait-text">等待中</span>
|
<span class="wait-text">等待中</span>
|
||||||
</div>
|
</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 v-else-if="software.status === 'installing'" class="progress-status">
|
||||||
<div class="progress-ring-container">
|
<div class="progress-ring-container">
|
||||||
<svg viewBox="0 0 32 32" class="ring-svg">
|
<svg viewBox="0 0 32 32" class="ring-svg">
|
||||||
@@ -133,6 +147,9 @@ const props = defineProps<{
|
|||||||
progress: number;
|
progress: number;
|
||||||
actionLabel?: string;
|
actionLabel?: string;
|
||||||
targetVersion?: string;
|
targetVersion?: string;
|
||||||
|
post_install?: any;
|
||||||
|
post_install_url?: string;
|
||||||
|
enablePostInstall?: boolean;
|
||||||
},
|
},
|
||||||
actionLabel?: string,
|
actionLabel?: string,
|
||||||
selectable?: boolean,
|
selectable?: boolean,
|
||||||
@@ -140,7 +157,7 @@ const props = defineProps<{
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits(['install', 'toggleSelect']);
|
const emit = defineEmits(['install', 'toggleSelect', 'togglePostInstall']);
|
||||||
|
|
||||||
const displayProgress = computed(() => {
|
const displayProgress = computed(() => {
|
||||||
if (!props.software.progress) return '准备中';
|
if (!props.software.progress) return '准备中';
|
||||||
@@ -288,17 +305,6 @@ const handleCardClick = () => {
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-sec);
|
|
||||||
line-height: 1.4;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 1;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-info {
|
.version-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -320,10 +326,64 @@ const handleCardClick = () => {
|
|||||||
.card-right {
|
.card-right {
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-install-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-install-toggle:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-sec);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
width: 32px;
|
||||||
|
height: 18px;
|
||||||
|
background-color: #E5E5EA;
|
||||||
|
border-radius: 9px;
|
||||||
|
position: relative;
|
||||||
|
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.is-active {
|
||||||
|
background-color: #34C759;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.is-active .toggle-dot {
|
||||||
|
transform: translateX(14px);
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
@@ -373,6 +433,30 @@ const handleCardClick = () => {
|
|||||||
color: #AEAEB2;
|
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 {
|
.wait-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
initStatus: '正在检查系统环境...',
|
initStatus: '正在检查系统环境...',
|
||||||
lastFetched: 0,
|
lastFetched: 0,
|
||||||
refreshTimer: null as any,
|
refreshTimer: null as any,
|
||||||
batchQueue: [] as string[]
|
batchQueue: [] as string[],
|
||||||
|
postInstallPrefs: {} as Record<string, boolean> // 记录用户对每个软件后安装配置的偏好
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
mergedEssentials: (state) => {
|
mergedEssentials: (state) => {
|
||||||
@@ -66,13 +67,11 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
let targetVersion = recommendedVersion || availableVersion;
|
let targetVersion = recommendedVersion || availableVersion;
|
||||||
|
|
||||||
if (isInstalled) {
|
if (isInstalled) {
|
||||||
// 逻辑:已安装 >= 推荐 -> 已安装(禁用)
|
|
||||||
// 逻辑:已安装 < 推荐 -> 更新
|
|
||||||
const comp = compareVersions(currentVersion, recommendedVersion);
|
const comp = compareVersions(currentVersion, recommendedVersion);
|
||||||
if (comp >= 0) {
|
if (comp >= 0) {
|
||||||
displayStatus = task ? task.status : 'installed';
|
displayStatus = task ? task.status : 'installed';
|
||||||
actionLabel = '已安装';
|
actionLabel = '已安装';
|
||||||
targetVersion = undefined; // 禁用安装
|
targetVersion = undefined;
|
||||||
} else {
|
} else {
|
||||||
actionLabel = '更新';
|
actionLabel = '更新';
|
||||||
targetVersion = recommendedVersion;
|
targetVersion = recommendedVersion;
|
||||||
@@ -82,6 +81,9 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
targetVersion = recommendedVersion || availableVersion;
|
targetVersion = recommendedVersion || availableVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取偏好,默认开启
|
||||||
|
const enablePostInstall = state.postInstallPrefs[item.id] !== false;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
version: currentVersion,
|
version: currentVersion,
|
||||||
@@ -90,19 +92,22 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
status: displayStatus,
|
status: displayStatus,
|
||||||
progress: task ? task.progress : 0,
|
progress: task ? task.progress : 0,
|
||||||
actionLabel,
|
actionLabel,
|
||||||
targetVersion // 传递给视图,用于点击安装
|
targetVersion,
|
||||||
|
enablePostInstall
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
sortedUpdates: (state) => {
|
sortedUpdates: (state) => {
|
||||||
return [...state.updates].map(item => {
|
return [...state.updates].map(item => {
|
||||||
const task = state.activeTasks[item.id];
|
const task = state.activeTasks[item.id];
|
||||||
|
const enablePostInstall = state.postInstallPrefs[item.id] !== false;
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
status: task ? task.status : 'idle',
|
status: task ? task.status : 'idle',
|
||||||
progress: task ? task.progress : 0,
|
progress: task ? task.progress : 0,
|
||||||
actionLabel: '更新',
|
actionLabel: '更新',
|
||||||
targetVersion: item.available_version // 更新页面永远追求最新版
|
targetVersion: item.available_version,
|
||||||
|
enablePostInstall
|
||||||
};
|
};
|
||||||
}).sort(sortByName);
|
}).sort(sortByName);
|
||||||
},
|
},
|
||||||
@@ -113,7 +118,6 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
// ... (initializeApp, saveSettings, syncEssentials stay the same)
|
|
||||||
async initializeApp() {
|
async initializeApp() {
|
||||||
if (this.isInitialized) return;
|
if (this.isInitialized) return;
|
||||||
this.initStatus = '正在加载应用配置...';
|
this.initStatus = '正在加载应用配置...';
|
||||||
@@ -230,6 +234,9 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
async install(id: string, targetVersion?: string) {
|
async install(id: string, targetVersion?: string) {
|
||||||
const software = this.findSoftware(id)
|
const software = this.findSoftware(id)
|
||||||
if (software) {
|
if (software) {
|
||||||
|
// 根据偏好决定是否开启后安装配置
|
||||||
|
const enablePostInstall = this.postInstallPrefs[id] !== false;
|
||||||
|
|
||||||
this.activeTasks[id] = { status: 'pending', progress: 0, targetVersion };
|
this.activeTasks[id] = { status: 'pending', progress: 0, targetVersion };
|
||||||
try {
|
try {
|
||||||
await invoke('install_software', {
|
await invoke('install_software', {
|
||||||
@@ -237,7 +244,8 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
id,
|
id,
|
||||||
version: targetVersion,
|
version: targetVersion,
|
||||||
use_manifest: software.use_manifest || false,
|
use_manifest: software.use_manifest || false,
|
||||||
manifest_url: software.manifest_url || null
|
manifest_url: software.manifest_url || null,
|
||||||
|
enable_post_install: enablePostInstall
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -247,26 +255,26 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 注册批量任务
|
togglePostInstallPref(id: string) {
|
||||||
|
const current = this.postInstallPrefs[id] !== false;
|
||||||
|
this.postInstallPrefs[id] = !current;
|
||||||
|
},
|
||||||
|
|
||||||
startBatch(ids: string[]) {
|
startBatch(ids: string[]) {
|
||||||
this.batchQueue = [...ids];
|
this.batchQueue = [...ids];
|
||||||
},
|
},
|
||||||
|
|
||||||
// 引入防抖刷新:仅在批量任务全部处理完后的 2 秒执行全量扫描
|
|
||||||
scheduleDataRefresh() {
|
scheduleDataRefresh() {
|
||||||
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
||||||
|
|
||||||
this.refreshTimer = setTimeout(async () => {
|
this.refreshTimer = setTimeout(async () => {
|
||||||
await this.fetchAllData();
|
await this.fetchAllData();
|
||||||
|
|
||||||
// 刷新完成后清理所有已终结(成功或失败)的任务状态快照
|
|
||||||
Object.keys(this.activeTasks).forEach(id => {
|
Object.keys(this.activeTasks).forEach(id => {
|
||||||
const status = this.activeTasks[id].status;
|
const status = this.activeTasks[id].status;
|
||||||
if (status === 'success' || status === 'error') {
|
if (status === 'success' || status === 'error') {
|
||||||
delete this.activeTasks[id];
|
delete this.activeTasks[id];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.refreshTimer = null;
|
this.refreshTimer = null;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
},
|
},
|
||||||
@@ -280,25 +288,41 @@ export const useSoftwareStore = defineStore('software', {
|
|||||||
if ((window as any).__tauri_listener_init) return;
|
if ((window as any).__tauri_listener_init) return;
|
||||||
(window as any).__tauri_listener_init = true;
|
(window as any).__tauri_listener_init = true;
|
||||||
|
|
||||||
listen('install-status', (event: any) => {
|
listen('install-status', async (event: any) => {
|
||||||
const { id, status, progress } = event.payload
|
const { id, status, progress } = event.payload
|
||||||
const task = this.activeTasks[id];
|
const task = this.activeTasks[id];
|
||||||
|
|
||||||
this.activeTasks[id] = { status, progress, targetVersion: task?.targetVersion };
|
this.activeTasks[id] = { status, progress, targetVersion: task?.targetVersion };
|
||||||
|
|
||||||
// 当任务达到终态(成功或失败)时
|
|
||||||
if (status === 'success' || status === 'error') {
|
if (status === 'success' || status === 'error') {
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
this.lastFetched = 0;
|
try {
|
||||||
// 立即更新勾选状态,提升响应感
|
const latestInfo = await invoke('get_software_info', { id }) as any;
|
||||||
this.selectedEssentialIds = this.selectedEssentialIds.filter(i => i !== id);
|
if (latestInfo) {
|
||||||
this.selectedUpdateIds = this.selectedUpdateIds.filter(i => i !== id);
|
const index = this.allSoftware.findIndex(s => s.id.toLowerCase() === id.toLowerCase());
|
||||||
|
if (index !== -1) {
|
||||||
|
this.allSoftware[index] = { ...this.allSoftware[index], ...latestInfo };
|
||||||
|
} else {
|
||||||
|
this.allSoftware.push(latestInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Partial refresh failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedEssentialIds = this.selectedEssentialIds.filter(i => i !== id);
|
||||||
|
this.selectedUpdateIds = this.selectedUpdateIds.filter(i => i !== id);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.activeTasks[id]?.status === 'success') {
|
||||||
|
delete this.activeTasks[id];
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否属于正在进行的批量任务
|
|
||||||
const index = this.batchQueue.indexOf(id);
|
const index = this.batchQueue.indexOf(id);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
this.batchQueue.splice(index, 1);
|
this.batchQueue.splice(index, 1);
|
||||||
// 如果这是批量任务中的最后一个,则触发延迟刷新
|
|
||||||
if (this.batchQueue.length === 0) {
|
if (this.batchQueue.length === 0) {
|
||||||
this.scheduleDataRefresh();
|
this.scheduleDataRefresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
|
<!-- 固定标头区域 -->
|
||||||
|
<div class="sticky-header">
|
||||||
<header class="content-header">
|
<header class="content-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1>装机必备</h1>
|
<h1>装机必备</h1>
|
||||||
@@ -44,7 +46,10 @@
|
|||||||
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
|
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 可滚动内容区域 -->
|
||||||
|
<div class="scroll-content">
|
||||||
<div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state">
|
<div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>正在读取必备软件列表...</p>
|
<p>正在读取必备软件列表...</p>
|
||||||
@@ -60,9 +65,11 @@
|
|||||||
:is-selected="store.selectedEssentialIds.includes(item.id)"
|
:is-selected="store.selectedEssentialIds.includes(item.id)"
|
||||||
:disabled="store.isBusy"
|
:disabled="store.isBusy"
|
||||||
@install="store.install"
|
@install="store.install"
|
||||||
@toggle-select="id => store.toggleSelection(id, 'essential')"
|
@toggle-select="store.toggleSelection($event, 'essential')"
|
||||||
|
@toggle-post-install="store.togglePostInstallPref"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -97,15 +104,30 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 40px 60px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden; /* 关键:禁止最外层滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-header {
|
||||||
|
padding: 24px 60px 8px 60px;
|
||||||
|
background-color: #F5F5F7; /* 与 App.vue 背景色保持一致 */
|
||||||
|
z-index: 10;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-content {
|
||||||
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: 8px 60px 32px 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-header {
|
.content-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
@@ -136,16 +158,16 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 批量选择工具栏 */
|
|
||||||
.selection-toolbar {
|
.selection-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
background-color: white; /* 这里的工具栏背景设为白色更清晰 */
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 10px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-count {
|
.selection-count {
|
||||||
|
|||||||
@@ -47,8 +47,8 @@
|
|||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3 class="section-title">关于</h3>
|
<h3 class="section-title">关于</h3>
|
||||||
<div class="settings-card about-card">
|
<div class="settings-card about-card">
|
||||||
<p>Windows 软件管理器 v{{ version }}</p>
|
<p>Windows 软件管理 v{{ version }}</p>
|
||||||
<p class="hint">基于 Tauri 和 WinGet 构建的 Windows 软件管理工具</p>
|
<p class="hint">基于 WinGet 构建的 Windows 软件管理工具</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
|
<!-- 固定标头区域 -->
|
||||||
|
<div class="sticky-header">
|
||||||
<header class="content-header">
|
<header class="content-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1>软件更新</h1>
|
<h1>软件更新</h1>
|
||||||
@@ -43,7 +45,10 @@
|
|||||||
<button @click="store.invertSelection('update')" class="text-btn" :disabled="store.isBusy">反选</button>
|
<button @click="store.invertSelection('update')" class="text-btn" :disabled="store.isBusy">反选</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 可滚动内容区域 -->
|
||||||
|
<div class="scroll-content">
|
||||||
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
|
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>正在使用 Winget 扫描可用的更新...</p>
|
<p>正在使用 Winget 扫描可用的更新...</p>
|
||||||
@@ -59,14 +64,16 @@
|
|||||||
v-for="item in store.sortedUpdates"
|
v-for="item in store.sortedUpdates"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:software="item"
|
:software="item"
|
||||||
action-label="更新"
|
:action-label="item.actionLabel"
|
||||||
:selectable="true"
|
:selectable="true"
|
||||||
:is-selected="store.selectedUpdateIds.includes(item.id)"
|
:is-selected="store.selectedUpdateIds.includes(item.id)"
|
||||||
:disabled="store.isBusy"
|
:disabled="store.isBusy"
|
||||||
@install="store.install"
|
@install="store.install"
|
||||||
@toggle-select="id => store.toggleSelection(id, 'update')"
|
@toggle-select="store.toggleSelection($event, 'update')"
|
||||||
|
@toggle-post-install="store.togglePostInstallPref"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -98,15 +105,30 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 40px 60px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden; /* 关键:禁止最外层滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-header {
|
||||||
|
padding: 24px 60px 8px 60px;
|
||||||
|
background-color: #F5F5F7;
|
||||||
|
z-index: 10;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-content {
|
||||||
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: 8px 60px 32px 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-header {
|
.content-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-header h1 {
|
.content-header h1 {
|
||||||
@@ -121,16 +143,16 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 批量选择工具栏 */
|
|
||||||
.selection-toolbar {
|
.selection-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
background-color: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 10px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-count {
|
.selection-count {
|
||||||
|
|||||||
Reference in New Issue
Block a user