Compare commits

...

23 Commits

Author SHA1 Message Date
Julian Freeman
52cf6736cf change label 2026-04-18 22:36:12 -04:00
Julian Freeman
cfe93144e6 support categories 2026-04-18 20:45:10 -04:00
Julian Freeman
87ffe2e243 no post after update 2026-04-18 20:17:54 -04:00
Julian Freeman
72d878d221 fix fetch bug 2026-04-18 16:40:10 -04:00
Julian Freeman
708df41063 refactor 6 2026-04-18 16:15:52 -04:00
Julian Freeman
73976d1367 refactor 5 2026-04-18 16:08:37 -04:00
Julian Freeman
2625c8b52f refactor 4 2026-04-18 16:05:00 -04:00
Julian Freeman
0fc523e234 refactor 3 2026-04-18 16:01:38 -04:00
Julian Freeman
db377852fc refactor 2 2026-04-18 15:58:19 -04:00
Julian Freeman
2aaa330c9a refactor 1 2026-04-18 15:55:03 -04:00
Julian Freeman
fe86431899 op1 2026-04-18 15:27:00 -04:00
Julian Freeman
bba113e089 fix ui 2026-04-04 19:58:17 -04:00
Julian Freeman
ff238eb534 fix title area 2026-04-04 19:51:06 -04:00
Julian Freeman
886f513b5d support disable config 2026-04-04 19:06:27 -04:00
Julian Freeman
8067cc870f fix cmd 2026-04-04 18:42:23 -04:00
Julian Freeman
fbdfcc8abe support delay 2026-04-04 18:24:34 -04:00
Julian Freeman
86df026091 add details fetch log 2026-04-04 18:08:50 -04:00
Julian Freeman
fd8241fd43 optimize status fetch 2026-04-04 17:20:06 -04:00
Julian Freeman
66b6ac4738 fix env name 2026-04-04 17:02:33 -04:00
Julian Freeman
1d53f42d10 add file_delete and copy 2026-04-04 16:01:04 -04:00
Julian Freeman
c230847cc0 support del reg 2026-04-04 15:54:40 -04:00
Julian Freeman
dac6f6cd62 add scripts 2026-04-04 15:48:22 -04:00
Julian Freeman
04e4a510e5 support config 2026-04-04 13:22:40 -04:00
36 changed files with 3024 additions and 894 deletions

118
scripts/convert-reg.ps1 Normal file
View 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

View File

@@ -1,6 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/target*/
# Generated by Tauri
# will have schema files for capabilities auto-completion

15
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]]
@@ -5108,6 +5108,7 @@ dependencies = [
name = "win-softmgr"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"regex",
"reqwest 0.12.28",
@@ -5117,6 +5118,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-opener",
"tokio",
"winreg 0.56.0",
]
[[package]]
@@ -5592,6 +5594,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"] }
base64 = "0.22.1"

View File

@@ -0,0 +1,57 @@
use tauri::AppHandle;
use crate::domain::models::{
AppSettings, DashboardSnapshot, EssentialsRepo, EssentialsStatusItem, LogPayload, SyncEssentialsResult,
UpdateCandidate,
};
use crate::services::{essentials_service, settings_service, software_state_service};
#[tauri::command]
pub fn get_settings(app: AppHandle) -> AppSettings {
settings_service::get_settings(&app)
}
#[tauri::command]
pub fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
settings_service::save_settings(&app, &settings)
}
#[tauri::command]
pub async fn sync_essentials(app: AppHandle) -> Result<SyncEssentialsResult, String> {
essentials_service::sync_essentials(&app).await
}
#[tauri::command]
pub fn get_essentials(app: AppHandle) -> Option<EssentialsRepo> {
essentials_service::get_essentials(&app)
}
#[tauri::command]
pub async fn initialize_app(app: AppHandle) -> Result<bool, String> {
software_state_service::initialize_app(app).await
}
#[tauri::command]
pub async fn get_dashboard_snapshot(app: AppHandle) -> DashboardSnapshot {
software_state_service::get_dashboard_snapshot(app).await
}
#[tauri::command]
pub async fn get_essentials_status(app: AppHandle) -> (String, Vec<EssentialsStatusItem>) {
software_state_service::get_essentials_status(app).await
}
#[tauri::command]
pub async fn get_update_candidates(app: AppHandle) -> Vec<UpdateCandidate> {
software_state_service::get_update_candidates(app).await
}
#[tauri::command]
pub async fn get_software_icon(app: AppHandle, id: String, name: String) -> Option<String> {
software_state_service::get_software_icon(app, id, name).await
}
#[tauri::command]
pub fn get_logs_history() -> Vec<LogPayload> {
vec![]
}

View File

@@ -0,0 +1 @@
pub mod app_commands;

View File

@@ -0,0 +1 @@
pub mod models;

View File

@@ -0,0 +1,114 @@
use serde::{Deserialize, Serialize};
use crate::winget::{PostInstallStep, Software};
#[derive(Clone, Serialize, Deserialize)]
pub struct AppSettings {
pub repo_url: String,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
repo_url: "https://karlblue.github.io/winget-repo".to_string(),
}
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct EssentialsRepo {
pub version: String,
pub essentials: Vec<Software>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct InstallTask {
pub id: String,
pub version: Option<String>,
#[serde(default)]
pub use_manifest: bool,
pub manifest_url: Option<String>,
#[serde(default = "default_true")]
pub enable_post_install: bool,
}
fn default_true() -> bool {
true
}
#[derive(Clone, Serialize)]
pub struct LogPayload {
pub id: String,
pub timestamp: String,
pub command: String,
pub output: String,
pub status: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct SyncEssentialsResult {
pub status: String,
pub message: String,
}
#[derive(Clone)]
pub struct ResolvedPostInstall {
pub software: Software,
pub steps: Vec<PostInstallStep>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct TaskEventPayload {
pub task_id: String,
pub software_id: String,
pub task_type: String,
pub status: String,
pub stage: String,
pub progress: f32,
pub target_version: Option<String>,
pub message: Option<String>,
pub software_info: Option<Software>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct EssentialsStatusItem {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
pub recommended_version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,
pub use_manifest: bool,
pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
pub action_label: String,
pub target_version: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct UpdateCandidate {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
pub available_version: Option<String>,
pub icon_url: Option<String>,
pub use_manifest: bool,
pub manifest_url: Option<String>,
pub post_install: Option<Vec<PostInstallStep>>,
pub post_install_url: Option<String>,
pub action_label: String,
pub target_version: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct DashboardSnapshot {
pub essentials_version: String,
pub essentials: Vec<EssentialsStatusItem>,
pub updates: Vec<UpdateCandidate>,
pub installed_software: Vec<Software>,
}

View File

@@ -1,369 +1,33 @@
use tauri::Manager;
pub mod commands;
pub mod domain;
pub mod providers;
pub mod services;
pub mod storage;
pub mod tasks;
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};
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<Software>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct InstallTask {
pub id: String,
pub version: Option<String>,
#[serde(default)]
pub use_manifest: bool,
pub manifest_url: Option<String>,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
repo_url: "https://karlblue.github.io/winget-repo".to_string(),
}
}
}
struct AppState {
install_tx: mpsc::Sender<InstallTask>,
}
#[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<bool, String> {
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<Software> }
let validation: Result<EssentialsRepo, _> = 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<bool, String> {
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<EssentialsRepo> {
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<Software> {
tokio::task::spawn_blocking(move || list_installed_software(&app)).await.unwrap_or_default()
}
#[tauri::command]
async fn get_updates(app: AppHandle) -> Vec<Software> {
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<LogPayload> {
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::<InstallTask>(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);
// 1. 发送正在安装状态
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<PathBuf> = 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);
// 使用 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();
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::<f32>() {
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::<f32>().unwrap_or(0.0);
let total = caps[2].parse::<f32>().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);
if exit_status { "success" } else { "error" }
},
Err(e) => {
emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error");
"error"
}
};
// 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);
}
}
});
let install_state = tasks::install_queue::create_install_state(app.handle().clone());
app.manage(install_state);
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
commands::app_commands::initialize_app,
commands::app_commands::get_settings,
commands::app_commands::save_settings,
commands::app_commands::sync_essentials,
commands::app_commands::get_essentials,
commands::app_commands::get_dashboard_snapshot,
commands::app_commands::get_essentials_status,
commands::app_commands::get_update_candidates,
commands::app_commands::get_software_icon,
tasks::install_queue::install_software,
commands::app_commands::get_logs_history
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -0,0 +1 @@
pub mod winget_client;

View File

@@ -0,0 +1,23 @@
use tauri::AppHandle;
use crate::winget::{self, Software};
pub fn ensure_environment_ready(handle: &AppHandle) -> Result<(), String> {
winget::ensure_winget_dependencies(handle)
}
pub fn list_installed_packages(handle: &AppHandle) -> Vec<Software> {
winget::list_installed_software(handle)
}
pub fn list_upgrade_candidates(handle: &AppHandle) -> Vec<Software> {
winget::list_updates(handle)
}
pub fn get_package_by_id(handle: &AppHandle, id: &str) -> Option<Software> {
winget::get_software_info(handle, id)
}
pub fn resolve_icon(handle: &AppHandle, id: &str, name: &str) -> Option<String> {
winget::get_cached_or_extract_icon(handle, id, name)
}

View File

@@ -0,0 +1,163 @@
use reqwest::Client;
use tauri::AppHandle;
use crate::domain::models::{EssentialsRepo, SyncEssentialsResult};
use crate::services::log_service::emit_log;
use crate::services::settings_service;
use crate::services::task_event_service::emit_task_event;
use crate::storage::{essentials_store, paths};
pub async fn sync_essentials(app: &AppHandle) -> Result<SyncEssentialsResult, String> {
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"running",
"starting",
0.0,
None,
Some("Starting essentials sync".to_string()),
None,
);
let settings = settings_service::get_settings(app);
let url = format!("{}/setup-essentials.json", settings.repo_url.trim_end_matches('/'));
let cache_path = paths::get_essentials_path(app);
emit_log(
app,
"sync-essentials",
"Syncing Essentials",
&format!("Downloading from {}...", url),
"info",
);
let client = 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())?;
let validation: Result<EssentialsRepo, _> = serde_json::from_str(&content);
if validation.is_ok() {
essentials_store::save_essentials(app, &content)?;
emit_log(
app,
"sync-essentials",
"Result",
"Essentials list updated successfully.",
"success",
);
Ok(SyncEssentialsResult {
status: "updated".to_string(),
message: "清单同步成功".to_string(),
})
.inspect(|_| {
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"completed",
"updated",
1.0,
None,
Some("Essentials list updated successfully".to_string()),
None,
);
})
} else {
emit_log(
app,
"sync-essentials",
"Error",
"Invalid JSON format from repository. Expected { version, essentials }.",
"error",
);
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"failed",
"invalid_json",
1.0,
None,
Some("Invalid JSON format".to_string()),
None,
);
Err("Invalid JSON format".to_string())
}
} else {
let err_msg = format!("HTTP Error: {}", response.status());
emit_log(app, "sync-essentials", "Error", &err_msg, "error");
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"failed",
"http_error",
1.0,
None,
Some(err_msg.clone()),
None,
);
Err(err_msg)
}
}
Err(e) => {
if cache_path.exists() {
emit_log(
app,
"sync-essentials",
"Skipped",
&format!("Network issue: {}. Using local cache.", e),
"info",
);
Ok(SyncEssentialsResult {
status: "cache_used".to_string(),
message: "网络不可用,已继续使用本地缓存".to_string(),
})
.inspect(|_| {
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"completed",
"cache_used",
1.0,
None,
Some("Network unavailable, used local cache".to_string()),
None,
);
})
} else {
let err_msg = format!("Network issue: {}", e);
emit_log(app, "sync-essentials", "Error", &err_msg, "error");
emit_task_event(
app,
"sync-essentials",
"sync-essentials",
"sync_essentials",
"failed",
"network_error",
1.0,
None,
Some(err_msg.clone()),
None,
);
Err(err_msg)
}
}
}
}
pub fn get_essentials(app: &AppHandle) -> Option<EssentialsRepo> {
essentials_store::load_essentials(app)
}

View File

@@ -0,0 +1,17 @@
use tauri::{AppHandle, Emitter};
use crate::domain::models::LogPayload;
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(),
},
);
}

View File

@@ -0,0 +1,6 @@
pub mod essentials_service;
pub mod log_service;
pub mod reconcile_service;
pub mod settings_service;
pub mod software_state_service;
pub mod task_event_service;

View File

@@ -0,0 +1,160 @@
use std::collections::HashMap;
use crate::domain::models::{DashboardSnapshot, EssentialsRepo, EssentialsStatusItem, UpdateCandidate};
use crate::winget::Software;
pub fn build_dashboard_snapshot(
repo: Option<EssentialsRepo>,
installed_software: Vec<Software>,
updates: Vec<Software>,
) -> DashboardSnapshot {
let essentials_version = repo
.as_ref()
.map(|item| item.version.clone())
.unwrap_or_default();
let definitions = repo.map(|item| item.essentials).unwrap_or_default();
let essentials = build_essentials_status(&definitions, &installed_software, &updates);
let update_candidates = build_update_candidates(&definitions, updates);
DashboardSnapshot {
essentials_version,
essentials,
updates: update_candidates,
installed_software,
}
}
pub fn build_essentials_status(
definitions: &[Software],
installed_software: &[Software],
updates: &[Software],
) -> Vec<EssentialsStatusItem> {
definitions
.iter()
.map(|definition| {
let installed = installed_software
.iter()
.find(|item| item.id.eq_ignore_ascii_case(&definition.id));
let update = updates
.iter()
.find(|item| item.id.eq_ignore_ascii_case(&definition.id));
let current_version = installed.and_then(|item| item.version.clone());
let recommended_version = definition.version.clone();
let available_version = update.and_then(|item| item.available_version.clone());
let (action_label, target_version) = if installed.is_some() {
match compare_versions(current_version.as_deref(), recommended_version.as_deref()) {
Some(std::cmp::Ordering::Less) => (
"更新".to_string(),
recommended_version.clone().or(available_version.clone()),
),
Some(_) => ("已安装".to_string(), None),
None => ("已安装".to_string(), None),
}
} else {
(
"安装".to_string(),
recommended_version.clone().or(available_version.clone()),
)
};
EssentialsStatusItem {
id: definition.id.clone(),
name: definition.name.clone(),
description: definition.description.clone(),
category: definition.category.clone(),
version: current_version,
recommended_version,
available_version,
icon_url: definition.icon_url.clone(),
use_manifest: definition.use_manifest,
manifest_url: definition.manifest_url.clone(),
post_install: definition.post_install.clone(),
post_install_url: definition.post_install_url.clone(),
action_label,
target_version,
}
})
.collect()
}
pub fn build_update_candidates(
definitions: &[Software],
updates: Vec<Software>,
) -> Vec<UpdateCandidate> {
let definition_map: HashMap<String, &Software> = definitions
.iter()
.map(|item| (item.id.to_ascii_lowercase(), item))
.collect();
let mut result: Vec<UpdateCandidate> = updates
.into_iter()
.map(|update| {
let definition = definition_map.get(&update.id.to_ascii_lowercase()).copied();
UpdateCandidate {
id: update.id.clone(),
name: update.name.clone(),
description: definition.and_then(|item| item.description.clone()),
category: definition.and_then(|item| item.category.clone()),
version: update.version.clone(),
available_version: update.available_version.clone(),
icon_url: update.icon_url.clone().or_else(|| definition.and_then(|item| item.icon_url.clone())),
use_manifest: definition.map(|item| item.use_manifest).unwrap_or(false),
manifest_url: definition.and_then(|item| item.manifest_url.clone()),
post_install: None,
post_install_url: None,
action_label: "更新".to_string(),
target_version: update.available_version.clone(),
}
})
.collect();
result.sort_by(|left, right| left.name.locale_compare(&right.name));
result
}
trait LocaleCompare {
fn locale_compare(&self, other: &str) -> std::cmp::Ordering;
}
impl LocaleCompare for String {
fn locale_compare(&self, other: &str) -> std::cmp::Ordering {
self.to_lowercase().cmp(&other.to_lowercase())
}
}
fn compare_versions(left: Option<&str>, right: Option<&str>) -> Option<std::cmp::Ordering> {
let left = left?;
let right = right?;
if left == right {
return Some(std::cmp::Ordering::Equal);
}
let clean = |value: &str| {
value
.trim_start_matches('v')
.trim_start_matches('V')
.split(['-', '+'])
.next()
.unwrap_or(value)
.split('.')
.map(|item| item.parse::<u32>().unwrap_or(0))
.collect::<Vec<_>>()
};
let left_parts = clean(left);
let right_parts = clean(right);
let max_len = left_parts.len().max(right_parts.len());
for index in 0..max_len {
let left_value = *left_parts.get(index).unwrap_or(&0);
let right_value = *right_parts.get(index).unwrap_or(&0);
match left_value.cmp(&right_value) {
std::cmp::Ordering::Equal => continue,
ordering => return Some(ordering),
}
}
Some(std::cmp::Ordering::Equal)
}

View File

@@ -0,0 +1,12 @@
use tauri::AppHandle;
use crate::domain::models::AppSettings;
use crate::storage::settings_store;
pub fn get_settings(app: &AppHandle) -> AppSettings {
settings_store::get_settings(app)
}
pub fn save_settings(app: &AppHandle, settings: &AppSettings) -> Result<(), String> {
settings_store::save_settings(app, settings)
}

View File

@@ -0,0 +1,85 @@
use tauri::AppHandle;
use crate::domain::models::{DashboardSnapshot, EssentialsStatusItem, UpdateCandidate};
use crate::providers::winget_client;
use crate::services::{essentials_service, reconcile_service};
use crate::services::task_event_service::emit_task_event;
pub async fn initialize_app(app: AppHandle) -> Result<bool, String> {
emit_task_event(
&app,
"env-check",
"env-check",
"initialize_app",
"running",
"checking_environment",
0.0,
None,
Some("Checking WinGet environment".to_string()),
None,
);
let app_clone = app.clone();
let result = tokio::task::spawn_blocking(move || winget_client::ensure_environment_ready(&app_clone).map(|_| true))
.await
.unwrap_or(Err("Initialization Task Panicked".to_string()));
match &result {
Ok(_) => emit_task_event(
&app,
"env-check",
"env-check",
"initialize_app",
"completed",
"ready",
1.0,
None,
Some("WinGet environment ready".to_string()),
None,
),
Err(err) => emit_task_event(
&app,
"env-check",
"env-check",
"initialize_app",
"failed",
"error",
1.0,
None,
Some(err.clone()),
None,
),
}
result
}
pub async fn get_software_icon(app: AppHandle, id: String, name: String) -> Option<String> {
tokio::task::spawn_blocking(move || winget_client::resolve_icon(&app, &id, &name))
.await
.unwrap_or(None)
}
pub async fn get_dashboard_snapshot(app: AppHandle) -> DashboardSnapshot {
let repo = essentials_service::get_essentials(&app);
let app_for_installed = app.clone();
let app_for_updates = app.clone();
let installed_handle =
tokio::task::spawn_blocking(move || winget_client::list_installed_packages(&app_for_installed));
let updates_handle =
tokio::task::spawn_blocking(move || winget_client::list_upgrade_candidates(&app_for_updates));
let installed_software = installed_handle.await.unwrap_or_default();
let updates = updates_handle.await.unwrap_or_default();
reconcile_service::build_dashboard_snapshot(repo, installed_software, updates)
}
pub async fn get_essentials_status(app: AppHandle) -> (String, Vec<EssentialsStatusItem>) {
let snapshot = get_dashboard_snapshot(app).await;
(snapshot.essentials_version, snapshot.essentials)
}
pub async fn get_update_candidates(app: AppHandle) -> Vec<UpdateCandidate> {
get_dashboard_snapshot(app).await.updates
}

View File

@@ -0,0 +1,32 @@
use tauri::{AppHandle, Emitter};
use crate::domain::models::TaskEventPayload;
use crate::winget::Software;
pub fn emit_task_event(
handle: &AppHandle,
task_id: &str,
software_id: &str,
task_type: &str,
status: &str,
stage: &str,
progress: f32,
target_version: Option<String>,
message: Option<String>,
software_info: Option<Software>,
) {
let _ = handle.emit(
"task-event",
TaskEventPayload {
task_id: task_id.to_string(),
software_id: software_id.to_string(),
task_type: task_type.to_string(),
status: status.to_string(),
stage: stage.to_string(),
progress,
target_version,
message,
software_info,
},
);
}

View File

@@ -0,0 +1,21 @@
use std::fs;
use tauri::AppHandle;
use crate::domain::models::EssentialsRepo;
use crate::storage::paths::get_essentials_path;
pub fn load_essentials(app: &AppHandle) -> Option<EssentialsRepo> {
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()
}
pub fn save_essentials(app: &AppHandle, content: &str) -> Result<(), String> {
let path = get_essentials_path(app);
fs::write(path, content).map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,3 @@
pub mod essentials_store;
pub mod paths;
pub mod settings_store;

View File

@@ -0,0 +1,20 @@
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
pub fn get_app_data_dir(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
}
pub fn get_settings_path(app: &AppHandle) -> PathBuf {
get_app_data_dir(app).join("settings.json")
}
pub fn get_essentials_path(app: &AppHandle) -> PathBuf {
get_app_data_dir(app).join("setup-essentials.json")
}

View File

@@ -0,0 +1,24 @@
use std::fs;
use tauri::AppHandle;
use crate::domain::models::AppSettings;
use crate::storage::paths::get_settings_path;
pub 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()
}
pub 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())
}

View File

@@ -0,0 +1,753 @@
use std::fs;
use std::io::{BufRead, BufReader, Read};
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use regex::Regex;
use tauri::{AppHandle, Emitter, State};
use tokio::sync::mpsc;
use winreg::enums::*;
use winreg::RegKey;
use crate::domain::models::{InstallTask, TaskEventPayload};
use crate::providers::winget_client;
use crate::services::essentials_service;
use crate::services::log_service::emit_log;
use crate::services::task_event_service;
use crate::winget::{PostInstallStep, Software};
pub struct AppState {
pub install_tx: mpsc::Sender<InstallTask>,
pub app_handle: AppHandle,
}
pub fn create_install_state(handle: AppHandle) -> AppState {
let (tx, mut rx) = mpsc::channel::<InstallTask>(100);
let runtime_handle = handle.clone();
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 enable_post_install_flag = task.enable_post_install;
let log_id = format!("install-{}", task_id);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"installing",
0.0,
task_version.clone(),
None,
None,
);
let mut args = vec!["install".to_string()];
let display_cmd: String;
let mut temp_manifest_path: Option<PathBuf> = None;
if use_manifest && manifest_url.is_some() {
let url = manifest_url.unwrap();
display_cmd = format!("Winget Install (Manifest): {} from {}", task_id, url);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"downloading_manifest",
0.0,
task_version.clone(),
Some("Downloading remote manifest".to_string()),
None,
);
emit_log(
&runtime_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(
&runtime_handle,
&log_id,
"Error",
"Failed to download or save manifest.",
"error",
);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"failed",
"manifest_error",
0.0,
task_version.clone(),
Some("Failed to download or save manifest".to_string()),
None,
);
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(
&runtime_handle,
&log_id,
&display_cmd,
&format!("Executing: {}\n---", full_command),
"info",
);
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"invoking_winget",
0.0,
task_version.clone(),
None,
None,
);
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) => {
let stdout_handle = child_proc.stdout.take().map(|stdout| {
spawn_install_stream_reader(
stdout,
runtime_handle.clone(),
log_id.clone(),
task_id.clone(),
"stdout",
perc_re.clone(),
size_re.clone(),
)
});
let stderr_handle = child_proc.stderr.take().map(|stderr| {
spawn_install_stream_reader(
stderr,
runtime_handle.clone(),
log_id.clone(),
task_id.clone(),
"stderr",
perc_re.clone(),
size_re.clone(),
)
});
let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false);
if let Some(join_handle) = stdout_handle {
let _ = join_handle.join();
}
if let Some(join_handle) = stderr_handle {
let _ = join_handle.join();
}
let status_result = if exit_status { "success" } else { "error" };
if status_result == "success" && enable_post_install_flag {
let software_info = essentials_service::get_essentials(&runtime_handle)
.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(
&runtime_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(
&runtime_handle,
&log_id,
"Post-Install",
&format!(
"Successfully fetched remote config with {} steps.",
steps.len()
),
"info",
);
final_steps = Some(steps);
}
Err(e) => {
emit_log(
&runtime_handle,
&log_id,
"Post-Install Error",
&format!("JSON Parse Error: {}. Raw Content: {}", e, text),
"error",
);
}
}
}
} else {
emit_log(
&runtime_handle,
&log_id,
"Post-Install Error",
&format!("Remote config HTTP Error: {}", resp.status()),
"error",
);
}
}
}
if let Some(steps) = final_steps {
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"running",
"configuring",
1.0,
task_version.clone(),
Some("Starting post-installation configuration".to_string()),
None,
);
emit_log(
&runtime_handle,
&log_id,
"Post-Install",
"Starting post-installation configuration...",
"info",
);
if let Err(e) = execute_post_install(&runtime_handle, &log_id, steps).await {
emit_log(&runtime_handle, &log_id, "Post-Install Error", &e, "error");
} else {
emit_log(
&runtime_handle,
&log_id,
"Post-Install",
"Post-installation configuration completed.",
"success",
);
}
}
}
}
status_result
}
Err(e) => {
emit_log(&runtime_handle, &log_id, "Fatal Error", &e.to_string(), "error");
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
"failed",
"spawn_error",
0.0,
task_version.clone(),
Some(e.to_string()),
None,
);
"error"
}
};
let resolved_software_info = if status_result == "success" {
winget_client::get_package_by_id(&runtime_handle, &task_id)
} else {
None
};
emit_task_event(
&runtime_handle,
&log_id,
&task_id,
"install",
if status_result == "success" { "completed" } else { "failed" },
status_result,
1.0,
task_version.clone(),
Some(format!("Execution finished: {}", status_result)),
resolved_software_info,
);
emit_log(
&runtime_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);
}
}
});
AppState { install_tx: tx, app_handle: handle }
}
#[tauri::command]
pub async fn install_software(
task: InstallTask,
state: State<'_, AppState>,
) -> Result<(), String> {
let log_id = format!("install-{}", task.id);
emit_task_event(
&state.app_handle,
&log_id,
&task.id,
"install",
"queued",
"queued",
0.0,
task.version.clone(),
None,
None,
);
state.install_tx.send(task).await.map_err(|e| e.to_string())
}
fn spawn_install_stream_reader<R: Read + Send + 'static>(
reader: R,
handle: AppHandle,
log_id: String,
task_id: String,
stream_name: &'static str,
perc_re: Regex,
size_re: Regex,
) -> thread::JoinHandle<()> {
thread::spawn(move || {
let reader = BufReader::new(reader);
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;
}
if stream_name == "stdout" {
let mut is_progress = false;
if let Some(caps) = perc_re.captures(clean_line) {
if let Ok(p_val) = caps[1].parse::<f32>() {
let _ = handle.emit(
"task-event",
TaskEventPayload {
task_id: log_id.clone(),
software_id: task_id.clone(),
task_type: "install".to_string(),
status: "running".to_string(),
stage: "installing".to_string(),
progress: p_val / 100.0,
target_version: None,
message: None,
software_info: None,
},
);
is_progress = true;
}
} else if let Some(caps) = size_re.captures(clean_line) {
let current = caps[1].parse::<f32>().unwrap_or(0.0);
let total = caps[2].parse::<f32>().unwrap_or(1.0);
if total > 0.0 {
let _ = handle.emit(
"task-event",
TaskEventPayload {
task_id: log_id.clone(),
software_id: task_id.clone(),
task_type: "install".to_string(),
status: "running".to_string(),
stage: "installing".to_string(),
progress: (current / total).min(1.0),
target_version: None,
message: None,
software_info: None,
},
);
is_progress = true;
}
}
if !is_progress && clean_line.chars().count() > 1 {
emit_log(&handle, &log_id, "", clean_line, "info");
}
} else {
emit_log(&handle, &log_id, stream_name, clean_line, "error");
}
}
}
})
}
fn emit_task_event(
handle: &AppHandle,
task_id: &str,
software_id: &str,
task_type: &str,
status: &str,
stage: &str,
progress: f32,
target_version: Option<String>,
message: Option<String>,
software_info: Option<Software>,
) {
task_event_service::emit_task_event(
handle,
task_id,
software_id,
task_type,
status,
stage,
progress,
target_version.clone(),
message,
software_info,
);
}
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::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(())
}

View File

@@ -0,0 +1 @@
pub mod install_queue;

View File

@@ -1,24 +1,65 @@
use serde::{Deserialize, Serialize};
use base64::Engine;
use std::fs;
use std::process::Command;
use std::os::windows::process::CommandExt;
use tauri::AppHandle;
use crate::emit_log;
use std::collections::HashMap;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use crate::services::log_service::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)]
pub struct Software {
pub id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub version: Option<String>,
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() }
@@ -139,84 +180,18 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
let script = r#"
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = 'SilentlyContinue'
Add-Type -AssemblyName System.Drawing
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
$pkgs = Get-WinGetPackage -ErrorAction SilentlyContinue | Where-Object { $_.IsUpdateAvailable }
if ($pkgs) {
$wshShell = New-Object -ComObject WScript.Shell
# 预加载开始菜单快捷方式
$startMenuPaths = @(
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs",
"$env:AppData\Microsoft\Windows\Start Menu\Programs"
)
$lnkFiles = Get-ChildItem -Path $startMenuPaths -Filter "*.lnk" -Recurse -File
# 预加载注册表项
$registryPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
$regItems = Get-ItemProperty $registryPaths
$pkgs | ForEach-Object {
$p = $_
$iconUrl = $null
$foundPath = ""
# 策略 1: 寻找并解析开始菜单快捷方式 (去除箭头关键点)
$matchedLnk = $lnkFiles | Where-Object { $_.BaseName -eq $p.Name -or $p.Name -like "*$($_.BaseName)*" } | Select-Object -First 1
if ($matchedLnk) {
try {
# 解析快捷方式指向的真实目标
$target = $wshShell.CreateShortcut($matchedLnk.FullName).TargetPath
if ($target -and (Test-Path $target) -and $target.EndsWith(".exe")) {
$foundPath = $target
} else {
# 如果目标不可读或是 UWP 快捷方式,仍使用快捷方式文件提取
$foundPath = $matchedLnk.FullName
}
} catch {
$foundPath = $matchedLnk.FullName
}
}
# 策略 2: 注册表 DisplayIcon
if (-not $foundPath) {
$matchedReg = $regItems | Where-Object { $_.DisplayName -eq $p.Name -or $_.PSChildName -eq $p.Id } | Select-Object -First 1
if ($matchedReg.DisplayIcon) {
$foundPath = $matchedReg.DisplayIcon.Split(',')[0].Trim('"')
} elseif ($matchedReg.InstallLocation) {
$loc = $matchedReg.InstallLocation.Trim('"')
if (Test-Path $loc) {
$exe = Get-ChildItem -Path $loc -Filter "*.exe" -File | Select-Object -First 1
if ($exe) { $foundPath = $exe.FullName }
}
}
}
# 提取并转 Base64
if ($foundPath -and (Test-Path $foundPath)) {
try {
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($foundPath)
$bitmap = $icon.ToBitmap()
$ms = New-Object System.IO.MemoryStream
$bitmap.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$base64 = [Convert]::ToBase64String($ms.ToArray())
$iconUrl = "data:image/png;base64,$base64"
$ms.Dispose(); $bitmap.Dispose(); $icon.Dispose()
} catch {}
}
[PSCustomObject]@{
Name = [string]$p.Name;
Id = [string]$p.Id;
InstalledVersion = [string]$p.InstalledVersion;
AvailableVersions = $p.AvailableVersions;
IconUrl = $iconUrl
Name = [string]$_.Name;
Id = [string]$_.Id;
InstalledVersion = [string]$_.InstalledVersion;
AvailableVersions = $_.AvailableVersions;
IconUrl = $null
}
} | ConvertTo-Json -Compress
} else {
@@ -227,6 +202,132 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
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()
}
pub fn get_cached_or_extract_icon(handle: &AppHandle, id: &str, name: &str) -> Option<String> {
let cache_key = sanitize_cache_key(id);
let icon_dir = get_icon_cache_dir(handle);
let icon_path = icon_dir.join(format!("{}.png", cache_key));
if let Ok(bytes) = fs::read(&icon_path) {
return Some(format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
));
}
let log_id = format!("icon-{}", cache_key);
emit_log(handle, &log_id, "Icon Lookup", &format!("Resolving icon for {}...", id), "info");
let script = format!(r#"
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = 'SilentlyContinue'
Add-Type -AssemblyName System.Drawing
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
$packageId = @'
{id}
'@
$packageName = @'
{name}
'@
$foundPath = ""
$wshShell = New-Object -ComObject WScript.Shell
$startMenuPaths = @(
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs",
"$env:AppData\Microsoft\Windows\Start Menu\Programs"
)
$lnkFiles = Get-ChildItem -Path $startMenuPaths -Filter "*.lnk" -Recurse -File
$matchedLnk = $lnkFiles | Where-Object {{ $_.BaseName -eq $packageName -or $packageName -like "*$($_.BaseName)*" }} | Select-Object -First 1
if ($matchedLnk) {{
try {{
$target = $wshShell.CreateShortcut($matchedLnk.FullName).TargetPath
if ($target -and (Test-Path $target) -and $target.EndsWith(".exe")) {{
$foundPath = $target
}} else {{
$foundPath = $matchedLnk.FullName
}}
}} catch {{
$foundPath = $matchedLnk.FullName
}}
}}
if (-not $foundPath) {{
$registryPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
$regItems = Get-ItemProperty $registryPaths
$matchedReg = $regItems | Where-Object {{ $_.DisplayName -eq $packageName -or $_.PSChildName -eq $packageId }} | Select-Object -First 1
if ($matchedReg.DisplayIcon) {{
$foundPath = $matchedReg.DisplayIcon.Split(',')[0].Trim('"')
}} elseif ($matchedReg.InstallLocation) {{
$loc = $matchedReg.InstallLocation.Trim('"')
if (Test-Path $loc) {{
$exe = Get-ChildItem -Path $loc -Filter "*.exe" -File | Select-Object -First 1
if ($exe) {{ $foundPath = $exe.FullName }}
}}
}}
}}
if ($foundPath -and (Test-Path $foundPath)) {{
try {{
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($foundPath)
if ($icon) {{
$bitmap = $icon.ToBitmap()
$ms = New-Object System.IO.MemoryStream
$bitmap.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
[Convert]::ToBase64String($ms.ToArray())
$ms.Dispose()
$bitmap.Dispose()
$icon.Dispose()
}}
}} catch {{}}
}}
"#, id = id, name = name);
let output = Command::new("powershell")
.args(["-NoProfile", "-Command", &script])
.creation_flags(0x08000000)
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let encoded = stdout.trim_start_matches('\u{feff}').trim();
if encoded.is_empty() {
return None;
}
let bytes = base64::engine::general_purpose::STANDARD.decode(encoded).ok()?;
if fs::create_dir_all(&icon_dir).is_err() {
return Some(format!("data:image/png;base64,{}", encoded));
}
let _ = fs::write(&icon_path, &bytes);
Some(format!("data:image/png;base64,{}", encoded))
}
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");
@@ -271,11 +372,23 @@ fn parse_json_output(json_str: String) -> Vec<Software> {
vec![]
}
fn get_icon_cache_dir(handle: &AppHandle) -> PathBuf {
let app_data_dir = handle.path().app_data_dir().unwrap_or_default();
app_data_dir.join("icons")
}
fn sanitize_cache_key(id: &str) -> String {
id.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
fn map_package(p: WingetPackage) -> Software {
Software {
id: p.id,
name: p.name,
description: None,
category: None,
version: p.installed_version,
available_version: p.available_versions.and_then(|v| v.first().cloned()),
icon_url: p.icon_url,
@@ -283,5 +396,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

@@ -10,7 +10,18 @@
<path d="M12 3l1.912 5.885h6.19l-5.007 3.638 1.912 5.885L12 14.77l-5.007 3.638 1.912-5.885-5.007-3.638h6.19z"></path>
</svg>
</span>
装机必备
装机常用
</router-link>
<router-link to="/other-software" class="nav-item">
<span class="nav-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="6" rx="1.5"></rect>
<rect x="3" y="14" width="18" height="6" rx="1.5"></rect>
<path d="M7 7h.01"></path>
<path d="M7 17h.01"></path>
</svg>
</span>
网传有关
</router-link>
<router-link to="/updates" class="nav-item">
<span class="nav-icon">
@@ -57,7 +68,7 @@
width: 240px;
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
padding: 40px 20px;
padding: 40px 20px 5px 20px;
display: flex;
flex-direction: column;
}
@@ -66,7 +77,7 @@
font-size: 20px;
font-weight: 700;
margin-bottom: 40px;
padding-left: 20px;
padding-left: 10px;
color: var(--text-main);
}

View File

@@ -35,7 +35,6 @@
<span class="id-badge">{{ software.id }}</span>
</div>
<div class="version-info">
<!-- 情况 1: 已安装且有推荐/最新版本 -->
<template v-if="software.version">
<span class="version-tag">当前: {{ software.version }}</span>
<span v-if="software.recommended_version && software.actionLabel === '更新'" class="version-tag recommended">
@@ -46,7 +45,6 @@
</span>
</template>
<!-- 情况 2: 未安装 -->
<template v-else>
<span v-if="software.recommended_version" class="version-tag recommended">
推荐: {{ software.recommended_version }}
@@ -64,6 +62,19 @@
<div class="card-right" v-if="actionLabel || software.status !== 'idle'">
<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
v-if="software.status === 'idle'"
@click.stop="$emit('install', software.id, (software as any).targetVersion)"
@@ -81,12 +92,15 @@
已安装
</button>
<!-- 等待中状态 -->
<div v-else-if="software.status === 'pending'" class="status-pending">
<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">
<svg viewBox="0 0 32 32" class="ring-svg">
@@ -133,6 +147,9 @@ const props = defineProps<{
progress: number;
actionLabel?: string;
targetVersion?: string;
post_install?: any;
post_install_url?: string;
enablePostInstall?: boolean;
},
actionLabel?: string,
selectable?: boolean,
@@ -140,7 +157,7 @@ const props = defineProps<{
disabled?: boolean
}>();
const emit = defineEmits(['install', 'toggleSelect']);
const emit = defineEmits(['install', 'toggleSelect', 'togglePostInstall']);
const displayProgress = computed(() => {
if (!props.software.progress) return '准备中';
@@ -288,17 +305,6 @@ const handleCardClick = () => {
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 {
display: flex;
gap: 8px;
@@ -320,10 +326,64 @@ const handleCardClick = () => {
.card-right {
margin-left: 20px;
min-width: 100px;
}
.action-wrapper {
display: flex;
align-items: center;
gap: 16px;
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 {
width: 90px;
height: 34px;
@@ -373,6 +433,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

@@ -11,6 +11,10 @@ const router = createRouter({
path: '/essentials',
component: () => import('../views/Essentials.vue')
},
{
path: '/other-software',
component: () => import('../views/OtherSoftware.vue')
},
{
path: '/updates',
component: () => import('../views/Updates.vue')

137
src/store/catalog.ts Normal file
View File

@@ -0,0 +1,137 @@
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import type { AppSettings, DashboardSnapshot, SoftwareListItem, SyncEssentialsResult, UpdateCandidate } from './types'
type EssentialsStatusResponse = [string, SoftwareListItem[]]
export const useCatalogStore = defineStore('catalog', {
state: () => ({
essentials: [] as SoftwareListItem[],
essentialsVersion: '',
updates: [] as UpdateCandidate[],
allSoftware: [] as SoftwareListItem[],
settings: {
repo_url: 'https://karlblue.github.io/winget-repo'
} as AppSettings,
loading: false,
isInitialized: false,
initStatus: '正在检查系统环境...',
lastFetched: 0
}),
actions: {
async ensureEssentialsAvailable() {
const cachedRepo = await invoke('get_essentials') as unknown
if (cachedRepo) return true
try {
await invoke('sync_essentials')
return true
} catch (err) {
console.error('Initial sync failed:', err)
return false
}
},
async initializeApp() {
if (this.isInitialized) return
this.initStatus = '正在加载应用配置...'
try {
this.settings = await invoke('get_settings')
this.initStatus = '正在同步 Winget 模块...'
await invoke('initialize_app')
this.isInitialized = true
} catch {
this.initStatus = '环境配置失败,请检查运行日志'
setTimeout(() => { this.isInitialized = true }, 2000)
}
},
async saveSettings(newSettings: AppSettings) {
await invoke('save_settings', { settings: newSettings })
this.settings = newSettings
},
async syncEssentials() {
this.loading = true
try {
const result = await invoke('sync_essentials') as SyncEssentialsResult
await this.fetchEssentials()
return result
} finally {
this.loading = false
}
},
async fetchEssentials() {
await this.ensureEssentialsAvailable()
let response = await invoke('get_essentials_status') as EssentialsStatusResponse
if (response && Array.isArray(response[1])) {
this.essentialsVersion = response[0] || ''
this.essentials = response[1]
} else {
this.essentials = []
this.essentialsVersion = ''
}
},
async fetchUpdates() {
this.loading = true
try {
const res = await invoke('get_update_candidates')
this.updates = res as UpdateCandidate[]
await this.loadIconsForUpdates()
} finally {
this.loading = false
}
},
async syncDataIfNeeded(force = false) {
const now = Date.now()
const cacheTimeout = 5 * 60 * 1000
if (!force && this.allSoftware.length > 0 && (now - this.lastFetched < cacheTimeout)) {
if (this.essentials.length === 0) await this.fetchEssentials()
return
}
await this.fetchAllData()
},
async fetchAllData() {
this.loading = true
try {
await this.ensureEssentialsAvailable()
const snapshot = await invoke('get_dashboard_snapshot') as DashboardSnapshot
this.applyDashboardSnapshot(snapshot)
await this.loadIconsForUpdates()
this.lastFetched = Date.now()
} finally {
this.loading = false
}
},
applyDashboardSnapshot(snapshot: DashboardSnapshot) {
this.essentialsVersion = snapshot.essentials_version
this.essentials = snapshot.essentials
this.updates = snapshot.updates
this.allSoftware = snapshot.installed_software
},
async loadIconsForUpdates() {
const targets = this.updates.filter(item => !item.icon_url && item.id && item.name)
await Promise.allSettled(targets.map(async (item) => {
const iconUrl = await invoke('get_software_icon', { id: item.id, name: item.name }) as string | null
if (!iconUrl) return
const target = this.updates.find(update => update.id === item.id)
if (target) {
target.icon_url = iconUrl
}
}))
},
findSoftware(id: string) {
return this.essentials.find(s => s.id === id)
|| this.updates.find(s => s.id === id)
|| this.allSoftware.find(s => s.id === id)
}
}
})

View File

@@ -1,322 +1,191 @@
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { defineStore, storeToRefs } from 'pinia'
import { computed } from 'vue'
const sortByName = (a: any, b: any) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' });
import { useCatalogStore } from './catalog'
import { useTaskRuntimeStore } from './taskRuntime'
// 版本比对工具函数
const compareVersions = (v1: string | null | undefined, v2: string | null | undefined) => {
if (!v1 || !v2) return 0;
if (v1 === v2) return 0;
const cleanV = (v: string) => v.replace(/^v/i, '').split(/[-+]/)[0].split('.');
const p1 = cleanV(v1);
const p2 = cleanV(v2);
const len = Math.max(p1.length, p2.length);
for (let i = 0; i < len; i++) {
const n1 = parseInt(p1[i] || '0', 10);
const n2 = parseInt(p2[i] || '0', 10);
if (n1 < n2) return -1;
if (n1 > n2) return 1;
}
return 0;
};
export const useSoftwareStore = defineStore('software', () => {
const catalog = useCatalogStore()
const taskRuntime = useTaskRuntimeStore()
export interface LogEntry {
id: string; // 日志唯一标识
timestamp: string;
command: string;
output: string;
status: 'info' | 'success' | 'error';
}
const {
essentials,
essentialsVersion,
updates,
allSoftware,
settings,
loading,
isInitialized,
initStatus
} = storeToRefs(catalog)
export const useSoftwareStore = defineStore('software', {
state: () => ({
essentials: [] as any[],
essentialsVersion: '',
updates: [] as any[],
allSoftware: [] as any[],
selectedEssentialIds: [] as string[],
selectedUpdateIds: [] as string[],
logs: [] as LogEntry[],
settings: {
repo_url: 'https://karlblue.github.io/winget-repo'
},
activeTasks: {} as Record<string, { status: string, progress: number, targetVersion?: string }>,
loading: false,
isInitialized: false,
initStatus: '正在检查系统环境...',
lastFetched: 0,
refreshTimer: null as any,
batchQueue: [] as string[]
}),
getters: {
mergedEssentials: (state) => {
return state.essentials.map(item => {
const installedInfo = state.allSoftware.find(s => s.id.toLowerCase() === item.id.toLowerCase());
const wingetUpdate = state.updates.find(s => s.id.toLowerCase() === item.id.toLowerCase());
const {
activeTasks,
selectedEssentialIds,
selectedSpecialIds,
selectedUpdateIds,
logs,
postInstallPrefs
} = storeToRefs(taskRuntime)
const task = state.activeTasks[item.id];
const isInstalled = !!installedInfo;
const currentVersion = installedInfo?.version;
const recommendedVersion = item.version; // 清单里的推荐版本
const availableVersion = wingetUpdate?.available_version; // Winget 查到的最新版
let displayStatus = task ? task.status : 'idle';
let actionLabel = '安装';
let targetVersion = recommendedVersion || availableVersion;
if (isInstalled) {
// 逻辑:已安装 >= 推荐 -> 已安装(禁用)
// 逻辑:已安装 < 推荐 -> 更新
const comp = compareVersions(currentVersion, recommendedVersion);
if (comp >= 0) {
displayStatus = task ? task.status : 'installed';
actionLabel = '已安装';
targetVersion = undefined; // 禁用安装
} else {
actionLabel = '更新';
targetVersion = recommendedVersion;
}
} else {
actionLabel = '安装';
targetVersion = recommendedVersion || availableVersion;
}
const mergeSoftwareItem = (item: typeof essentials.value[number]) => {
const task = activeTasks.value[item.id]
const enablePostInstall = postInstallPrefs.value[item.id] !== false
const baseStatus = item.action_label === '已安装' ? 'installed' : 'idle'
return {
...item,
version: currentVersion,
recommended_version: recommendedVersion,
available_version: availableVersion,
status: displayStatus,
version: item.version ?? undefined,
recommended_version: item.recommended_version ?? undefined,
available_version: item.available_version ?? undefined,
icon_url: item.icon_url ?? undefined,
manifest_url: item.manifest_url ?? undefined,
post_install_url: item.post_install_url ?? undefined,
category: item.category ?? 'general',
actionLabel: item.action_label,
targetVersion: item.target_version ?? undefined,
status: task ? task.status : baseStatus,
progress: task ? task.progress : 0,
actionLabel,
targetVersion // 传递给视图,用于点击安装
};
});
},
sortedUpdates: (state) => {
return [...state.updates].map(item => {
const task = state.activeTasks[item.id];
enablePostInstall
}
}
const mergedEssentials = computed(() => essentials.value.map(mergeSoftwareItem))
const categorizedEssentials = computed(() => {
return mergedEssentials.value.reduce((acc, item) => {
const category = item.category === 'special' ? 'special' : 'general'
acc[category].push(item)
return acc
}, { general: [] as typeof mergedEssentials.value, special: [] as typeof mergedEssentials.value })
})
const generalEssentials = computed(() => categorizedEssentials.value.general)
const specialEssentials = computed(() => categorizedEssentials.value.special)
const sortedUpdates = computed(() => [...updates.value].map(item => {
const task = activeTasks.value[item.id]
const enablePostInstall = postInstallPrefs.value[item.id] !== false
return {
...item,
version: item.version ?? undefined,
recommended_version: item.recommended_version ?? undefined,
available_version: item.available_version ?? undefined,
icon_url: item.icon_url ?? undefined,
manifest_url: item.manifest_url ?? undefined,
post_install_url: item.post_install_url ?? undefined,
actionLabel: item.action_label,
targetVersion: item.target_version ?? undefined,
status: task ? task.status : 'idle',
progress: task ? task.progress : 0,
actionLabel: '更新',
targetVersion: item.available_version // 更新页面永远追求最新版
};
}).sort(sortByName);
},
isBusy: (state) => {
return state.loading || Object.values(state.activeTasks).some(task =>
task.status === 'pending' || task.status === 'installing'
);
enablePostInstall
}
},
actions: {
// ... (initializeApp, saveSettings, syncEssentials stay the same)
async initializeApp() {
if (this.isInitialized) return;
this.initStatus = '正在加载应用配置...';
try {
this.settings = await invoke('get_settings');
this.initStatus = '正在同步 Winget 模块...';
await invoke('initialize_app');
this.isInitialized = true;
} catch (err) {
this.initStatus = '环境配置失败,请检查运行日志';
setTimeout(() => { this.isInitialized = true; }, 2000);
}).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' })))
const isBusy = computed(() => loading.value || taskRuntime.isTaskBusy)
const toggleSelection = (id: string, type: 'essential' | 'special' | 'update') => {
if (isBusy.value) return
taskRuntime.toggleSelection(id, type)
}
},
async saveSettings(newSettings: any) {
await invoke('save_settings', { settings: newSettings });
this.settings = newSettings;
},
async syncEssentials() {
this.loading = true;
try {
await invoke('sync_essentials');
await this.fetchEssentials();
} finally {
this.loading = false;
}
},
toggleSelection(id: string, type: 'essential' | 'update') {
if (this.isBusy) return;
const list = type === 'essential' ? this.selectedEssentialIds : this.selectedUpdateIds;
const index = list.indexOf(id);
if (index === -1) list.push(id);
else list.splice(index, 1);
},
selectAll(type: 'essential' | 'update') {
const selectAll = (type: 'essential' | 'special' | 'update') => {
if (type === 'essential') {
const selectable = this.mergedEssentials.filter(s => s.actionLabel !== '已安装');
this.selectedEssentialIds = selectable.map(s => s.id);
const selectable = generalEssentials.value.filter(item => item.actionLabel !== '已安装')
taskRuntime.setSelection('essential', selectable.map(item => item.id))
} else if (type === 'special') {
const selectable = specialEssentials.value.filter(item => item.actionLabel !== '已安装')
taskRuntime.setSelection('special', selectable.map(item => item.id))
} else {
this.selectedUpdateIds = this.updates.map(s => s.id);
taskRuntime.setSelection('update', updates.value.map(item => item.id))
}
},
deselectAll(type: 'essential' | 'update') {
if (type === 'essential') this.selectedEssentialIds = [];
else this.selectedUpdateIds = [];
},
invertSelection(type: 'essential' | 'update') {
}
const deselectAll = (type: 'essential' | 'special' | 'update') => {
taskRuntime.setSelection(type, [])
}
const invertSelection = (type: 'essential' | 'special' | 'update') => {
if (type === 'essential') {
const selectable = this.mergedEssentials.filter(s => s.actionLabel !== '已安装').map(s => s.id);
this.selectedEssentialIds = selectable.filter(id => !this.selectedEssentialIds.includes(id));
const selectable = generalEssentials.value
.filter(item => item.actionLabel !== '已安装')
.map(item => item.id)
taskRuntime.setSelection(
'essential',
selectable.filter(id => !selectedEssentialIds.value.includes(id))
)
} else if (type === 'special') {
const selectable = specialEssentials.value
.filter(item => item.actionLabel !== '已安装')
.map(item => item.id)
taskRuntime.setSelection(
'special',
selectable.filter(id => !selectedSpecialIds.value.includes(id))
)
} else {
const selectable = this.updates.map(s => s.id);
this.selectedUpdateIds = selectable.filter(id => !this.selectedUpdateIds.includes(id));
const selectable = updates.value.map(item => item.id)
taskRuntime.setSelection(
'update',
selectable.filter(id => !selectedUpdateIds.value.includes(id))
)
}
},
async fetchEssentials() {
let repo = await invoke('get_essentials') as any;
if (!repo) {
try {
await invoke('sync_essentials');
repo = await invoke('get_essentials') as any;
} catch (err) {
console.error('Initial sync failed:', err);
}
}
if (repo) {
this.essentials = repo.essentials;
this.essentialsVersion = repo.version;
} else {
this.essentials = [];
this.essentialsVersion = '';
}
},
async fetchUpdates() {
if (this.isBusy) return;
this.loading = true
try {
const res = await invoke('get_updates')
this.updates = res as any[]
if (this.selectedUpdateIds.length === 0) this.selectAll('update');
} finally {
this.loading = false
}
},
async syncDataIfNeeded(force = false) {
if (this.isBusy) return;
const now = Date.now();
const CACHE_TIMEOUT = 5 * 60 * 1000;
if (!force && this.allSoftware.length > 0 && (now - this.lastFetched < CACHE_TIMEOUT)) {
if (this.essentials.length === 0) await this.fetchEssentials();
return;
}
await this.fetchAllData();
},
async fetchAllData() {
this.loading = true;
try {
await this.fetchEssentials();
const [all, updates] = await Promise.all([
invoke('get_installed_software'),
invoke('get_updates')
]);
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;
}
},
async install(id: string, targetVersion?: string) {
const software = this.findSoftware(id)
if (software) {
this.activeTasks[id] = { status: 'pending', progress: 0, targetVersion };
try {
await invoke('install_software', {
task: {
id,
version: targetVersion,
use_manifest: software.use_manifest || false,
manifest_url: software.manifest_url || null
}
})
} catch (err) {
console.error('Invoke install failed:', err);
this.activeTasks[id] = { status: 'error', progress: 0 };
}
}
},
// 注册批量任务
startBatch(ids: string[]) {
this.batchQueue = [...ids];
},
// 引入防抖刷新:仅在批量任务全部处理完后的 2 秒执行全量扫描
scheduleDataRefresh() {
if (this.refreshTimer) clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(async () => {
await this.fetchAllData();
// 刷新完成后清理所有已终结(成功或失败)的任务状态快照
Object.keys(this.activeTasks).forEach(id => {
const status = this.activeTasks[id].status;
if (status === 'success' || status === 'error') {
delete this.activeTasks[id];
}
});
this.refreshTimer = null;
}, 2000);
},
findSoftware(id: string) {
return this.essentials.find(s => s.id === id) ||
this.updates.find(s => s.id === id) ||
this.allSoftware.find(s => s.id === id)
},
initListener() {
if ((window as any).__tauri_listener_init) return;
(window as any).__tauri_listener_init = true;
listen('install-status', (event: any) => {
const { id, status, progress } = event.payload
const task = this.activeTasks[id];
this.activeTasks[id] = { status, progress, targetVersion: task?.targetVersion };
// 当任务达到终态(成功或失败)时
if (status === 'success' || status === 'error') {
if (status === 'success') {
this.lastFetched = 0;
// 立即更新勾选状态,提升响应感
this.selectedEssentialIds = this.selectedEssentialIds.filter(i => i !== id);
this.selectedUpdateIds = this.selectedUpdateIds.filter(i => i !== id);
}
// 检查是否属于正在进行的批量任务
const index = this.batchQueue.indexOf(id);
if (index !== -1) {
this.batchQueue.splice(index, 1);
// 如果这是批量任务中的最后一个,则触发延迟刷新
if (this.batchQueue.length === 0) {
this.scheduleDataRefresh();
const fetchUpdates = async () => {
if (isBusy.value) return
await catalog.fetchUpdates()
if (selectedUpdateIds.value.length === 0) selectAll('update')
}
}
}
})
listen('log-event', (event: any) => {
const payload = event.payload as LogEntry;
const existingLog = this.logs.find(l => l.id === payload.id);
if (existingLog) {
if (payload.output) existingLog.output += '\n' + payload.output;
if (payload.status !== 'info') existingLog.status = payload.status;
} else {
this.logs.unshift(payload);
if (this.logs.length > 100) this.logs.pop();
const syncDataIfNeeded = async (force = false) => {
if (isBusy.value) return
await catalog.syncDataIfNeeded(force)
if (selectedEssentialIds.value.length === 0) selectAll('essential')
if (selectedSpecialIds.value.length === 0) selectAll('special')
}
})
const fetchAllData = async () => {
await catalog.fetchAllData()
if (selectedEssentialIds.value.length === 0) selectAll('essential')
if (selectedSpecialIds.value.length === 0) selectAll('special')
}
return {
essentials,
essentialsVersion,
updates,
allSoftware,
selectedEssentialIds,
selectedSpecialIds,
selectedUpdateIds,
logs,
settings,
activeTasks,
loading,
isInitialized,
initStatus,
mergedEssentials,
generalEssentials,
specialEssentials,
sortedUpdates,
isBusy,
initializeApp: catalog.initializeApp,
saveSettings: catalog.saveSettings,
syncEssentials: catalog.syncEssentials,
fetchEssentials: catalog.fetchEssentials,
fetchUpdates,
syncDataIfNeeded,
fetchAllData,
install: taskRuntime.install,
togglePostInstallPref: taskRuntime.togglePostInstallPref,
startBatch: taskRuntime.startBatch,
scheduleDataRefresh: taskRuntime.scheduleDataRefresh,
findSoftware: catalog.findSoftware,
initListener: taskRuntime.initListener,
toggleSelection,
selectAll,
deselectAll,
invertSelection
}
})

206
src/store/taskRuntime.ts Normal file
View File

@@ -0,0 +1,206 @@
import { defineStore } from 'pinia'
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { useCatalogStore } from './catalog'
import type { ActiveTaskState, LogEntry, TaskEventPayload, TaskRecord } from './types'
export const useTaskRuntimeStore = defineStore('task-runtime', {
state: () => ({
taskRecords: {} as Record<string, TaskRecord>,
selectedEssentialIds: [] as string[],
selectedSpecialIds: [] as string[],
selectedUpdateIds: [] as string[],
logs: [] as LogEntry[],
refreshTimer: null as ReturnType<typeof setTimeout> | null,
batchQueue: [] as string[],
postInstallPrefs: {} as Record<string, boolean>
}),
getters: {
activeTasks: (state): Record<string, ActiveTaskState> => {
return Object.values(state.taskRecords).reduce<Record<string, ActiveTaskState>>((acc, task) => {
acc[task.softwareId] = {
status: mapTaskToLegacyStatus(task),
progress: task.progress,
targetVersion: task.targetVersion
}
return acc
}, {})
},
isTaskBusy(): boolean {
return Object.values(this.taskRecords).some(task =>
task.status === 'queued' || task.status === 'running'
)
}
},
actions: {
toggleSelection(id: string, type: 'essential' | 'special' | 'update') {
const list = type === 'essential'
? this.selectedEssentialIds
: type === 'special'
? this.selectedSpecialIds
: this.selectedUpdateIds
const index = list.indexOf(id)
if (index === -1) list.push(id)
else list.splice(index, 1)
},
setSelection(type: 'essential' | 'special' | 'update', ids: string[]) {
if (type === 'essential') this.selectedEssentialIds = ids
else if (type === 'special') this.selectedSpecialIds = ids
else this.selectedUpdateIds = ids
},
togglePostInstallPref(id: string) {
const current = this.postInstallPrefs[id] !== false
this.postInstallPrefs[id] = !current
},
startBatch(ids: string[]) {
this.batchQueue = [...ids]
},
async install(id: string, targetVersion?: string) {
const catalog = useCatalogStore()
const updateSoftware = catalog.updates.find(item => item.id.toLowerCase() === id.toLowerCase())
const software = updateSoftware ?? catalog.findSoftware(id)
if (!software) return
const enablePostInstall = updateSoftware ? false : this.postInstallPrefs[id] !== false
try {
await invoke('install_software', {
task: {
id,
version: targetVersion,
use_manifest: software.use_manifest || false,
manifest_url: software.manifest_url || null,
enable_post_install: enablePostInstall
}
})
} catch (err) {
console.error('Invoke install failed:', err)
this.taskRecords[`install-${id}`] = {
taskId: `install-${id}`,
softwareId: id,
taskType: 'install',
status: 'failed',
stage: 'invoke_error',
progress: 0,
targetVersion,
message: String(err)
}
}
},
scheduleDataRefresh() {
const catalog = useCatalogStore()
if (this.refreshTimer) clearTimeout(this.refreshTimer)
this.refreshTimer = setTimeout(async () => {
await catalog.fetchAllData()
Object.keys(this.taskRecords).forEach(taskId => {
const status = this.taskRecords[taskId].status
if (status === 'completed' || status === 'failed') {
delete this.taskRecords[taskId]
}
})
this.refreshTimer = null
}, 2000)
},
initListener() {
if ((window as Window & { __tauri_listener_init?: boolean }).__tauri_listener_init) return
;(window as Window & { __tauri_listener_init?: boolean }).__tauri_listener_init = true
listen('task-event', async (event: { payload: TaskEventPayload }) => {
const catalog = useCatalogStore()
const payload = event.payload
const taskRecord: TaskRecord = {
taskId: payload.task_id,
softwareId: payload.software_id,
taskType: payload.task_type,
status: payload.status,
stage: payload.stage,
progress: payload.progress,
targetVersion: payload.target_version ?? undefined,
message: payload.message ?? undefined
}
this.taskRecords[payload.task_id] = taskRecord
if (payload.status === 'completed' || payload.status === 'failed') {
if (payload.task_type === 'install' && payload.status === 'completed') {
const latestInfo = payload.software_info
if (latestInfo) {
const installedIndex = catalog.allSoftware.findIndex(s => s.id.toLowerCase() === payload.software_id.toLowerCase())
if (installedIndex !== -1) {
catalog.allSoftware[installedIndex] = { ...catalog.allSoftware[installedIndex], ...latestInfo }
} else {
catalog.allSoftware.push(latestInfo)
}
const essentialIndex = catalog.essentials.findIndex(s => s.id.toLowerCase() === payload.software_id.toLowerCase())
if (essentialIndex !== -1) {
catalog.essentials[essentialIndex] = {
...catalog.essentials[essentialIndex],
version: latestInfo.version,
available_version: undefined,
action_label: '已安装',
target_version: undefined
}
}
catalog.updates = catalog.updates.filter(item => item.id.toLowerCase() !== payload.software_id.toLowerCase())
}
this.selectedEssentialIds = this.selectedEssentialIds.filter(item => item !== payload.software_id)
this.selectedSpecialIds = this.selectedSpecialIds.filter(item => item !== payload.software_id)
this.selectedUpdateIds = this.selectedUpdateIds.filter(item => item !== payload.software_id)
setTimeout(() => {
if (this.taskRecords[payload.task_id]?.status === 'completed') {
delete this.taskRecords[payload.task_id]
}
}, 3000)
} else if (payload.task_type !== 'install') {
setTimeout(() => {
if (this.taskRecords[payload.task_id]?.status === payload.status) {
delete this.taskRecords[payload.task_id]
}
}, 1500)
}
if (payload.task_type === 'install') {
const index = this.batchQueue.indexOf(payload.software_id)
if (index !== -1) {
this.batchQueue.splice(index, 1)
if (this.batchQueue.length === 0) {
this.scheduleDataRefresh()
}
}
}
}
})
listen('log-event', (event: { payload: LogEntry }) => {
const payload = event.payload
const existingLog = this.logs.find(item => item.id === payload.id)
if (existingLog) {
if (payload.output) existingLog.output += '\n' + payload.output
if (payload.status !== 'info') existingLog.status = payload.status
} else {
this.logs.unshift(payload)
if (this.logs.length > 100) this.logs.pop()
}
})
}
}
})
function mapTaskToLegacyStatus(task: TaskRecord): string {
if (task.status === 'queued') return 'pending'
if (task.status === 'completed') return 'success'
if (task.status === 'failed') return 'error'
if (task.stage === 'configuring') return 'configuring'
return 'installing'
}

76
src/store/types.ts Normal file
View File

@@ -0,0 +1,76 @@
export interface LogEntry {
id: string
timestamp: string
command: string
output: string
status: 'info' | 'success' | 'error'
}
export interface SyncEssentialsResult {
status: 'updated' | 'cache_used'
message: string
}
export interface DashboardSnapshot {
essentials_version: string
essentials: SoftwareListItem[]
updates: UpdateCandidate[]
installed_software: SoftwareListItem[]
}
export interface SoftwareListItem {
id: string
name: string
description?: string
category?: string | null
version?: string | null
recommended_version?: string | null
available_version?: string | null
icon_url?: string | null
use_manifest?: boolean
manifest_url?: string | null
post_install?: unknown
post_install_url?: string | null
actionLabel?: string
action_label?: string
targetVersion?: string | null
target_version?: string | null
}
export interface UpdateCandidate extends SoftwareListItem {
action_label: string
target_version?: string | null
}
export interface ActiveTaskState {
status: string
progress: number
targetVersion?: string
}
export interface TaskRecord {
taskId: string
softwareId: string
taskType: string
status: string
stage: string
progress: number
targetVersion?: string
message?: string
}
export interface TaskEventPayload {
task_id: string
software_id: string
task_type: string
status: string
stage: string
progress: number
target_version?: string | null
message?: string | null
software_info?: SoftwareListItem | null
}
export interface AppSettings {
repo_url: string
}

View File

@@ -1,8 +1,10 @@
<template>
<main class="content">
<!-- 固定标头区域 -->
<div class="sticky-header">
<header class="content-header">
<div class="header-left">
<h1>装机必备</h1>
<h1>装机常用</h1>
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
</div>
<div class="header-actions">
@@ -44,15 +46,18 @@
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
</div>
</div>
</div>
<div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state">
<!-- 可滚动内容区域 -->
<div class="scroll-content">
<div v-if="store.loading && store.generalEssentials.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在读取必备软件列表...</p>
<p>正在读取软件列表...</p>
</div>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.mergedEssentials"
v-for="item in store.generalEssentials"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
@@ -60,9 +65,11 @@
:is-selected="store.selectedEssentialIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="id => store.toggleSelection(id, 'essential')"
@toggle-select="store.toggleSelection($event, 'essential')"
@toggle-post-install="store.togglePostInstallPref"
/>
</div>
</div>
</main>
</template>
@@ -74,14 +81,14 @@ import { onMounted, computed } from 'vue';
const store = useSoftwareStore();
const selectableItems = computed(() => {
return store.mergedEssentials.filter(s => s.status !== 'installed');
return store.generalEssentials.filter(s => s.status !== 'installed');
});
const installSelected = () => {
const ids = [...store.selectedEssentialIds];
store.startBatch(ids);
ids.forEach(id => {
const item = store.mergedEssentials.find(s => s.id === id);
const item = store.generalEssentials.find(s => s.id === id);
if (item) {
store.install(id, item.targetVersion);
}
@@ -97,15 +104,30 @@ onMounted(() => {
<style scoped>
.content {
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;
padding: 8px 60px 32px 60px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
margin-bottom: 16px;
}
.header-left {
@@ -136,16 +158,16 @@ onMounted(() => {
align-items: center;
}
/* 批量选择工具栏 */
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: rgba(0, 0, 0, 0.02);
background-color: white; /* 这里的工具栏背景设为白色更清晰 */
border-radius: 12px;
margin-bottom: 20px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.selection-count {

293
src/views/OtherSoftware.vue Normal file
View File

@@ -0,0 +1,293 @@
<template>
<main class="content">
<div class="sticky-header">
<header class="content-header">
<div class="header-left">
<h1>网传有关</h1>
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
</div>
<div class="header-actions">
<button
@click="store.syncDataIfNeeded(true)"
class="secondary-btn action-btn"
:disabled="store.loading || store.isBusy"
>
<span class="icon" :class="{ spinning: store.loading }">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6"></path>
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
<path d="M3 22v-6h6"></path>
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
</svg>
</span>
{{ store.loading ? '正在刷新...' : '刷新状态' }}
</button>
<button
@click="installSelected"
class="primary-btn action-btn"
:disabled="store.loading || store.isBusy || store.selectedSpecialIds.length === 0"
>
安装所选 ({{ store.selectedSpecialIds.length }})
</button>
</div>
</header>
<div class="selection-toolbar" v-if="selectableItems.length > 0">
<div class="toolbar-left">
<span class="selection-count">已选 {{ store.selectedSpecialIds.length }} / {{ selectableItems.length }} </span>
</div>
<div class="toolbar-actions">
<button @click="store.selectAll('special')" class="text-btn" :disabled="store.isBusy">全选</button>
<div class="divider"></div>
<button @click="store.deselectAll('special')" class="text-btn" :disabled="store.isBusy">取消</button>
<div class="divider"></div>
<button @click="store.invertSelection('special')" class="text-btn" :disabled="store.isBusy">反选</button>
</div>
</div>
</div>
<div class="scroll-content">
<div v-if="store.loading && store.specialEssentials.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在读取软件列表...</p>
</div>
<div v-else-if="store.specialEssentials.length === 0" class="empty-state">
<p>当前清单没有标记为网传有关的项目</p>
</div>
<div v-else class="software-list">
<SoftwareCard
v-for="item in store.specialEssentials"
:key="item.id"
:software="item"
:action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedSpecialIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="store.toggleSelection($event, 'special')"
@toggle-post-install="store.togglePostInstallPref"
/>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import SoftwareCard from '../components/SoftwareCard.vue'
import { useSoftwareStore } from '../store/software'
const store = useSoftwareStore()
const selectableItems = computed(() => {
return store.specialEssentials.filter(item => item.status !== 'installed')
})
const installSelected = () => {
const ids = [...store.selectedSpecialIds]
store.startBatch(ids)
ids.forEach(id => {
const item = store.specialEssentials.find(software => software.id === id)
if (item) {
store.install(id, item.targetVersion)
}
})
}
onMounted(() => {
store.syncDataIfNeeded()
store.initListener()
})
</script>
<style scoped>
.content {
flex: 1;
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;
padding: 8px 60px 32px 60px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: baseline;
gap: 12px;
}
.version-badge {
font-size: 13px;
font-weight: 500;
color: var(--text-sec);
background-color: rgba(0, 0, 0, 0.05);
padding: 4px 10px;
border-radius: 20px;
}
.content-header h1 {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.5px;
color: var(--text-main);
}
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: white;
border-radius: 12px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.selection-count {
font-size: 13px;
color: var(--text-sec);
font-weight: 500;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
.text-btn {
background: none;
border: none;
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.2s ease;
}
.text-btn:hover:not(:disabled) {
background-color: rgba(0, 122, 255, 0.08);
}
.text-btn:disabled {
color: #AEAEB2;
cursor: not-allowed;
}
.divider {
width: 1px;
height: 12px;
background-color: var(--border-color);
}
.action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 12px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
border: none;
white-space: nowrap;
}
.primary-btn {
background-color: var(--primary-color);
color: white;
box-shadow: var(--btn-shadow);
}
.primary-btn:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.secondary-btn {
background-color: var(--bg-light);
color: var(--text-main);
border: 1px solid var(--border-color);
}
.secondary-btn:hover:not(:disabled) {
background-color: white;
border-color: var(--primary-color);
color: var(--primary-color);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.icon {
display: flex;
align-items: center;
}
.icon.spinning {
animation: spin 1s linear infinite;
}
.software-list {
display: flex;
flex-direction: column;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: var(--text-sec);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(0, 122, 255, 0.1);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -12,7 +12,7 @@
<div class="setting-item">
<div class="setting-info">
<label>仓库地址</label>
<p>应用将从该地址同步装机必备软件清单</p>
<p>应用将从该地址同步软件清单</p>
</div>
<div class="setting-action">
<input
@@ -47,8 +47,8 @@
<section class="settings-section">
<h3 class="section-title">关于</h3>
<div class="settings-card about-card">
<p>Windows 软件管理 v{{ version }}</p>
<p class="hint">基于 Tauri WinGet 构建的 Windows 软件管理工具</p>
<p>Windows 软件管理 v{{ version }}</p>
<p class="hint">基于 WinGet 构建的 Windows 软件管理工具</p>
</div>
</section>
</div>
@@ -107,8 +107,8 @@ const handleSave = async () => {
const handleSync = async () => {
try {
await store.syncEssentials()
showToast('清单同步成功')
const result = await store.syncEssentials()
showToast(result.message, result.status === 'updated' ? 'success' : 'error')
} catch (err) {
showToast('同步失败,请检查网络或地址', 'error')
}

View File

@@ -1,5 +1,7 @@
<template>
<main class="content">
<!-- 固定标头区域 -->
<div class="sticky-header">
<header class="content-header">
<div class="header-left">
<h1>软件更新</h1>
@@ -43,7 +45,10 @@
<button @click="store.invertSelection('update')" class="text-btn" :disabled="store.isBusy">反选</button>
</div>
</div>
</div>
<!-- 可滚动内容区域 -->
<div class="scroll-content">
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
<div class="spinner"></div>
<p>正在使用 Winget 扫描可用的更新...</p>
@@ -59,14 +64,16 @@
v-for="item in store.sortedUpdates"
:key="item.id"
:software="item"
action-label="更新"
:action-label="item.actionLabel"
:selectable="true"
:is-selected="store.selectedUpdateIds.includes(item.id)"
:disabled="store.isBusy"
@install="store.install"
@toggle-select="id => store.toggleSelection(id, 'update')"
@toggle-select="store.toggleSelection($event, 'update')"
@toggle-post-install="store.togglePostInstallPref"
/>
</div>
</div>
</main>
</template>
@@ -98,15 +105,30 @@ onMounted(() => {
<style scoped>
.content {
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;
padding: 8px 60px 32px 60px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
margin-bottom: 16px;
}
.content-header h1 {
@@ -121,16 +143,16 @@ onMounted(() => {
align-items: center;
}
/* 批量选择工具栏 */
.selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
background-color: rgba(0, 0, 0, 0.02);
background-color: white;
border-radius: 12px;
margin-bottom: 20px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
}
.selection-count {