Compare commits
23 Commits
9aa6f9cd1d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52cf6736cf | ||
|
|
cfe93144e6 | ||
|
|
87ffe2e243 | ||
|
|
72d878d221 | ||
|
|
708df41063 | ||
|
|
73976d1367 | ||
|
|
2625c8b52f | ||
|
|
0fc523e234 | ||
|
|
db377852fc | ||
|
|
2aaa330c9a | ||
|
|
fe86431899 | ||
|
|
bba113e089 | ||
|
|
ff238eb534 | ||
|
|
886f513b5d | ||
|
|
8067cc870f | ||
|
|
fbdfcc8abe | ||
|
|
86df026091 | ||
|
|
fd8241fd43 | ||
|
|
66b6ac4738 | ||
|
|
1d53f42d10 | ||
|
|
c230847cc0 | ||
|
|
dac6f6cd62 | ||
|
|
04e4a510e5 |
118
scripts/convert-reg.ps1
Normal file
118
scripts/convert-reg.ps1
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
将 Windows .reg 文件转换为 win-softmgr 所需的 post_install JSON 格式。
|
||||||
|
.EXAMPLE
|
||||||
|
.\convert-reg.ps1 -Path .\adobe.reg
|
||||||
|
#>
|
||||||
|
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$Path,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$false)]
|
||||||
|
[string]$OutputPath
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path $Path)) {
|
||||||
|
Write-Error "文件不存在: $Path"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||||
|
$fileInfo = Get-Item $Path
|
||||||
|
$OutputPath = Join-Path $fileInfo.DirectoryName ($fileInfo.BaseName + ".json")
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用 -Raw 读取并自动检测编码,然后按行拆分
|
||||||
|
$content = Get-Content $Path -Raw
|
||||||
|
$lines = $content -split "\r?\n"
|
||||||
|
|
||||||
|
$results = @()
|
||||||
|
$currentBatch = $null
|
||||||
|
|
||||||
|
foreach ($line in $lines) {
|
||||||
|
$line = $line.Trim()
|
||||||
|
if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith("Windows Registry Editor")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# 匹配 [HKEY_...] 路径
|
||||||
|
if ($line.StartsWith("[") -and $line.EndsWith("]")) {
|
||||||
|
# 检查是否是删除整个 Key 的语法:[-HKEY_...]
|
||||||
|
$isDeleteKey = $line.StartsWith("[-")
|
||||||
|
$fullPath = if ($isDeleteKey) { $line.Substring(2, $line.Length - 3) } else { $line.Substring(1, $line.Length - 2) }
|
||||||
|
|
||||||
|
$root = ""
|
||||||
|
$basePath = ""
|
||||||
|
|
||||||
|
if ($fullPath -match "^HKEY_CURRENT_USER(\\.*)?$") {
|
||||||
|
$root = "HKCU"
|
||||||
|
$basePath = if ($fullPath.Length -gt 17) { $fullPath.Substring(18) } else { "" }
|
||||||
|
} elseif ($fullPath -match "^HKEY_LOCAL_MACHINE(\\.*)?$") {
|
||||||
|
$root = "HKLM"
|
||||||
|
$basePath = if ($fullPath.Length -gt 18) { $fullPath.Substring(19) } else { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($root -ne "") {
|
||||||
|
$currentBatch = [ordered]@{
|
||||||
|
type = "registry_batch"
|
||||||
|
root = $root
|
||||||
|
base_path = $basePath
|
||||||
|
# 如果是删除整个 Key,我们可以在这里记录或者扩展 schema
|
||||||
|
# 但目前我们先处理 Value 删除
|
||||||
|
values = [ordered]@{}
|
||||||
|
}
|
||||||
|
$results += $currentBatch
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# 匹配 "Name"=Value
|
||||||
|
if ($line -match '^"(.+)"\s*=\s*(.+)$') {
|
||||||
|
$name = $Matches[1]
|
||||||
|
$rawVal = $Matches[2]
|
||||||
|
$vType = ""
|
||||||
|
$data = $null
|
||||||
|
|
||||||
|
if ($rawVal -eq "-") {
|
||||||
|
# 处理删除 Value 的逻辑: "Key"=-
|
||||||
|
$vType = "Delete"
|
||||||
|
$data = $null
|
||||||
|
} elseif ($rawVal.StartsWith("dword:")) {
|
||||||
|
$vType = "Dword"
|
||||||
|
$hex = $rawVal.Substring(6)
|
||||||
|
$data = [Convert]::ToInt32($hex, 16)
|
||||||
|
} elseif ($rawVal.StartsWith('"') -and $rawVal.EndsWith('"')) {
|
||||||
|
$vType = "String"
|
||||||
|
$data = $rawVal.Substring(1, $rawVal.Length - 2).Replace("\\", "\")
|
||||||
|
} elseif ($rawVal.StartsWith("hex(7):")) {
|
||||||
|
$vType = "MultiString"
|
||||||
|
$hexBytes = $rawVal.Substring(7).Split(',') | ForEach-Object { [Convert]::ToByte($_, 16) }
|
||||||
|
$decoded = [System.Text.Encoding]::Unicode.GetString($hexBytes)
|
||||||
|
$data = $decoded.Split("`0", [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||||
|
} elseif ($rawVal.StartsWith("hex(b):")) {
|
||||||
|
$vType = "Qword"
|
||||||
|
$hexBytes = $rawVal.Substring(7).Split(',') | ForEach-Object { $_ }
|
||||||
|
if ($hexBytes.Count -ge 8) {
|
||||||
|
$hexStr = ($hexBytes[7,6,5,4,3,2,1,0] -join "")
|
||||||
|
$data = [Convert]::ToInt64($hexStr, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -ne $currentBatch -and $vType -ne "") {
|
||||||
|
$currentBatch.values[$name] = [ordered]@{
|
||||||
|
v_type = $vType
|
||||||
|
data = $data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($results.Count -eq 0) {
|
||||||
|
Write-Warning "未在文件中识别到有效的注册表项。"
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsonOutput = ConvertTo-Json $results -Depth 10
|
||||||
|
[System.IO.File]::WriteAllText($OutputPath, $jsonOutput, [System.Text.Encoding]::UTF8)
|
||||||
|
|
||||||
|
Write-Host "转换成功!结果已保存至: $OutputPath" -ForegroundColor Green
|
||||||
1
src-tauri/.gitignore
vendored
1
src-tauri/.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Generated by Cargo
|
# Generated by Cargo
|
||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
/target/
|
/target/
|
||||||
|
/target*/
|
||||||
|
|
||||||
# Generated by Tauri
|
# Generated by Tauri
|
||||||
# will have schema files for capabilities auto-completion
|
# will have schema files for capabilities auto-completion
|
||||||
|
|||||||
15
src-tauri/Cargo.lock
generated
15
src-tauri/Cargo.lock
generated
@@ -859,7 +859,7 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
"vswhom",
|
"vswhom",
|
||||||
"winreg",
|
"winreg 0.55.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5108,6 +5108,7 @@ dependencies = [
|
|||||||
name = "win-softmgr"
|
name = "win-softmgr"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -5117,6 +5118,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"winreg 0.56.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5592,6 +5594,17 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.56.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"serde",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ tokio = { version = "1.50.0", features = ["full"] }
|
|||||||
chrono = "0.4.44"
|
chrono = "0.4.44"
|
||||||
regex = "1.12.3"
|
regex = "1.12.3"
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||||
|
winreg = { version = "0.56.0", features = ["serde"] }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
|||||||
57
src-tauri/src/commands/app_commands.rs
Normal file
57
src-tauri/src/commands/app_commands.rs
Normal 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![]
|
||||||
|
}
|
||||||
1
src-tauri/src/commands/mod.rs
Normal file
1
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod app_commands;
|
||||||
1
src-tauri/src/domain/mod.rs
Normal file
1
src-tauri/src/domain/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod models;
|
||||||
114
src-tauri/src/domain/models.rs
Normal file
114
src-tauri/src/domain/models.rs
Normal 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>,
|
||||||
|
}
|
||||||
@@ -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;
|
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() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let handle = app.handle().clone();
|
let install_state = tasks::install_queue::create_install_state(app.handle().clone());
|
||||||
let (tx, mut rx) = mpsc::channel::<InstallTask>(100);
|
app.manage(install_state);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
initialize_app,
|
commands::app_commands::initialize_app,
|
||||||
get_settings,
|
commands::app_commands::get_settings,
|
||||||
save_settings,
|
commands::app_commands::save_settings,
|
||||||
sync_essentials,
|
commands::app_commands::sync_essentials,
|
||||||
get_essentials,
|
commands::app_commands::get_essentials,
|
||||||
get_installed_software,
|
commands::app_commands::get_dashboard_snapshot,
|
||||||
get_updates,
|
commands::app_commands::get_essentials_status,
|
||||||
install_software,
|
commands::app_commands::get_update_candidates,
|
||||||
get_logs_history
|
commands::app_commands::get_software_icon,
|
||||||
|
tasks::install_queue::install_software,
|
||||||
|
commands::app_commands::get_logs_history
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
1
src-tauri/src/providers/mod.rs
Normal file
1
src-tauri/src/providers/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod winget_client;
|
||||||
23
src-tauri/src/providers/winget_client.rs
Normal file
23
src-tauri/src/providers/winget_client.rs
Normal 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)
|
||||||
|
}
|
||||||
163
src-tauri/src/services/essentials_service.rs
Normal file
163
src-tauri/src/services/essentials_service.rs
Normal 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)
|
||||||
|
}
|
||||||
17
src-tauri/src/services/log_service.rs
Normal file
17
src-tauri/src/services/log_service.rs
Normal 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(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src-tauri/src/services/mod.rs
Normal file
6
src-tauri/src/services/mod.rs
Normal 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;
|
||||||
160
src-tauri/src/services/reconcile_service.rs
Normal file
160
src-tauri/src/services/reconcile_service.rs
Normal 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)
|
||||||
|
}
|
||||||
12
src-tauri/src/services/settings_service.rs
Normal file
12
src-tauri/src/services/settings_service.rs
Normal 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)
|
||||||
|
}
|
||||||
85
src-tauri/src/services/software_state_service.rs
Normal file
85
src-tauri/src/services/software_state_service.rs
Normal 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
|
||||||
|
}
|
||||||
32
src-tauri/src/services/task_event_service.rs
Normal file
32
src-tauri/src/services/task_event_service.rs
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src-tauri/src/storage/essentials_store.rs
Normal file
21
src-tauri/src/storage/essentials_store.rs
Normal 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())
|
||||||
|
}
|
||||||
3
src-tauri/src/storage/mod.rs
Normal file
3
src-tauri/src/storage/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod essentials_store;
|
||||||
|
pub mod paths;
|
||||||
|
pub mod settings_store;
|
||||||
20
src-tauri/src/storage/paths.rs
Normal file
20
src-tauri/src/storage/paths.rs
Normal 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")
|
||||||
|
}
|
||||||
24
src-tauri/src/storage/settings_store.rs
Normal file
24
src-tauri/src/storage/settings_store.rs
Normal 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())
|
||||||
|
}
|
||||||
753
src-tauri/src/tasks/install_queue.rs
Normal file
753
src-tauri/src/tasks/install_queue.rs
Normal 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(())
|
||||||
|
}
|
||||||
1
src-tauri/src/tasks/mod.rs
Normal file
1
src-tauri/src/tasks/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod install_queue;
|
||||||
@@ -1,24 +1,65 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use base64::Engine;
|
||||||
|
use std::fs;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
use tauri::AppHandle;
|
use std::collections::HashMap;
|
||||||
use crate::emit_log;
|
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)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Software {
|
pub struct Software {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
pub version: Option<String>,
|
pub version: Option<String>,
|
||||||
pub available_version: Option<String>,
|
pub available_version: Option<String>,
|
||||||
pub icon_url: Option<String>,
|
pub icon_url: Option<String>,
|
||||||
#[serde(default = "default_status")]
|
#[serde(default = "default_status")]
|
||||||
pub status: String, // "idle", "pending", "installing", "success", "error"
|
pub status: String, // "idle", "pending", "installing", "configuring", "success", "error"
|
||||||
#[serde(default = "default_progress")]
|
#[serde(default = "default_progress")]
|
||||||
pub progress: f32,
|
pub progress: f32,
|
||||||
#[serde(default = "default_false")]
|
#[serde(default = "default_false")]
|
||||||
pub use_manifest: bool,
|
pub use_manifest: bool,
|
||||||
pub manifest_url: Option<String>,
|
pub manifest_url: Option<String>,
|
||||||
|
pub post_install: Option<Vec<PostInstallStep>>,
|
||||||
|
pub post_install_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_status() -> String { "idle".to_string() }
|
fn default_status() -> String { "idle".to_string() }
|
||||||
@@ -139,84 +180,18 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
|
|||||||
let script = r#"
|
let script = r#"
|
||||||
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
$ErrorActionPreference = 'SilentlyContinue'
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
Add-Type -AssemblyName System.Drawing
|
|
||||||
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
|
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
$pkgs = Get-WinGetPackage -ErrorAction SilentlyContinue | Where-Object { $_.IsUpdateAvailable }
|
$pkgs = Get-WinGetPackage -ErrorAction SilentlyContinue | Where-Object { $_.IsUpdateAvailable }
|
||||||
|
|
||||||
if ($pkgs) {
|
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 {
|
$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]@{
|
[PSCustomObject]@{
|
||||||
Name = [string]$p.Name;
|
Name = [string]$_.Name;
|
||||||
Id = [string]$p.Id;
|
Id = [string]$_.Id;
|
||||||
InstalledVersion = [string]$p.InstalledVersion;
|
InstalledVersion = [string]$_.InstalledVersion;
|
||||||
AvailableVersions = $p.AvailableVersions;
|
AvailableVersions = $_.AvailableVersions;
|
||||||
IconUrl = $iconUrl
|
IconUrl = $null
|
||||||
}
|
}
|
||||||
} | ConvertTo-Json -Compress
|
} | ConvertTo-Json -Compress
|
||||||
} else {
|
} else {
|
||||||
@@ -227,6 +202,132 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
|
|||||||
execute_powershell(handle, &log_id, "Fetch Updates", script)
|
execute_powershell(handle, &log_id, "Fetch Updates", script)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_software_info(handle: &AppHandle, id: &str) -> Option<Software> {
|
||||||
|
let log_id = format!("get-info-{}", chrono::Local::now().timestamp_millis());
|
||||||
|
let script = format!(r#"
|
||||||
|
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
|
||||||
|
$pkg = Get-WinGetPackage -Id "{}" -ErrorAction SilentlyContinue
|
||||||
|
if ($pkg) {{
|
||||||
|
[PSCustomObject]@{{
|
||||||
|
Name = [string]$pkg.Name;
|
||||||
|
Id = [string]$pkg.Id;
|
||||||
|
InstalledVersion = [string]$pkg.InstalledVersion;
|
||||||
|
AvailableVersions = @()
|
||||||
|
}} | ConvertTo-Json -Compress
|
||||||
|
}}
|
||||||
|
"#, id);
|
||||||
|
|
||||||
|
let res = execute_powershell(handle, &log_id, "Fetch Single Software Info", &script);
|
||||||
|
res.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
fn execute_powershell(handle: &AppHandle, log_id: &str, cmd_title: &str, script: &str) -> Vec<Software> {
|
||||||
emit_log(handle, log_id, cmd_title, "Fetching data from Winget...", "info");
|
emit_log(handle, log_id, cmd_title, "Fetching data from Winget...", "info");
|
||||||
|
|
||||||
@@ -271,11 +372,23 @@ fn parse_json_output(json_str: String) -> Vec<Software> {
|
|||||||
vec![]
|
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 {
|
fn map_package(p: WingetPackage) -> Software {
|
||||||
Software {
|
Software {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
description: None,
|
description: None,
|
||||||
|
category: None,
|
||||||
version: p.installed_version,
|
version: p.installed_version,
|
||||||
available_version: p.available_versions.and_then(|v| v.first().cloned()),
|
available_version: p.available_versions.and_then(|v| v.first().cloned()),
|
||||||
icon_url: p.icon_url,
|
icon_url: p.icon_url,
|
||||||
@@ -283,5 +396,7 @@ fn map_package(p: WingetPackage) -> Software {
|
|||||||
progress: 0.0,
|
progress: 0.0,
|
||||||
use_manifest: false,
|
use_manifest: false,
|
||||||
manifest_url: None,
|
manifest_url: None,
|
||||||
|
post_install: None,
|
||||||
|
post_install_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<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>
|
</svg>
|
||||||
</span>
|
</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>
|
||||||
<router-link to="/updates" class="nav-item">
|
<router-link to="/updates" class="nav-item">
|
||||||
<span class="nav-icon">
|
<span class="nav-icon">
|
||||||
@@ -57,7 +68,7 @@
|
|||||||
width: 240px;
|
width: 240px;
|
||||||
background-color: var(--sidebar-bg);
|
background-color: var(--sidebar-bg);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
padding: 40px 20px;
|
padding: 40px 20px 5px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -66,7 +77,7 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
padding-left: 20px;
|
padding-left: 10px;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
<span class="id-badge">{{ software.id }}</span>
|
<span class="id-badge">{{ software.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="version-info">
|
<div class="version-info">
|
||||||
<!-- 情况 1: 已安装且有推荐/最新版本 -->
|
|
||||||
<template v-if="software.version">
|
<template v-if="software.version">
|
||||||
<span class="version-tag">当前: {{ software.version }}</span>
|
<span class="version-tag">当前: {{ software.version }}</span>
|
||||||
<span v-if="software.recommended_version && software.actionLabel === '更新'" class="version-tag recommended">
|
<span v-if="software.recommended_version && software.actionLabel === '更新'" class="version-tag recommended">
|
||||||
@@ -46,7 +45,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 情况 2: 未安装 -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span v-if="software.recommended_version" class="version-tag recommended">
|
<span v-if="software.recommended_version" class="version-tag recommended">
|
||||||
推荐: {{ software.recommended_version }}
|
推荐: {{ software.recommended_version }}
|
||||||
@@ -64,6 +62,19 @@
|
|||||||
|
|
||||||
<div class="card-right" v-if="actionLabel || software.status !== 'idle'">
|
<div class="card-right" v-if="actionLabel || software.status !== 'idle'">
|
||||||
<div class="action-wrapper">
|
<div class="action-wrapper">
|
||||||
|
<!-- 后安装配置开关 -->
|
||||||
|
<div
|
||||||
|
v-if="software.status === 'idle' && (software.post_install || software.post_install_url)"
|
||||||
|
class="post-install-toggle"
|
||||||
|
@click.stop="$emit('togglePostInstall', software.id)"
|
||||||
|
:title="software.enablePostInstall ? '已开启安装后自动配置' : '已关闭安装后自动配置'"
|
||||||
|
>
|
||||||
|
<span class="toggle-label">自动配置</span>
|
||||||
|
<div class="toggle-switch" :class="{ 'is-active': software.enablePostInstall }">
|
||||||
|
<div class="toggle-dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="software.status === 'idle'"
|
v-if="software.status === 'idle'"
|
||||||
@click.stop="$emit('install', software.id, (software as any).targetVersion)"
|
@click.stop="$emit('install', software.id, (software as any).targetVersion)"
|
||||||
@@ -81,12 +92,15 @@
|
|||||||
已安装
|
已安装
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- 等待中状态 -->
|
|
||||||
<div v-else-if="software.status === 'pending'" class="status-pending">
|
<div v-else-if="software.status === 'pending'" class="status-pending">
|
||||||
<span class="wait-text">等待中</span>
|
<span class="wait-text">等待中</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 安装中状态:显示进度环和百分比 -->
|
<div v-else-if="software.status === 'configuring'" class="status-configuring">
|
||||||
|
<div class="mini-spinner"></div>
|
||||||
|
<span class="config-text">正在配置...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="software.status === 'installing'" class="progress-status">
|
<div v-else-if="software.status === 'installing'" class="progress-status">
|
||||||
<div class="progress-ring-container">
|
<div class="progress-ring-container">
|
||||||
<svg viewBox="0 0 32 32" class="ring-svg">
|
<svg viewBox="0 0 32 32" class="ring-svg">
|
||||||
@@ -133,6 +147,9 @@ const props = defineProps<{
|
|||||||
progress: number;
|
progress: number;
|
||||||
actionLabel?: string;
|
actionLabel?: string;
|
||||||
targetVersion?: string;
|
targetVersion?: string;
|
||||||
|
post_install?: any;
|
||||||
|
post_install_url?: string;
|
||||||
|
enablePostInstall?: boolean;
|
||||||
},
|
},
|
||||||
actionLabel?: string,
|
actionLabel?: string,
|
||||||
selectable?: boolean,
|
selectable?: boolean,
|
||||||
@@ -140,7 +157,7 @@ const props = defineProps<{
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits(['install', 'toggleSelect']);
|
const emit = defineEmits(['install', 'toggleSelect', 'togglePostInstall']);
|
||||||
|
|
||||||
const displayProgress = computed(() => {
|
const displayProgress = computed(() => {
|
||||||
if (!props.software.progress) return '准备中';
|
if (!props.software.progress) return '准备中';
|
||||||
@@ -288,17 +305,6 @@ const handleCardClick = () => {
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-sec);
|
|
||||||
line-height: 1.4;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 1;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.version-info {
|
.version-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -320,10 +326,64 @@ const handleCardClick = () => {
|
|||||||
.card-right {
|
.card-right {
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-install-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-install-toggle:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-sec);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
width: 32px;
|
||||||
|
height: 18px;
|
||||||
|
background-color: #E5E5EA;
|
||||||
|
border-radius: 9px;
|
||||||
|
position: relative;
|
||||||
|
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.is-active {
|
||||||
|
background-color: #34C759;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-dot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch.is-active .toggle-dot {
|
||||||
|
transform: translateX(14px);
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
@@ -373,6 +433,30 @@ const handleCardClick = () => {
|
|||||||
color: #AEAEB2;
|
color: #AEAEB2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-configuring {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 90px;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(0, 122, 255, 0.1);
|
||||||
|
border-top-color: var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-text {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.wait-text {
|
.wait-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const router = createRouter({
|
|||||||
path: '/essentials',
|
path: '/essentials',
|
||||||
component: () => import('../views/Essentials.vue')
|
component: () => import('../views/Essentials.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/other-software',
|
||||||
|
component: () => import('../views/OtherSoftware.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/updates',
|
path: '/updates',
|
||||||
component: () => import('../views/Updates.vue')
|
component: () => import('../views/Updates.vue')
|
||||||
|
|||||||
137
src/store/catalog.ts
Normal file
137
src/store/catalog.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,322 +1,191 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore, storeToRefs } from 'pinia'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { computed } from 'vue'
|
||||||
import { listen } from '@tauri-apps/api/event'
|
|
||||||
|
|
||||||
const sortByName = (a: any, b: any) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' });
|
import { useCatalogStore } from './catalog'
|
||||||
|
import { useTaskRuntimeStore } from './taskRuntime'
|
||||||
|
|
||||||
// 版本比对工具函数
|
export const useSoftwareStore = defineStore('software', () => {
|
||||||
const compareVersions = (v1: string | null | undefined, v2: string | null | undefined) => {
|
const catalog = useCatalogStore()
|
||||||
if (!v1 || !v2) return 0;
|
const taskRuntime = useTaskRuntimeStore()
|
||||||
if (v1 === v2) return 0;
|
|
||||||
const cleanV = (v: string) => v.replace(/^v/i, '').split(/[-+]/)[0].split('.');
|
const {
|
||||||
const p1 = cleanV(v1);
|
essentials,
|
||||||
const p2 = cleanV(v2);
|
essentialsVersion,
|
||||||
const len = Math.max(p1.length, p2.length);
|
updates,
|
||||||
for (let i = 0; i < len; i++) {
|
allSoftware,
|
||||||
const n1 = parseInt(p1[i] || '0', 10);
|
settings,
|
||||||
const n2 = parseInt(p2[i] || '0', 10);
|
loading,
|
||||||
if (n1 < n2) return -1;
|
isInitialized,
|
||||||
if (n1 > n2) return 1;
|
initStatus
|
||||||
|
} = storeToRefs(catalog)
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeTasks,
|
||||||
|
selectedEssentialIds,
|
||||||
|
selectedSpecialIds,
|
||||||
|
selectedUpdateIds,
|
||||||
|
logs,
|
||||||
|
postInstallPrefs
|
||||||
|
} = storeToRefs(taskRuntime)
|
||||||
|
|
||||||
|
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: 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,
|
||||||
|
enablePostInstall
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface LogEntry {
|
const mergedEssentials = computed(() => essentials.value.map(mergeSoftwareItem))
|
||||||
id: string; // 日志唯一标识
|
|
||||||
timestamp: string;
|
|
||||||
command: string;
|
|
||||||
output: string;
|
|
||||||
status: 'info' | 'success' | 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSoftwareStore = defineStore('software', {
|
const categorizedEssentials = computed(() => {
|
||||||
state: () => ({
|
return mergedEssentials.value.reduce((acc, item) => {
|
||||||
essentials: [] as any[],
|
const category = item.category === 'special' ? 'special' : 'general'
|
||||||
essentialsVersion: '',
|
acc[category].push(item)
|
||||||
updates: [] as any[],
|
return acc
|
||||||
allSoftware: [] as any[],
|
}, { general: [] as typeof mergedEssentials.value, special: [] as typeof mergedEssentials.value })
|
||||||
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 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 generalEssentials = computed(() => categorizedEssentials.value.general)
|
||||||
// 逻辑:已安装 >= 推荐 -> 已安装(禁用)
|
const specialEssentials = computed(() => categorizedEssentials.value.special)
|
||||||
// 逻辑:已安装 < 推荐 -> 更新
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const sortedUpdates = computed(() => [...updates.value].map(item => {
|
||||||
...item,
|
const task = activeTasks.value[item.id]
|
||||||
version: currentVersion,
|
const enablePostInstall = postInstallPrefs.value[item.id] !== false
|
||||||
recommended_version: recommendedVersion,
|
|
||||||
available_version: availableVersion,
|
return {
|
||||||
status: displayStatus,
|
...item,
|
||||||
progress: task ? task.progress : 0,
|
version: item.version ?? undefined,
|
||||||
actionLabel,
|
recommended_version: item.recommended_version ?? undefined,
|
||||||
targetVersion // 传递给视图,用于点击安装
|
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,
|
||||||
sortedUpdates: (state) => {
|
actionLabel: item.action_label,
|
||||||
return [...state.updates].map(item => {
|
targetVersion: item.target_version ?? undefined,
|
||||||
const task = state.activeTasks[item.id];
|
status: task ? task.status : 'idle',
|
||||||
return {
|
progress: task ? task.progress : 0,
|
||||||
...item,
|
enablePostInstall
|
||||||
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'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
}).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' })))
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveSettings(newSettings: any) {
|
const isBusy = computed(() => loading.value || taskRuntime.isTaskBusy)
|
||||||
await invoke('save_settings', { settings: newSettings });
|
|
||||||
this.settings = newSettings;
|
|
||||||
},
|
|
||||||
|
|
||||||
async syncEssentials() {
|
const toggleSelection = (id: string, type: 'essential' | 'special' | 'update') => {
|
||||||
this.loading = true;
|
if (isBusy.value) return
|
||||||
try {
|
taskRuntime.toggleSelection(id, type)
|
||||||
await invoke('sync_essentials');
|
}
|
||||||
await this.fetchEssentials();
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleSelection(id: string, type: 'essential' | 'update') {
|
const selectAll = (type: 'essential' | 'special' | 'update') => {
|
||||||
if (this.isBusy) return;
|
if (type === 'essential') {
|
||||||
const list = type === 'essential' ? this.selectedEssentialIds : this.selectedUpdateIds;
|
const selectable = generalEssentials.value.filter(item => item.actionLabel !== '已安装')
|
||||||
const index = list.indexOf(id);
|
taskRuntime.setSelection('essential', selectable.map(item => item.id))
|
||||||
if (index === -1) list.push(id);
|
} else if (type === 'special') {
|
||||||
else list.splice(index, 1);
|
const selectable = specialEssentials.value.filter(item => item.actionLabel !== '已安装')
|
||||||
},
|
taskRuntime.setSelection('special', selectable.map(item => item.id))
|
||||||
selectAll(type: 'essential' | 'update') {
|
} else {
|
||||||
if (type === 'essential') {
|
taskRuntime.setSelection('update', updates.value.map(item => item.id))
|
||||||
const selectable = this.mergedEssentials.filter(s => s.actionLabel !== '已安装');
|
|
||||||
this.selectedEssentialIds = selectable.map(s => s.id);
|
|
||||||
} else {
|
|
||||||
this.selectedUpdateIds = this.updates.map(s => s.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deselectAll(type: 'essential' | 'update') {
|
|
||||||
if (type === 'essential') this.selectedEssentialIds = [];
|
|
||||||
else this.selectedUpdateIds = [];
|
|
||||||
},
|
|
||||||
invertSelection(type: 'essential' | '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));
|
|
||||||
} else {
|
|
||||||
const selectable = this.updates.map(s => s.id);
|
|
||||||
this.selectedUpdateIds = selectable.filter(id => !this.selectedUpdateIds.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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 deselectAll = (type: 'essential' | 'special' | 'update') => {
|
||||||
|
taskRuntime.setSelection(type, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const invertSelection = (type: 'essential' | 'special' | 'update') => {
|
||||||
|
if (type === 'essential') {
|
||||||
|
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 = updates.value.map(item => item.id)
|
||||||
|
taskRuntime.setSelection(
|
||||||
|
'update',
|
||||||
|
selectable.filter(id => !selectedUpdateIds.value.includes(id))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUpdates = async () => {
|
||||||
|
if (isBusy.value) return
|
||||||
|
await catalog.fetchUpdates()
|
||||||
|
if (selectedUpdateIds.value.length === 0) selectAll('update')
|
||||||
|
}
|
||||||
|
|
||||||
|
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
206
src/store/taskRuntime.ts
Normal 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
76
src/store/types.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,67 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<header class="content-header">
|
<!-- 固定标头区域 -->
|
||||||
<div class="header-left">
|
<div class="sticky-header">
|
||||||
<h1>装机必备</h1>
|
<header class="content-header">
|
||||||
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
|
<div class="header-left">
|
||||||
</div>
|
<h1>装机常用</h1>
|
||||||
<div class="header-actions">
|
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
|
||||||
<button
|
</div>
|
||||||
@click="store.syncDataIfNeeded(true)"
|
<div class="header-actions">
|
||||||
class="secondary-btn action-btn"
|
<button
|
||||||
:disabled="store.loading || store.isBusy"
|
@click="store.syncDataIfNeeded(true)"
|
||||||
>
|
class="secondary-btn action-btn"
|
||||||
<span class="icon" :class="{ 'spinning': store.loading }">
|
:disabled="store.loading || store.isBusy"
|
||||||
<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>
|
<span class="icon" :class="{ 'spinning': store.loading }">
|
||||||
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
|
<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="M3 22v-6h6"></path>
|
<path d="M21 2v6h-6"></path>
|
||||||
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
|
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
|
||||||
</svg>
|
<path d="M3 22v-6h6"></path>
|
||||||
</span>
|
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
|
||||||
{{ store.loading ? '正在刷新...' : '刷新状态' }}
|
</svg>
|
||||||
</button>
|
</span>
|
||||||
<button
|
{{ store.loading ? '正在刷新...' : '刷新状态' }}
|
||||||
@click="installSelected"
|
</button>
|
||||||
class="primary-btn action-btn"
|
<button
|
||||||
:disabled="store.loading || store.isBusy || store.selectedEssentialIds.length === 0"
|
@click="installSelected"
|
||||||
>
|
class="primary-btn action-btn"
|
||||||
安装所选 ({{ store.selectedEssentialIds.length }})
|
:disabled="store.loading || store.isBusy || store.selectedEssentialIds.length === 0"
|
||||||
</button>
|
>
|
||||||
</div>
|
安装所选 ({{ store.selectedEssentialIds.length }})
|
||||||
</header>
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- 批量选择控制栏 -->
|
<!-- 批量选择控制栏 -->
|
||||||
<div class="selection-toolbar" v-if="selectableItems.length > 0">
|
<div class="selection-toolbar" v-if="selectableItems.length > 0">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<span class="selection-count">已选 {{ store.selectedEssentialIds.length }} / {{ selectableItems.length }} 项</span>
|
<span class="selection-count">已选 {{ store.selectedEssentialIds.length }} / {{ selectableItems.length }} 项</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<button @click="store.selectAll('essential')" class="text-btn" :disabled="store.isBusy">全选</button>
|
<button @click="store.selectAll('essential')" class="text-btn" :disabled="store.isBusy">全选</button>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<button @click="store.deselectAll('essential')" class="text-btn" :disabled="store.isBusy">取消</button>
|
<button @click="store.deselectAll('essential')" class="text-btn" :disabled="store.isBusy">取消</button>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
|
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state">
|
<!-- 可滚动内容区域 -->
|
||||||
<div class="spinner"></div>
|
<div class="scroll-content">
|
||||||
<p>正在读取必备软件列表...</p>
|
<div v-if="store.loading && store.generalEssentials.length === 0" class="loading-state">
|
||||||
</div>
|
<div class="spinner"></div>
|
||||||
|
<p>正在读取软件列表...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="software-list">
|
<div v-else class="software-list">
|
||||||
<SoftwareCard
|
<SoftwareCard
|
||||||
v-for="item in store.mergedEssentials"
|
v-for="item in store.generalEssentials"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:software="item"
|
:software="item"
|
||||||
:action-label="item.actionLabel"
|
:action-label="item.actionLabel"
|
||||||
:selectable="true"
|
:selectable="true"
|
||||||
:is-selected="store.selectedEssentialIds.includes(item.id)"
|
:is-selected="store.selectedEssentialIds.includes(item.id)"
|
||||||
:disabled="store.isBusy"
|
:disabled="store.isBusy"
|
||||||
@install="store.install"
|
@install="store.install"
|
||||||
@toggle-select="id => store.toggleSelection(id, 'essential')"
|
@toggle-select="store.toggleSelection($event, 'essential')"
|
||||||
/>
|
@toggle-post-install="store.togglePostInstallPref"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -74,14 +81,14 @@ import { onMounted, computed } from 'vue';
|
|||||||
const store = useSoftwareStore();
|
const store = useSoftwareStore();
|
||||||
|
|
||||||
const selectableItems = computed(() => {
|
const selectableItems = computed(() => {
|
||||||
return store.mergedEssentials.filter(s => s.status !== 'installed');
|
return store.generalEssentials.filter(s => s.status !== 'installed');
|
||||||
});
|
});
|
||||||
|
|
||||||
const installSelected = () => {
|
const installSelected = () => {
|
||||||
const ids = [...store.selectedEssentialIds];
|
const ids = [...store.selectedEssentialIds];
|
||||||
store.startBatch(ids);
|
store.startBatch(ids);
|
||||||
ids.forEach(id => {
|
ids.forEach(id => {
|
||||||
const item = store.mergedEssentials.find(s => s.id === id);
|
const item = store.generalEssentials.find(s => s.id === id);
|
||||||
if (item) {
|
if (item) {
|
||||||
store.install(id, item.targetVersion);
|
store.install(id, item.targetVersion);
|
||||||
}
|
}
|
||||||
@@ -97,15 +104,30 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 40px 60px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden; /* 关键:禁止最外层滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-header {
|
||||||
|
padding: 24px 60px 8px 60px;
|
||||||
|
background-color: #F5F5F7; /* 与 App.vue 背景色保持一致 */
|
||||||
|
z-index: 10;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-content {
|
||||||
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: 8px 60px 32px 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-header {
|
.content-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
@@ -136,16 +158,16 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 批量选择工具栏 */
|
|
||||||
.selection-toolbar {
|
.selection-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
background-color: white; /* 这里的工具栏背景设为白色更清晰 */
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 10px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-count {
|
.selection-count {
|
||||||
|
|||||||
293
src/views/OtherSoftware.vue
Normal file
293
src/views/OtherSoftware.vue
Normal 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>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label>仓库地址</label>
|
<label>仓库地址</label>
|
||||||
<p>应用将从该地址同步“装机必备”软件清单</p>
|
<p>应用将从该地址同步软件清单</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-action">
|
<div class="setting-action">
|
||||||
<input
|
<input
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3 class="section-title">关于</h3>
|
<h3 class="section-title">关于</h3>
|
||||||
<div class="settings-card about-card">
|
<div class="settings-card about-card">
|
||||||
<p>Windows 软件管理器 v{{ version }}</p>
|
<p>Windows 软件管理 v{{ version }}</p>
|
||||||
<p class="hint">基于 Tauri 和 WinGet 构建的 Windows 软件管理工具</p>
|
<p class="hint">基于 WinGet 构建的 Windows 软件管理工具</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,8 +107,8 @@ const handleSave = async () => {
|
|||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
try {
|
try {
|
||||||
await store.syncEssentials()
|
const result = await store.syncEssentials()
|
||||||
showToast('清单同步成功')
|
showToast(result.message, result.status === 'updated' ? 'success' : 'error')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('同步失败,请检查网络或地址', 'error')
|
showToast('同步失败,请检查网络或地址', 'error')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,78 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<header class="content-header">
|
<!-- 固定标头区域 -->
|
||||||
<div class="header-left">
|
<div class="sticky-header">
|
||||||
<h1>软件更新</h1>
|
<header class="content-header">
|
||||||
</div>
|
<div class="header-left">
|
||||||
<div class="header-actions">
|
<h1>软件更新</h1>
|
||||||
<button
|
</div>
|
||||||
@click="store.fetchUpdates"
|
<div class="header-actions">
|
||||||
class="secondary-btn action-btn"
|
<button
|
||||||
:disabled="store.loading || store.isBusy"
|
@click="store.fetchUpdates"
|
||||||
>
|
class="secondary-btn action-btn"
|
||||||
<span class="icon" :class="{ 'spinning': store.loading }">
|
:disabled="store.loading || store.isBusy"
|
||||||
<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>
|
<span class="icon" :class="{ 'spinning': store.loading }">
|
||||||
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
|
<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="M3 22v-6h6"></path>
|
<path d="M21 2v6h-6"></path>
|
||||||
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
|
<path d="M3 12a9 9 0 0 1 15-6.7L21 8"></path>
|
||||||
</svg>
|
<path d="M3 22v-6h6"></path>
|
||||||
</span>
|
<path d="M21 12a9 9 0 0 1-15 6.7L3 16"></path>
|
||||||
{{ store.loading ? '正在检查...' : '检查更新' }}
|
</svg>
|
||||||
</button>
|
</span>
|
||||||
<button
|
{{ store.loading ? '正在检查...' : '检查更新' }}
|
||||||
@click="updateSelected"
|
</button>
|
||||||
class="primary-btn action-btn"
|
<button
|
||||||
:disabled="store.selectedUpdateIds.length === 0 || store.loading || store.isBusy"
|
@click="updateSelected"
|
||||||
>
|
class="primary-btn action-btn"
|
||||||
更新所选 ({{ store.selectedUpdateIds.length }})
|
:disabled="store.selectedUpdateIds.length === 0 || store.loading || store.isBusy"
|
||||||
</button>
|
>
|
||||||
</div>
|
更新所选 ({{ store.selectedUpdateIds.length }})
|
||||||
</header>
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<!-- 批量选择控制栏 -->
|
<!-- 批量选择控制栏 -->
|
||||||
<div class="selection-toolbar" v-if="store.sortedUpdates.length > 0">
|
<div class="selection-toolbar" v-if="store.sortedUpdates.length > 0">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<span class="selection-count">已选 {{ store.selectedUpdateIds.length }} / {{ store.sortedUpdates.length }} 项</span>
|
<span class="selection-count">已选 {{ store.selectedUpdateIds.length }} / {{ store.sortedUpdates.length }} 项</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<button @click="store.selectAll('update')" class="text-btn" :disabled="store.isBusy">全选</button>
|
<button @click="store.selectAll('update')" class="text-btn" :disabled="store.isBusy">全选</button>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<button @click="store.deselectAll('update')" class="text-btn" :disabled="store.isBusy">取消</button>
|
<button @click="store.deselectAll('update')" class="text-btn" :disabled="store.isBusy">取消</button>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<button @click="store.invertSelection('update')" class="text-btn" :disabled="store.isBusy">反选</button>
|
<button @click="store.invertSelection('update')" class="text-btn" :disabled="store.isBusy">反选</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
|
<!-- 可滚动内容区域 -->
|
||||||
<div class="spinner"></div>
|
<div class="scroll-content">
|
||||||
<p>正在使用 Winget 扫描可用的更新...</p>
|
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
|
||||||
</div>
|
<div class="spinner"></div>
|
||||||
|
<p>正在使用 Winget 扫描可用的更新...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="store.updates.length === 0 && !store.loading" class="empty-state">
|
<div v-else-if="store.updates.length === 0 && !store.loading" class="empty-state">
|
||||||
<span class="empty-icon">✅</span>
|
<span class="empty-icon">✅</span>
|
||||||
<p>所有软件已是最新版本</p>
|
<p>所有软件已是最新版本</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="software-list">
|
<div v-else class="software-list">
|
||||||
<SoftwareCard
|
<SoftwareCard
|
||||||
v-for="item in store.sortedUpdates"
|
v-for="item in store.sortedUpdates"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:software="item"
|
:software="item"
|
||||||
action-label="更新"
|
:action-label="item.actionLabel"
|
||||||
:selectable="true"
|
:selectable="true"
|
||||||
:is-selected="store.selectedUpdateIds.includes(item.id)"
|
:is-selected="store.selectedUpdateIds.includes(item.id)"
|
||||||
:disabled="store.isBusy"
|
:disabled="store.isBusy"
|
||||||
@install="store.install"
|
@install="store.install"
|
||||||
@toggle-select="id => store.toggleSelection(id, 'update')"
|
@toggle-select="store.toggleSelection($event, 'update')"
|
||||||
/>
|
@toggle-post-install="store.togglePostInstallPref"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -98,15 +105,30 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 40px 60px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden; /* 关键:禁止最外层滚动 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-header {
|
||||||
|
padding: 24px 60px 8px 60px;
|
||||||
|
background-color: #F5F5F7;
|
||||||
|
z-index: 10;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-content {
|
||||||
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding: 8px 60px 32px 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-header {
|
.content-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-header h1 {
|
.content-header h1 {
|
||||||
@@ -121,16 +143,16 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 批量选择工具栏 */
|
|
||||||
.selection-toolbar {
|
.selection-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
background-color: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 10px;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection-count {
|
.selection-count {
|
||||||
|
|||||||
Reference in New Issue
Block a user