Compare commits
24 Commits
46c622fd86
...
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 | ||
|
|
9aa6f9cd1d |
@@ -1,6 +0,0 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
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
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/target*/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
|
||||
15
src-tauri/Cargo.lock
generated
15
src-tauri/Cargo.lock
generated
@@ -859,7 +859,7 @@ dependencies = [
|
||||
"rustc_version",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
"winreg 0.55.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5108,6 +5108,7 @@ dependencies = [
|
||||
name = "win-softmgr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
@@ -5117,6 +5118,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
"winreg 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5592,6 +5594,17 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"serde",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -26,4 +26,5 @@ tokio = { version = "1.50.0", features = ["full"] }
|
||||
chrono = "0.4.44"
|
||||
regex = "1.12.3"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
|
||||
|
||||
winreg = { version = "0.56.0", features = ["serde"] }
|
||||
base64 = "0.22.1"
|
||||
|
||||
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;
|
||||
|
||||
use std::fs;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::mpsc;
|
||||
use tauri::{AppHandle, Manager, State, Emitter};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use winget::{Software, list_installed_software, list_updates, ensure_winget_dependencies};
|
||||
use regex::Regex;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AppSettings {
|
||||
pub repo_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct EssentialsRepo {
|
||||
pub version: String,
|
||||
pub essentials: Vec<Software>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct InstallTask {
|
||||
pub id: String,
|
||||
pub version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub use_manifest: bool,
|
||||
pub manifest_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
repo_url: "https://karlblue.github.io/winget-repo".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
install_tx: mpsc::Sender<InstallTask>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct LogPayload {
|
||||
pub id: String,
|
||||
pub timestamp: String,
|
||||
pub command: String,
|
||||
pub output: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
pub fn emit_log(handle: &AppHandle, id: &str, command: &str, output: &str, status: &str) {
|
||||
let now = chrono::Local::now().format("%H:%M:%S").to_string();
|
||||
let _ = handle.emit("log-event", LogPayload {
|
||||
id: id.to_string(),
|
||||
timestamp: now,
|
||||
command: command.to_string(),
|
||||
output: output.to_string(),
|
||||
status: status.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
fn get_settings_path(app: &AppHandle) -> PathBuf {
|
||||
let app_data_dir = app.path().app_data_dir().unwrap_or_default();
|
||||
if !app_data_dir.exists() {
|
||||
let _ = fs::create_dir_all(&app_data_dir);
|
||||
}
|
||||
app_data_dir.join("settings.json")
|
||||
}
|
||||
|
||||
fn get_essentials_path(app: &AppHandle) -> PathBuf {
|
||||
let app_data_dir = app.path().app_data_dir().unwrap_or_default();
|
||||
if !app_data_dir.exists() {
|
||||
let _ = fs::create_dir_all(&app_data_dir);
|
||||
}
|
||||
app_data_dir.join("setup-essentials.json")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_settings(app: AppHandle) -> AppSettings {
|
||||
let path = get_settings_path(&app);
|
||||
if !path.exists() {
|
||||
let default_settings = AppSettings::default();
|
||||
let _ = fs::write(&path, serde_json::to_string_pretty(&default_settings).unwrap());
|
||||
return default_settings;
|
||||
}
|
||||
let content = fs::read_to_string(path).unwrap_or_default();
|
||||
serde_json::from_str(&content).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(), String> {
|
||||
let path = get_settings_path(&app);
|
||||
let content = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||
fs::write(path, content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn sync_essentials(app: AppHandle) -> Result<bool, String> {
|
||||
let settings = get_settings(app.clone());
|
||||
let url = format!("{}/setup-essentials.json", settings.repo_url.trim_end_matches('/'));
|
||||
|
||||
emit_log(&app, "sync-essentials", "Syncing Essentials", &format!("Downloading from {}...", url), "info");
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
let content = response.text().await.map_err(|e| e.to_string())?;
|
||||
// 验证 JSON 格式(新格式:{ version: string, essentials: Vec<Software> })
|
||||
let validation: Result<EssentialsRepo, _> = serde_json::from_str(&content);
|
||||
if validation.is_ok() {
|
||||
let path = get_essentials_path(&app);
|
||||
fs::write(path, content).map_err(|e| e.to_string())?;
|
||||
emit_log(&app, "sync-essentials", "Result", "Essentials list updated successfully.", "success");
|
||||
Ok(true)
|
||||
} else {
|
||||
emit_log(&app, "sync-essentials", "Error", "Invalid JSON format from repository. Expected { version, essentials }.", "error");
|
||||
Err("Invalid JSON format".to_string())
|
||||
}
|
||||
} else {
|
||||
let err_msg = format!("HTTP Error: {}", response.status());
|
||||
emit_log(&app, "sync-essentials", "Error", &err_msg, "error");
|
||||
Err(err_msg)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
emit_log(&app, "sync-essentials", "Skipped", &format!("Network issue: {}. Using local cache.", e), "info");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn initialize_app(app: AppHandle) -> Result<bool, String> {
|
||||
let app_clone = app.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ensure_winget_dependencies(&app_clone).map(|_| true)
|
||||
}).await.unwrap_or(Err("Initialization Task Panicked".to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_essentials(app: AppHandle) -> Option<EssentialsRepo> {
|
||||
let file_path = get_essentials_path(&app);
|
||||
if !file_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(file_path).unwrap_or_default();
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_installed_software(app: AppHandle) -> Vec<Software> {
|
||||
tokio::task::spawn_blocking(move || list_installed_software(&app)).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_updates(app: AppHandle) -> Vec<Software> {
|
||||
tokio::task::spawn_blocking(move || list_updates(&app)).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn install_software(
|
||||
task: InstallTask,
|
||||
state: State<'_, AppState>
|
||||
) -> Result<(), String> {
|
||||
state.install_tx.send(task).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_logs_history() -> Vec<LogPayload> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct InstallProgress {
|
||||
id: String,
|
||||
status: String,
|
||||
progress: f32,
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(move |app| {
|
||||
let handle = app.handle().clone();
|
||||
let (tx, mut rx) = mpsc::channel::<InstallTask>(100);
|
||||
app.manage(AppState { install_tx: tx });
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let perc_re = Regex::new(r"(\d+)\s*%").unwrap();
|
||||
let size_re = Regex::new(r"([\d\.]+)\s*[a-zA-Z]+\s*/\s*([\d\.]+)\s*[a-zA-Z]+").unwrap();
|
||||
|
||||
while let Some(task) = rx.recv().await {
|
||||
let task_id = task.id.clone();
|
||||
let task_version = task.version.clone();
|
||||
let use_manifest = task.use_manifest;
|
||||
let manifest_url = task.manifest_url.clone();
|
||||
|
||||
let log_id = format!("install-{}", task_id);
|
||||
|
||||
// 1. 发送正在安装状态
|
||||
let _ = handle.emit("install-status", InstallProgress {
|
||||
id: task_id.clone(),
|
||||
status: "installing".to_string(),
|
||||
progress: 0.0,
|
||||
});
|
||||
|
||||
let mut args = vec!["install".to_string()];
|
||||
let display_cmd: String;
|
||||
let mut temp_manifest_path: Option<PathBuf> = None;
|
||||
|
||||
if use_manifest && manifest_url.is_some() {
|
||||
let url = manifest_url.unwrap();
|
||||
display_cmd = format!("Winget Install (Manifest): {} from {}", task_id, url);
|
||||
emit_log(&handle, &log_id, &display_cmd, "Downloading remote manifest...", "info");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
match client.get(&url).timeout(std::time::Duration::from_secs(15)).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(content) = resp.text().await {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let file_name = format!("winget_tmp_{}.yaml", chrono::Local::now().timestamp_millis());
|
||||
let local_path = temp_dir.join(file_name);
|
||||
if fs::write(&local_path, content).is_ok() {
|
||||
args.push("--manifest".to_string());
|
||||
args.push(local_path.to_string_lossy().to_string());
|
||||
temp_manifest_path = Some(local_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if temp_manifest_path.is_none() {
|
||||
emit_log(&handle, &log_id, "Error", "Failed to download or save manifest.", "error");
|
||||
let _ = handle.emit("install-status", InstallProgress {
|
||||
id: task_id.clone(),
|
||||
status: "error".to_string(),
|
||||
progress: 0.0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
args.push("--id".to_string());
|
||||
args.push(task_id.clone());
|
||||
args.push("-e".to_string());
|
||||
|
||||
if let Some(v) = &task_version {
|
||||
if !v.is_empty() {
|
||||
args.push("--version".to_string());
|
||||
args.push(v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
display_cmd = match &task_version {
|
||||
Some(v) if !v.is_empty() => format!("Winget Install: {} (v{})", task_id, v),
|
||||
_ => format!("Winget Install: {}", task_id),
|
||||
};
|
||||
}
|
||||
|
||||
args.extend([
|
||||
"--silent".to_string(),
|
||||
"--accept-package-agreements".to_string(),
|
||||
"--accept-source-agreements".to_string(),
|
||||
"--disable-interactivity".to_string(),
|
||||
]);
|
||||
|
||||
let full_command = format!("winget {}", args.join(" "));
|
||||
emit_log(&handle, &log_id, &display_cmd, &format!("Executing: {}\n---", full_command), "info");
|
||||
|
||||
let h = handle.clone();
|
||||
let current_id = task_id.clone();
|
||||
|
||||
let child = Command::new("winget")
|
||||
.args(&args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.creation_flags(0x08000000)
|
||||
.spawn();
|
||||
|
||||
let status_result = match child {
|
||||
Ok(mut child_proc) => {
|
||||
if let Some(stdout) = child_proc.stdout.take() {
|
||||
let reader = BufReader::new(stdout);
|
||||
// 使用 split(b'\r') 是为了捕捉 PowerShell 中的动态进度行
|
||||
for line_res in reader.split(b'\r') {
|
||||
if let Ok(line_bytes) = line_res {
|
||||
let line_str = String::from_utf8_lossy(&line_bytes).to_string();
|
||||
let clean_line = line_str.trim();
|
||||
if clean_line.is_empty() { continue; }
|
||||
|
||||
let mut is_progress = false;
|
||||
if let Some(caps) = perc_re.captures(clean_line) {
|
||||
if let Ok(p_val) = caps[1].parse::<f32>() {
|
||||
let _ = h.emit("install-status", InstallProgress {
|
||||
id: current_id.clone(),
|
||||
status: "installing".to_string(),
|
||||
progress: p_val / 100.0,
|
||||
});
|
||||
is_progress = true;
|
||||
}
|
||||
} else if let Some(caps) = size_re.captures(clean_line) {
|
||||
let current = caps[1].parse::<f32>().unwrap_or(0.0);
|
||||
let total = caps[2].parse::<f32>().unwrap_or(1.0);
|
||||
if total > 0.0 {
|
||||
let _ = h.emit("install-status", InstallProgress {
|
||||
id: current_id.clone(),
|
||||
status: "installing".to_string(),
|
||||
progress: (current / total).min(1.0),
|
||||
});
|
||||
is_progress = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !is_progress && clean_line.chars().count() > 1 {
|
||||
emit_log(&h, &log_id, "", clean_line, "info");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let exit_status = child_proc.wait().map(|s| s.success()).unwrap_or(false);
|
||||
if exit_status { "success" } else { "error" }
|
||||
},
|
||||
Err(e) => {
|
||||
emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error");
|
||||
"error"
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 发送最终完成/失败状态
|
||||
let _ = handle.emit("install-status", InstallProgress {
|
||||
id: task_id.clone(),
|
||||
status: status_result.to_string(),
|
||||
progress: 1.0,
|
||||
});
|
||||
|
||||
emit_log(&handle, &log_id, "Result", &format!("Execution finished: {}", status_result), if status_result == "success" { "success" } else { "error" });
|
||||
|
||||
// 3. 清理临时清单文件
|
||||
if let Some(path) = temp_manifest_path {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let install_state = tasks::install_queue::create_install_state(app.handle().clone());
|
||||
app.manage(install_state);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
initialize_app,
|
||||
get_settings,
|
||||
save_settings,
|
||||
sync_essentials,
|
||||
get_essentials,
|
||||
get_installed_software,
|
||||
get_updates,
|
||||
install_software,
|
||||
get_logs_history
|
||||
commands::app_commands::initialize_app,
|
||||
commands::app_commands::get_settings,
|
||||
commands::app_commands::save_settings,
|
||||
commands::app_commands::sync_essentials,
|
||||
commands::app_commands::get_essentials,
|
||||
commands::app_commands::get_dashboard_snapshot,
|
||||
commands::app_commands::get_essentials_status,
|
||||
commands::app_commands::get_update_candidates,
|
||||
commands::app_commands::get_software_icon,
|
||||
tasks::install_queue::install_software,
|
||||
commands::app_commands::get_logs_history
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
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 base64::Engine;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use tauri::AppHandle;
|
||||
use crate::emit_log;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use crate::services::log_service::emit_log;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RegistryValue {
|
||||
pub v_type: String, // "String", "Dword", "Qword", "MultiString", "ExpandString"
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum PostInstallStep {
|
||||
#[serde(rename = "registry_batch")]
|
||||
RegistryBatch {
|
||||
root: String,
|
||||
base_path: String,
|
||||
values: HashMap<String, RegistryValue>,
|
||||
delay_ms: Option<u64>,
|
||||
},
|
||||
#[serde(rename = "file_copy")]
|
||||
FileCopy {
|
||||
src: String,
|
||||
dest: String,
|
||||
delay_ms: Option<u64>,
|
||||
},
|
||||
#[serde(rename = "file_delete")]
|
||||
FileDelete {
|
||||
path: String,
|
||||
delay_ms: Option<u64>,
|
||||
},
|
||||
#[serde(rename = "command")]
|
||||
Command {
|
||||
run: String,
|
||||
delay_ms: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Software {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub available_version: Option<String>,
|
||||
pub icon_url: Option<String>,
|
||||
#[serde(default = "default_status")]
|
||||
pub status: String, // "idle", "pending", "installing", "success", "error"
|
||||
pub status: String, // "idle", "pending", "installing", "configuring", "success", "error"
|
||||
#[serde(default = "default_progress")]
|
||||
pub progress: f32,
|
||||
#[serde(default = "default_false")]
|
||||
pub use_manifest: bool,
|
||||
pub manifest_url: Option<String>,
|
||||
pub post_install: Option<Vec<PostInstallStep>>,
|
||||
pub post_install_url: Option<String>,
|
||||
}
|
||||
|
||||
fn default_status() -> String { "idle".to_string() }
|
||||
@@ -139,84 +180,18 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
|
||||
let script = r#"
|
||||
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
|
||||
|
||||
$pkgs = Get-WinGetPackage -ErrorAction SilentlyContinue | Where-Object { $_.IsUpdateAvailable }
|
||||
|
||||
if ($pkgs) {
|
||||
$wshShell = New-Object -ComObject WScript.Shell
|
||||
|
||||
# 预加载开始菜单快捷方式
|
||||
$startMenuPaths = @(
|
||||
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs",
|
||||
"$env:AppData\Microsoft\Windows\Start Menu\Programs"
|
||||
)
|
||||
$lnkFiles = Get-ChildItem -Path $startMenuPaths -Filter "*.lnk" -Recurse -File
|
||||
|
||||
# 预加载注册表项
|
||||
$registryPaths = @(
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
$regItems = Get-ItemProperty $registryPaths
|
||||
|
||||
$pkgs | ForEach-Object {
|
||||
$p = $_
|
||||
$iconUrl = $null
|
||||
$foundPath = ""
|
||||
|
||||
# 策略 1: 寻找并解析开始菜单快捷方式 (去除箭头关键点)
|
||||
$matchedLnk = $lnkFiles | Where-Object { $_.BaseName -eq $p.Name -or $p.Name -like "*$($_.BaseName)*" } | Select-Object -First 1
|
||||
if ($matchedLnk) {
|
||||
try {
|
||||
# 解析快捷方式指向的真实目标
|
||||
$target = $wshShell.CreateShortcut($matchedLnk.FullName).TargetPath
|
||||
if ($target -and (Test-Path $target) -and $target.EndsWith(".exe")) {
|
||||
$foundPath = $target
|
||||
} else {
|
||||
# 如果目标不可读或是 UWP 快捷方式,仍使用快捷方式文件提取
|
||||
$foundPath = $matchedLnk.FullName
|
||||
}
|
||||
} catch {
|
||||
$foundPath = $matchedLnk.FullName
|
||||
}
|
||||
}
|
||||
|
||||
# 策略 2: 注册表 DisplayIcon
|
||||
if (-not $foundPath) {
|
||||
$matchedReg = $regItems | Where-Object { $_.DisplayName -eq $p.Name -or $_.PSChildName -eq $p.Id } | Select-Object -First 1
|
||||
if ($matchedReg.DisplayIcon) {
|
||||
$foundPath = $matchedReg.DisplayIcon.Split(',')[0].Trim('"')
|
||||
} elseif ($matchedReg.InstallLocation) {
|
||||
$loc = $matchedReg.InstallLocation.Trim('"')
|
||||
if (Test-Path $loc) {
|
||||
$exe = Get-ChildItem -Path $loc -Filter "*.exe" -File | Select-Object -First 1
|
||||
if ($exe) { $foundPath = $exe.FullName }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 提取并转 Base64
|
||||
if ($foundPath -and (Test-Path $foundPath)) {
|
||||
try {
|
||||
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($foundPath)
|
||||
$bitmap = $icon.ToBitmap()
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$bitmap.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$base64 = [Convert]::ToBase64String($ms.ToArray())
|
||||
$iconUrl = "data:image/png;base64,$base64"
|
||||
$ms.Dispose(); $bitmap.Dispose(); $icon.Dispose()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
Name = [string]$p.Name;
|
||||
Id = [string]$p.Id;
|
||||
InstalledVersion = [string]$p.InstalledVersion;
|
||||
AvailableVersions = $p.AvailableVersions;
|
||||
IconUrl = $iconUrl
|
||||
Name = [string]$_.Name;
|
||||
Id = [string]$_.Id;
|
||||
InstalledVersion = [string]$_.InstalledVersion;
|
||||
AvailableVersions = $_.AvailableVersions;
|
||||
IconUrl = $null
|
||||
}
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
@@ -227,6 +202,132 @@ pub fn list_updates(handle: &AppHandle) -> Vec<Software> {
|
||||
execute_powershell(handle, &log_id, "Fetch Updates", script)
|
||||
}
|
||||
|
||||
pub fn get_software_info(handle: &AppHandle, id: &str) -> Option<Software> {
|
||||
let log_id = format!("get-info-{}", chrono::Local::now().timestamp_millis());
|
||||
let script = format!(r#"
|
||||
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
|
||||
$pkg = Get-WinGetPackage -Id "{}" -ErrorAction SilentlyContinue
|
||||
if ($pkg) {{
|
||||
[PSCustomObject]@{{
|
||||
Name = [string]$pkg.Name;
|
||||
Id = [string]$pkg.Id;
|
||||
InstalledVersion = [string]$pkg.InstalledVersion;
|
||||
AvailableVersions = @()
|
||||
}} | ConvertTo-Json -Compress
|
||||
}}
|
||||
"#, id);
|
||||
|
||||
let res = execute_powershell(handle, &log_id, "Fetch Single Software Info", &script);
|
||||
res.into_iter().next()
|
||||
}
|
||||
|
||||
pub fn get_cached_or_extract_icon(handle: &AppHandle, id: &str, name: &str) -> Option<String> {
|
||||
let cache_key = sanitize_cache_key(id);
|
||||
let icon_dir = get_icon_cache_dir(handle);
|
||||
let icon_path = icon_dir.join(format!("{}.png", cache_key));
|
||||
|
||||
if let Ok(bytes) = fs::read(&icon_path) {
|
||||
return Some(format!(
|
||||
"data:image/png;base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
));
|
||||
}
|
||||
|
||||
let log_id = format!("icon-{}", cache_key);
|
||||
emit_log(handle, &log_id, "Icon Lookup", &format!("Resolving icon for {}...", id), "info");
|
||||
|
||||
let script = format!(r#"
|
||||
$OutputEncoding = [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Import-Module Microsoft.WinGet.Client -ErrorAction SilentlyContinue
|
||||
|
||||
$packageId = @'
|
||||
{id}
|
||||
'@
|
||||
$packageName = @'
|
||||
{name}
|
||||
'@
|
||||
|
||||
$foundPath = ""
|
||||
$wshShell = New-Object -ComObject WScript.Shell
|
||||
$startMenuPaths = @(
|
||||
"$env:ProgramData\Microsoft\Windows\Start Menu\Programs",
|
||||
"$env:AppData\Microsoft\Windows\Start Menu\Programs"
|
||||
)
|
||||
|
||||
$lnkFiles = Get-ChildItem -Path $startMenuPaths -Filter "*.lnk" -Recurse -File
|
||||
$matchedLnk = $lnkFiles | Where-Object {{ $_.BaseName -eq $packageName -or $packageName -like "*$($_.BaseName)*" }} | Select-Object -First 1
|
||||
if ($matchedLnk) {{
|
||||
try {{
|
||||
$target = $wshShell.CreateShortcut($matchedLnk.FullName).TargetPath
|
||||
if ($target -and (Test-Path $target) -and $target.EndsWith(".exe")) {{
|
||||
$foundPath = $target
|
||||
}} else {{
|
||||
$foundPath = $matchedLnk.FullName
|
||||
}}
|
||||
}} catch {{
|
||||
$foundPath = $matchedLnk.FullName
|
||||
}}
|
||||
}}
|
||||
|
||||
if (-not $foundPath) {{
|
||||
$registryPaths = @(
|
||||
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
$regItems = Get-ItemProperty $registryPaths
|
||||
$matchedReg = $regItems | Where-Object {{ $_.DisplayName -eq $packageName -or $_.PSChildName -eq $packageId }} | Select-Object -First 1
|
||||
if ($matchedReg.DisplayIcon) {{
|
||||
$foundPath = $matchedReg.DisplayIcon.Split(',')[0].Trim('"')
|
||||
}} elseif ($matchedReg.InstallLocation) {{
|
||||
$loc = $matchedReg.InstallLocation.Trim('"')
|
||||
if (Test-Path $loc) {{
|
||||
$exe = Get-ChildItem -Path $loc -Filter "*.exe" -File | Select-Object -First 1
|
||||
if ($exe) {{ $foundPath = $exe.FullName }}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
if ($foundPath -and (Test-Path $foundPath)) {{
|
||||
try {{
|
||||
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($foundPath)
|
||||
if ($icon) {{
|
||||
$bitmap = $icon.ToBitmap()
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$bitmap.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
[Convert]::ToBase64String($ms.ToArray())
|
||||
$ms.Dispose()
|
||||
$bitmap.Dispose()
|
||||
$icon.Dispose()
|
||||
}}
|
||||
}} catch {{}}
|
||||
}}
|
||||
"#, id = id, name = name);
|
||||
|
||||
let output = Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", &script])
|
||||
.creation_flags(0x08000000)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let encoded = stdout.trim_start_matches('\u{feff}').trim();
|
||||
if encoded.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(encoded).ok()?;
|
||||
if fs::create_dir_all(&icon_dir).is_err() {
|
||||
return Some(format!("data:image/png;base64,{}", encoded));
|
||||
}
|
||||
let _ = fs::write(&icon_path, &bytes);
|
||||
Some(format!("data:image/png;base64,{}", encoded))
|
||||
}
|
||||
|
||||
fn execute_powershell(handle: &AppHandle, log_id: &str, cmd_title: &str, script: &str) -> Vec<Software> {
|
||||
emit_log(handle, log_id, cmd_title, "Fetching data from Winget...", "info");
|
||||
|
||||
@@ -271,11 +372,23 @@ fn parse_json_output(json_str: String) -> Vec<Software> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn get_icon_cache_dir(handle: &AppHandle) -> PathBuf {
|
||||
let app_data_dir = handle.path().app_data_dir().unwrap_or_default();
|
||||
app_data_dir.join("icons")
|
||||
}
|
||||
|
||||
fn sanitize_cache_key(id: &str) -> String {
|
||||
id.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn map_package(p: WingetPackage) -> Software {
|
||||
Software {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: None,
|
||||
category: None,
|
||||
version: p.installed_version,
|
||||
available_version: p.available_versions.and_then(|v| v.first().cloned()),
|
||||
icon_url: p.icon_url,
|
||||
@@ -283,5 +396,7 @@ fn map_package(p: WingetPackage) -> Software {
|
||||
progress: 0.0,
|
||||
use_manifest: false,
|
||||
manifest_url: None,
|
||||
post_install: None,
|
||||
post_install_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,18 @@
|
||||
<path d="M12 3l1.912 5.885h6.19l-5.007 3.638 1.912 5.885L12 14.77l-5.007 3.638 1.912-5.885-5.007-3.638h6.19z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
装机必备
|
||||
装机常用
|
||||
</router-link>
|
||||
<router-link to="/other-software" class="nav-item">
|
||||
<span class="nav-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="6" rx="1.5"></rect>
|
||||
<rect x="3" y="14" width="18" height="6" rx="1.5"></rect>
|
||||
<path d="M7 7h.01"></path>
|
||||
<path d="M7 17h.01"></path>
|
||||
</svg>
|
||||
</span>
|
||||
网传有关
|
||||
</router-link>
|
||||
<router-link to="/updates" class="nav-item">
|
||||
<span class="nav-icon">
|
||||
@@ -57,7 +68,7 @@
|
||||
width: 240px;
|
||||
background-color: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 40px 20px;
|
||||
padding: 40px 20px 5px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -66,7 +77,7 @@
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 40px;
|
||||
padding-left: 20px;
|
||||
padding-left: 10px;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
<span class="id-badge">{{ software.id }}</span>
|
||||
</div>
|
||||
<div class="version-info">
|
||||
<!-- 情况 1: 已安装且有推荐/最新版本 -->
|
||||
<template v-if="software.version">
|
||||
<span class="version-tag">当前: {{ software.version }}</span>
|
||||
<span v-if="software.recommended_version && software.actionLabel === '更新'" class="version-tag recommended">
|
||||
@@ -46,7 +45,6 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 情况 2: 未安装 -->
|
||||
<template v-else>
|
||||
<span v-if="software.recommended_version" class="version-tag recommended">
|
||||
推荐: {{ software.recommended_version }}
|
||||
@@ -64,6 +62,19 @@
|
||||
|
||||
<div class="card-right" v-if="actionLabel || software.status !== 'idle'">
|
||||
<div class="action-wrapper">
|
||||
<!-- 后安装配置开关 -->
|
||||
<div
|
||||
v-if="software.status === 'idle' && (software.post_install || software.post_install_url)"
|
||||
class="post-install-toggle"
|
||||
@click.stop="$emit('togglePostInstall', software.id)"
|
||||
:title="software.enablePostInstall ? '已开启安装后自动配置' : '已关闭安装后自动配置'"
|
||||
>
|
||||
<span class="toggle-label">自动配置</span>
|
||||
<div class="toggle-switch" :class="{ 'is-active': software.enablePostInstall }">
|
||||
<div class="toggle-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="software.status === 'idle'"
|
||||
@click.stop="$emit('install', software.id, (software as any).targetVersion)"
|
||||
@@ -81,12 +92,15 @@
|
||||
已安装
|
||||
</button>
|
||||
|
||||
<!-- 等待中状态 -->
|
||||
<div v-else-if="software.status === 'pending'" class="status-pending">
|
||||
<span class="wait-text">等待中</span>
|
||||
</div>
|
||||
|
||||
<!-- 安装中状态:显示进度环和百分比 -->
|
||||
<div v-else-if="software.status === 'configuring'" class="status-configuring">
|
||||
<div class="mini-spinner"></div>
|
||||
<span class="config-text">正在配置...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="software.status === 'installing'" class="progress-status">
|
||||
<div class="progress-ring-container">
|
||||
<svg viewBox="0 0 32 32" class="ring-svg">
|
||||
@@ -133,6 +147,9 @@ const props = defineProps<{
|
||||
progress: number;
|
||||
actionLabel?: string;
|
||||
targetVersion?: string;
|
||||
post_install?: any;
|
||||
post_install_url?: string;
|
||||
enablePostInstall?: boolean;
|
||||
},
|
||||
actionLabel?: string,
|
||||
selectable?: boolean,
|
||||
@@ -140,7 +157,7 @@ const props = defineProps<{
|
||||
disabled?: boolean
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['install', 'toggleSelect']);
|
||||
const emit = defineEmits(['install', 'toggleSelect', 'togglePostInstall']);
|
||||
|
||||
const displayProgress = computed(() => {
|
||||
if (!props.software.progress) return '准备中';
|
||||
@@ -288,17 +305,6 @@ const handleCardClick = () => {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13px;
|
||||
color: var(--text-sec);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -320,10 +326,64 @@ const handleCardClick = () => {
|
||||
.card-right {
|
||||
margin-left: 20px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.action-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.post-install-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.post-install-toggle:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-sec);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
background-color: #E5E5EA;
|
||||
border-radius: 9px;
|
||||
position: relative;
|
||||
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.toggle-switch.is-active {
|
||||
background-color: #34C759;
|
||||
}
|
||||
|
||||
.toggle-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.toggle-switch.is-active .toggle-dot {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 90px;
|
||||
height: 34px;
|
||||
@@ -373,6 +433,30 @@ const handleCardClick = () => {
|
||||
color: #AEAEB2;
|
||||
}
|
||||
|
||||
.status-configuring {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 90px;
|
||||
justify-content: center;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.mini-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(0, 122, 255, 0.1);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.config-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wait-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -11,6 +11,10 @@ const router = createRouter({
|
||||
path: '/essentials',
|
||||
component: () => import('../views/Essentials.vue')
|
||||
},
|
||||
{
|
||||
path: '/other-software',
|
||||
component: () => import('../views/OtherSoftware.vue')
|
||||
},
|
||||
{
|
||||
path: '/updates',
|
||||
component: () => import('../views/Updates.vue')
|
||||
|
||||
137
src/store/catalog.ts
Normal file
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 { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const sortByName = (a: any, b: any) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' });
|
||||
import { useCatalogStore } from './catalog'
|
||||
import { useTaskRuntimeStore } from './taskRuntime'
|
||||
|
||||
// 版本比对工具函数
|
||||
const compareVersions = (v1: string | null | undefined, v2: string | null | undefined) => {
|
||||
if (!v1 || !v2) return 0;
|
||||
if (v1 === v2) return 0;
|
||||
const cleanV = (v: string) => v.replace(/^v/i, '').split(/[-+]/)[0].split('.');
|
||||
const p1 = cleanV(v1);
|
||||
const p2 = cleanV(v2);
|
||||
const len = Math.max(p1.length, p2.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const n1 = parseInt(p1[i] || '0', 10);
|
||||
const n2 = parseInt(p2[i] || '0', 10);
|
||||
if (n1 < n2) return -1;
|
||||
if (n1 > n2) return 1;
|
||||
export const useSoftwareStore = defineStore('software', () => {
|
||||
const catalog = useCatalogStore()
|
||||
const taskRuntime = useTaskRuntimeStore()
|
||||
|
||||
const {
|
||||
essentials,
|
||||
essentialsVersion,
|
||||
updates,
|
||||
allSoftware,
|
||||
settings,
|
||||
loading,
|
||||
isInitialized,
|
||||
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 {
|
||||
id: string; // 日志唯一标识
|
||||
timestamp: string;
|
||||
command: string;
|
||||
output: string;
|
||||
status: 'info' | 'success' | 'error';
|
||||
}
|
||||
const mergedEssentials = computed(() => essentials.value.map(mergeSoftwareItem))
|
||||
|
||||
export const useSoftwareStore = defineStore('software', {
|
||||
state: () => ({
|
||||
essentials: [] as any[],
|
||||
essentialsVersion: '',
|
||||
updates: [] as any[],
|
||||
allSoftware: [] as any[],
|
||||
selectedEssentialIds: [] as string[],
|
||||
selectedUpdateIds: [] as string[],
|
||||
logs: [] as LogEntry[],
|
||||
settings: {
|
||||
repo_url: 'https://karlblue.github.io/winget-repo'
|
||||
},
|
||||
activeTasks: {} as Record<string, { status: string, progress: number, targetVersion?: string }>,
|
||||
loading: false,
|
||||
isInitialized: false,
|
||||
initStatus: '正在检查系统环境...',
|
||||
lastFetched: 0,
|
||||
refreshTimer: null as any,
|
||||
batchQueue: [] as string[]
|
||||
}),
|
||||
getters: {
|
||||
mergedEssentials: (state) => {
|
||||
return state.essentials.map(item => {
|
||||
const installedInfo = state.allSoftware.find(s => s.id.toLowerCase() === item.id.toLowerCase());
|
||||
const wingetUpdate = state.updates.find(s => s.id.toLowerCase() === item.id.toLowerCase());
|
||||
const categorizedEssentials = computed(() => {
|
||||
return mergedEssentials.value.reduce((acc, item) => {
|
||||
const category = item.category === 'special' ? 'special' : 'general'
|
||||
acc[category].push(item)
|
||||
return acc
|
||||
}, { general: [] as typeof mergedEssentials.value, special: [] as typeof mergedEssentials.value })
|
||||
})
|
||||
|
||||
const task = state.activeTasks[item.id];
|
||||
const isInstalled = !!installedInfo;
|
||||
const currentVersion = installedInfo?.version;
|
||||
const recommendedVersion = item.version; // 清单里的推荐版本
|
||||
const availableVersion = wingetUpdate?.available_version; // Winget 查到的最新版
|
||||
const generalEssentials = computed(() => categorizedEssentials.value.general)
|
||||
const specialEssentials = computed(() => categorizedEssentials.value.special)
|
||||
|
||||
let displayStatus = task ? task.status : 'idle';
|
||||
let actionLabel = '安装';
|
||||
let targetVersion = recommendedVersion || availableVersion;
|
||||
const sortedUpdates = computed(() => [...updates.value].map(item => {
|
||||
const task = activeTasks.value[item.id]
|
||||
const enablePostInstall = postInstallPrefs.value[item.id] !== false
|
||||
|
||||
if (isInstalled) {
|
||||
// 逻辑:已安装 >= 推荐 -> 已安装(禁用)
|
||||
// 逻辑:已安装 < 推荐 -> 更新
|
||||
const comp = compareVersions(currentVersion, recommendedVersion);
|
||||
if (comp >= 0) {
|
||||
displayStatus = task ? task.status : 'installed';
|
||||
actionLabel = '已安装';
|
||||
targetVersion = undefined; // 禁用安装
|
||||
} else {
|
||||
actionLabel = '更新';
|
||||
targetVersion = recommendedVersion;
|
||||
}
|
||||
} else {
|
||||
actionLabel = '安装';
|
||||
targetVersion = recommendedVersion || availableVersion;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
version: currentVersion,
|
||||
recommended_version: recommendedVersion,
|
||||
available_version: availableVersion,
|
||||
status: displayStatus,
|
||||
progress: task ? task.progress : 0,
|
||||
actionLabel,
|
||||
targetVersion // 传递给视图,用于点击安装
|
||||
};
|
||||
});
|
||||
},
|
||||
sortedUpdates: (state) => {
|
||||
return [...state.updates].map(item => {
|
||||
const task = state.activeTasks[item.id];
|
||||
return {
|
||||
...item,
|
||||
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'
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
version: item.version ?? undefined,
|
||||
recommended_version: item.recommended_version ?? undefined,
|
||||
available_version: item.available_version ?? undefined,
|
||||
icon_url: item.icon_url ?? undefined,
|
||||
manifest_url: item.manifest_url ?? undefined,
|
||||
post_install_url: item.post_install_url ?? undefined,
|
||||
actionLabel: item.action_label,
|
||||
targetVersion: item.target_version ?? undefined,
|
||||
status: task ? task.status : 'idle',
|
||||
progress: task ? task.progress : 0,
|
||||
enablePostInstall
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
// ... (initializeApp, saveSettings, syncEssentials stay the same)
|
||||
async initializeApp() {
|
||||
if (this.isInitialized) return;
|
||||
this.initStatus = '正在加载应用配置...';
|
||||
try {
|
||||
this.settings = await invoke('get_settings');
|
||||
this.initStatus = '正在同步 Winget 模块...';
|
||||
await invoke('initialize_app');
|
||||
this.isInitialized = true;
|
||||
} catch (err) {
|
||||
this.initStatus = '环境配置失败,请检查运行日志';
|
||||
setTimeout(() => { this.isInitialized = true; }, 2000);
|
||||
}
|
||||
},
|
||||
}).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' })))
|
||||
|
||||
async saveSettings(newSettings: any) {
|
||||
await invoke('save_settings', { settings: newSettings });
|
||||
this.settings = newSettings;
|
||||
},
|
||||
const isBusy = computed(() => loading.value || taskRuntime.isTaskBusy)
|
||||
|
||||
async syncEssentials() {
|
||||
this.loading = true;
|
||||
try {
|
||||
await invoke('sync_essentials');
|
||||
await this.fetchEssentials();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
const toggleSelection = (id: string, type: 'essential' | 'special' | 'update') => {
|
||||
if (isBusy.value) return
|
||||
taskRuntime.toggleSelection(id, type)
|
||||
}
|
||||
|
||||
toggleSelection(id: string, type: 'essential' | 'update') {
|
||||
if (this.isBusy) return;
|
||||
const list = type === 'essential' ? this.selectedEssentialIds : this.selectedUpdateIds;
|
||||
const index = list.indexOf(id);
|
||||
if (index === -1) list.push(id);
|
||||
else list.splice(index, 1);
|
||||
},
|
||||
selectAll(type: 'essential' | 'update') {
|
||||
if (type === 'essential') {
|
||||
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 selectAll = (type: 'essential' | 'special' | 'update') => {
|
||||
if (type === 'essential') {
|
||||
const selectable = generalEssentials.value.filter(item => item.actionLabel !== '已安装')
|
||||
taskRuntime.setSelection('essential', selectable.map(item => item.id))
|
||||
} else if (type === 'special') {
|
||||
const selectable = specialEssentials.value.filter(item => item.actionLabel !== '已安装')
|
||||
taskRuntime.setSelection('special', selectable.map(item => item.id))
|
||||
} else {
|
||||
taskRuntime.setSelection('update', updates.value.map(item => item.id))
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<main class="content">
|
||||
<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.selectedEssentialIds.length === 0"
|
||||
>
|
||||
安装/更新所选 ({{ store.selectedEssentialIds.length }})
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- 固定标头区域 -->
|
||||
<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.selectedEssentialIds.length === 0"
|
||||
>
|
||||
安装所选 ({{ store.selectedEssentialIds.length }})
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 批量选择控制栏 -->
|
||||
<div class="selection-toolbar" v-if="selectableItems.length > 0">
|
||||
<div class="toolbar-left">
|
||||
<span class="selection-count">已选 {{ store.selectedEssentialIds.length }} / {{ selectableItems.length }} 项</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="store.selectAll('essential')" class="text-btn" :disabled="store.isBusy">全选</button>
|
||||
<div class="divider"></div>
|
||||
<button @click="store.deselectAll('essential')" class="text-btn" :disabled="store.isBusy">取消</button>
|
||||
<div class="divider"></div>
|
||||
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
|
||||
<!-- 批量选择控制栏 -->
|
||||
<div class="selection-toolbar" v-if="selectableItems.length > 0">
|
||||
<div class="toolbar-left">
|
||||
<span class="selection-count">已选 {{ store.selectedEssentialIds.length }} / {{ selectableItems.length }} 项</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="store.selectAll('essential')" class="text-btn" :disabled="store.isBusy">全选</button>
|
||||
<div class="divider"></div>
|
||||
<button @click="store.deselectAll('essential')" class="text-btn" :disabled="store.isBusy">取消</button>
|
||||
<div class="divider"></div>
|
||||
<button @click="store.invertSelection('essential')" class="text-btn" :disabled="store.isBusy">反选</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>正在读取必备软件列表...</p>
|
||||
</div>
|
||||
<!-- 可滚动内容区域 -->
|
||||
<div class="scroll-content">
|
||||
<div v-if="store.loading && store.generalEssentials.length === 0" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>正在读取软件列表...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="software-list">
|
||||
<SoftwareCard
|
||||
v-for="item in store.mergedEssentials"
|
||||
:key="item.id"
|
||||
:software="item"
|
||||
:action-label="item.actionLabel"
|
||||
:selectable="true"
|
||||
:is-selected="store.selectedEssentialIds.includes(item.id)"
|
||||
:disabled="store.isBusy"
|
||||
@install="store.install"
|
||||
@toggle-select="id => store.toggleSelection(id, 'essential')"
|
||||
/>
|
||||
<div v-else class="software-list">
|
||||
<SoftwareCard
|
||||
v-for="item in store.generalEssentials"
|
||||
:key="item.id"
|
||||
:software="item"
|
||||
:action-label="item.actionLabel"
|
||||
:selectable="true"
|
||||
:is-selected="store.selectedEssentialIds.includes(item.id)"
|
||||
:disabled="store.isBusy"
|
||||
@install="store.install"
|
||||
@toggle-select="store.toggleSelection($event, 'essential')"
|
||||
@toggle-post-install="store.togglePostInstallPref"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -74,14 +81,14 @@ import { onMounted, computed } from 'vue';
|
||||
const store = useSoftwareStore();
|
||||
|
||||
const selectableItems = computed(() => {
|
||||
return store.mergedEssentials.filter(s => s.status !== 'installed');
|
||||
return store.generalEssentials.filter(s => s.status !== 'installed');
|
||||
});
|
||||
|
||||
const installSelected = () => {
|
||||
const ids = [...store.selectedEssentialIds];
|
||||
store.startBatch(ids);
|
||||
ids.forEach(id => {
|
||||
const item = store.mergedEssentials.find(s => s.id === id);
|
||||
const item = store.generalEssentials.find(s => s.id === id);
|
||||
if (item) {
|
||||
store.install(id, item.targetVersion);
|
||||
}
|
||||
@@ -97,15 +104,30 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 40px 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden; /* 关键:禁止最外层滚动 */
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
padding: 24px 60px 8px 60px;
|
||||
background-color: #F5F5F7; /* 与 App.vue 背景色保持一致 */
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scroll-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 60px 32px 60px;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@@ -136,16 +158,16 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 批量选择工具栏 */
|
||||
.selection-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
background-color: white; /* 这里的工具栏背景设为白色更清晰 */
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
|
||||
293
src/views/OtherSoftware.vue
Normal file
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-info">
|
||||
<label>仓库地址</label>
|
||||
<p>应用将从该地址同步“装机必备”软件清单</p>
|
||||
<p>应用将从该地址同步软件清单</p>
|
||||
</div>
|
||||
<div class="setting-action">
|
||||
<input
|
||||
@@ -47,8 +47,8 @@
|
||||
<section class="settings-section">
|
||||
<h3 class="section-title">关于</h3>
|
||||
<div class="settings-card about-card">
|
||||
<p>Windows 软件管理器 v0.1.0</p>
|
||||
<p class="hint">基于 Tauri 和 WinGet 构建的现代化软件管理工具</p>
|
||||
<p>Windows 软件管理 v{{ version }}</p>
|
||||
<p class="hint">基于 WinGet 构建的 Windows 软件管理工具</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -65,6 +65,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useSoftwareStore } from '../store/software'
|
||||
import { version } from "../../package.json";
|
||||
|
||||
const store = useSoftwareStore()
|
||||
const tempRepoUrl = ref('')
|
||||
@@ -106,8 +107,8 @@ const handleSave = async () => {
|
||||
|
||||
const handleSync = async () => {
|
||||
try {
|
||||
await store.syncEssentials()
|
||||
showToast('清单同步成功')
|
||||
const result = await store.syncEssentials()
|
||||
showToast(result.message, result.status === 'updated' ? 'success' : 'error')
|
||||
} catch (err) {
|
||||
showToast('同步失败,请检查网络或地址', 'error')
|
||||
}
|
||||
|
||||
@@ -1,71 +1,78 @@
|
||||
<template>
|
||||
<main class="content">
|
||||
<header class="content-header">
|
||||
<div class="header-left">
|
||||
<h1>软件更新</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
@click="store.fetchUpdates"
|
||||
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="updateSelected"
|
||||
class="primary-btn action-btn"
|
||||
:disabled="store.selectedUpdateIds.length === 0 || store.loading || store.isBusy"
|
||||
>
|
||||
更新所选 ({{ store.selectedUpdateIds.length }})
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- 固定标头区域 -->
|
||||
<div class="sticky-header">
|
||||
<header class="content-header">
|
||||
<div class="header-left">
|
||||
<h1>软件更新</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
@click="store.fetchUpdates"
|
||||
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="updateSelected"
|
||||
class="primary-btn action-btn"
|
||||
:disabled="store.selectedUpdateIds.length === 0 || store.loading || store.isBusy"
|
||||
>
|
||||
更新所选 ({{ store.selectedUpdateIds.length }})
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 批量选择控制栏 -->
|
||||
<div class="selection-toolbar" v-if="store.sortedUpdates.length > 0">
|
||||
<div class="toolbar-left">
|
||||
<span class="selection-count">已选 {{ store.selectedUpdateIds.length }} / {{ store.sortedUpdates.length }} 项</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="store.selectAll('update')" class="text-btn" :disabled="store.isBusy">全选</button>
|
||||
<div class="divider"></div>
|
||||
<button @click="store.deselectAll('update')" class="text-btn" :disabled="store.isBusy">取消</button>
|
||||
<div class="divider"></div>
|
||||
<button @click="store.invertSelection('update')" class="text-btn" :disabled="store.isBusy">反选</button>
|
||||
<!-- 批量选择控制栏 -->
|
||||
<div class="selection-toolbar" v-if="store.sortedUpdates.length > 0">
|
||||
<div class="toolbar-left">
|
||||
<span class="selection-count">已选 {{ store.selectedUpdateIds.length }} / {{ store.sortedUpdates.length }} 项</span>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="store.selectAll('update')" class="text-btn" :disabled="store.isBusy">全选</button>
|
||||
<div class="divider"></div>
|
||||
<button @click="store.deselectAll('update')" class="text-btn" :disabled="store.isBusy">取消</button>
|
||||
<div class="divider"></div>
|
||||
<button @click="store.invertSelection('update')" class="text-btn" :disabled="store.isBusy">反选</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>正在使用 Winget 扫描可用的更新...</p>
|
||||
</div>
|
||||
<!-- 可滚动内容区域 -->
|
||||
<div class="scroll-content">
|
||||
<div v-if="store.loading && store.updates.length === 0" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>正在使用 Winget 扫描可用的更新...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.updates.length === 0 && !store.loading" class="empty-state">
|
||||
<span class="empty-icon">✅</span>
|
||||
<p>所有软件已是最新版本</p>
|
||||
</div>
|
||||
<div v-else-if="store.updates.length === 0 && !store.loading" class="empty-state">
|
||||
<span class="empty-icon">✅</span>
|
||||
<p>所有软件已是最新版本</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="software-list">
|
||||
<SoftwareCard
|
||||
v-for="item in store.sortedUpdates"
|
||||
:key="item.id"
|
||||
:software="item"
|
||||
action-label="更新"
|
||||
:selectable="true"
|
||||
:is-selected="store.selectedUpdateIds.includes(item.id)"
|
||||
:disabled="store.isBusy"
|
||||
@install="store.install"
|
||||
@toggle-select="id => store.toggleSelection(id, 'update')"
|
||||
/>
|
||||
<div v-else class="software-list">
|
||||
<SoftwareCard
|
||||
v-for="item in store.sortedUpdates"
|
||||
:key="item.id"
|
||||
:software="item"
|
||||
:action-label="item.actionLabel"
|
||||
:selectable="true"
|
||||
:is-selected="store.selectedUpdateIds.includes(item.id)"
|
||||
:disabled="store.isBusy"
|
||||
@install="store.install"
|
||||
@toggle-select="store.toggleSelection($event, 'update')"
|
||||
@toggle-post-install="store.togglePostInstallPref"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -98,15 +105,30 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 40px 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden; /* 关键:禁止最外层滚动 */
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
padding: 24px 60px 8px 60px;
|
||||
background-color: #F5F5F7;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scroll-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 60px 32px 60px;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.content-header h1 {
|
||||
@@ -121,16 +143,16 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 批量选择工具栏 */
|
||||
.selection-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 20px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
background-color: white;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
|
||||
Reference in New Issue
Block a user