Compare commits
11 Commits
bba113e089
...
52cf6736cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52cf6736cf | ||
|
|
cfe93144e6 | ||
|
|
87ffe2e243 | ||
|
|
72d878d221 | ||
|
|
708df41063 | ||
|
|
73976d1367 | ||
|
|
2625c8b52f | ||
|
|
0fc523e234 | ||
|
|
db377852fc | ||
|
|
2aaa330c9a | ||
|
|
fe86431899 |
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
|
||||
|
||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -5108,6 +5108,7 @@ dependencies = [
|
||||
name = "win-softmgr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
|
||||
@@ -27,4 +27,4 @@ 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,582 +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, PostInstallStep};
|
||||
use regex::Regex;
|
||||
use winreg::RegKey;
|
||||
use winreg::enums::*;
|
||||
|
||||
#[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>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enable_post_install: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool { true }
|
||||
|
||||
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())?;
|
||||
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]
|
||||
async fn get_software_info(app: AppHandle, id: String) -> Option<Software> {
|
||||
tokio::task::spawn_blocking(move || winget::get_software_info(&app, &id)).await.unwrap_or(None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_logs_history() -> Vec<LogPayload> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn expand_win_path(path: &str) -> PathBuf {
|
||||
let mut expanded = path.to_string();
|
||||
let env_vars = [
|
||||
"AppData", "LocalAppData", "ProgramData", "SystemRoot", "SystemDrive", "TEMP", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"
|
||||
];
|
||||
|
||||
for var in env_vars {
|
||||
let re = Regex::new(&format!(r"(?i)%{}%", var)).unwrap();
|
||||
if re.is_match(&expanded) {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
expanded = re.replace_all(&expanded, val.as_str()).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
PathBuf::from(expanded)
|
||||
}
|
||||
|
||||
async fn execute_post_install(handle: &AppHandle, log_id: &str, steps: Vec<PostInstallStep>) -> Result<(), String> {
|
||||
let steps_len = steps.len();
|
||||
for (i, step) in steps.into_iter().enumerate() {
|
||||
let step_prefix = format!("Step {}/{}: ", i + 1, steps_len);
|
||||
|
||||
let delay = match &step {
|
||||
PostInstallStep::RegistryBatch { delay_ms, .. } => *delay_ms,
|
||||
PostInstallStep::FileCopy { delay_ms, .. } => *delay_ms,
|
||||
PostInstallStep::FileDelete { delay_ms, .. } => *delay_ms,
|
||||
PostInstallStep::Command { delay_ms, .. } => *delay_ms,
|
||||
};
|
||||
|
||||
match step {
|
||||
PostInstallStep::RegistryBatch { root, base_path, values, .. } => {
|
||||
emit_log(handle, log_id, "Registry Update", &format!("{}Applying batch registry settings to {}...", step_prefix, base_path), "info");
|
||||
let hive = match root.as_str() {
|
||||
"HKCU" => RegKey::predef(HKEY_CURRENT_USER),
|
||||
"HKLM" => RegKey::predef(HKEY_LOCAL_MACHINE),
|
||||
_ => {
|
||||
emit_log(handle, log_id, "Registry Error", &format!("Unknown root hive: {}", root), "error");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match hive.create_subkey(&base_path) {
|
||||
Ok((key, _)) => {
|
||||
for (name, val) in values {
|
||||
let res = match val.v_type.as_str() {
|
||||
"String" => key.set_value(&name, &val.data.as_str().unwrap_or_default()),
|
||||
"Dword" => key.set_value(&name, &(val.data.as_u64().unwrap_or(0) as u32)),
|
||||
"Qword" => key.set_value(&name, &(val.data.as_u64().unwrap_or(0))),
|
||||
"MultiString" => {
|
||||
let strings: Vec<String> = val.data.as_array()
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.unwrap_or_default();
|
||||
key.set_value(&name, &strings)
|
||||
},
|
||||
"ExpandString" => {
|
||||
key.set_value(&name, &val.data.as_str().unwrap_or_default())
|
||||
},
|
||||
"Delete" => {
|
||||
key.delete_value(&name)
|
||||
},
|
||||
_ => Err(std::io::Error::new(std::io::ErrorKind::Other, "Unsupported type")),
|
||||
};
|
||||
if let Err(e) = res {
|
||||
emit_log(handle, log_id, "Registry Error", &format!("Failed to apply {}: {}", name, e), "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
emit_log(handle, log_id, "Registry Error", &format!("Failed to create/open key {}: {}", base_path, e), "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
PostInstallStep::FileCopy { src, dest, .. } => {
|
||||
let dest_path = expand_win_path(&dest);
|
||||
let src_is_url = src.starts_with("http://") || src.starts_with("https://");
|
||||
|
||||
if src_is_url {
|
||||
emit_log(handle, log_id, "File Download", &format!("{}Downloading {:?} to {:?}...", step_prefix, src, dest_path), "info");
|
||||
let client = reqwest::Client::new();
|
||||
match client.get(&src).timeout(std::time::Duration::from_secs(60)).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(bytes) = resp.bytes().await {
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
if let Err(e) = fs::write(&dest_path, bytes) {
|
||||
emit_log(handle, log_id, "File Error", &format!("Failed to write to {:?}: {}", dest_path, e), "error");
|
||||
} else {
|
||||
emit_log(handle, log_id, "Success", "File downloaded and saved successfully.", "success");
|
||||
}
|
||||
}
|
||||
},
|
||||
Ok(resp) => emit_log(handle, log_id, "Download Error", &format!("HTTP Status: {}", resp.status()), "error"),
|
||||
Err(e) => emit_log(handle, log_id, "Download Error", &e.to_string(), "error"),
|
||||
}
|
||||
} else {
|
||||
let src_path = expand_win_path(&src);
|
||||
emit_log(handle, log_id, "File Copy", &format!("{}Copying {:?} to {:?}...", step_prefix, src_path, dest_path), "info");
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
if let Err(e) = fs::copy(&src_path, &dest_path) {
|
||||
emit_log(handle, log_id, "File Error", &format!("Failed to copy file: {}", e), "error");
|
||||
} else {
|
||||
emit_log(handle, log_id, "Success", "File copied successfully.", "success");
|
||||
}
|
||||
}
|
||||
},
|
||||
PostInstallStep::FileDelete { path, .. } => {
|
||||
let full_path = expand_win_path(&path);
|
||||
emit_log(handle, log_id, "File Delete", &format!("{}Deleting {:?}...", step_prefix, full_path), "info");
|
||||
if full_path.exists() {
|
||||
if let Err(e) = fs::remove_file(&full_path) {
|
||||
emit_log(handle, log_id, "File Error", &format!("Failed to delete file: {}", e), "error");
|
||||
} else {
|
||||
emit_log(handle, log_id, "Success", "File deleted successfully.", "success");
|
||||
}
|
||||
} else {
|
||||
emit_log(handle, log_id, "File Info", "File does not exist, skipping.", "info");
|
||||
}
|
||||
},
|
||||
PostInstallStep::Command { run, .. } => {
|
||||
emit_log(handle, log_id, "Command Execution", &format!("{}Executing: {}", step_prefix, run), "info");
|
||||
let output = Command::new("cmd")
|
||||
.arg("/C")
|
||||
.raw_arg(&run)
|
||||
.creation_flags(0x08000000)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) => {
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
emit_log(handle, log_id, "Command Failed", &err, "error");
|
||||
} else {
|
||||
emit_log(handle, log_id, "Success", "Command executed successfully.", "success");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
emit_log(handle, log_id, "Execution Error", &e.to_string(), "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ms) = delay {
|
||||
if ms > 0 {
|
||||
emit_log(handle, log_id, "Post-Install", &format!("Waiting for {}ms...", ms), "info");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
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 enable_post_install_flag = task.enable_post_install;
|
||||
|
||||
let log_id = format!("install-{}", task_id);
|
||||
|
||||
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);
|
||||
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);
|
||||
let status_result = if exit_status { "success" } else { "error" };
|
||||
|
||||
if status_result == "success" && enable_post_install_flag {
|
||||
let essentials = get_essentials(handle.clone());
|
||||
let software_info = essentials.and_then(|repo| {
|
||||
repo.essentials.into_iter().find(|s| s.id == task_id)
|
||||
});
|
||||
|
||||
if let Some(sw) = software_info {
|
||||
let mut final_steps = None;
|
||||
if let Some(steps) = sw.post_install {
|
||||
if !steps.is_empty() {
|
||||
final_steps = Some(steps);
|
||||
}
|
||||
} else if let Some(url) = sw.post_install_url {
|
||||
emit_log(&handle, &log_id, "Post-Install", "Local config not found, fetching remote config...", "info");
|
||||
let client = reqwest::Client::new();
|
||||
if let Ok(resp) = client.get(&url).timeout(std::time::Duration::from_secs(10)).send().await {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(text) = resp.text().await {
|
||||
match serde_json::from_str::<Vec<PostInstallStep>>(&text) {
|
||||
Ok(steps) => {
|
||||
emit_log(&handle, &log_id, "Post-Install", &format!("Successfully fetched remote config with {} steps.", steps.len()), "info");
|
||||
final_steps = Some(steps);
|
||||
},
|
||||
Err(e) => {
|
||||
emit_log(&handle, &log_id, "Post-Install Error", &format!("JSON Parse Error: {}. Raw Content: {}", e, text), "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emit_log(&handle, &log_id, "Post-Install Error", &format!("Remote config HTTP Error: {}", resp.status()), "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(steps) = final_steps {
|
||||
let _ = handle.emit("install-status", InstallProgress {
|
||||
id: task_id.clone(),
|
||||
status: "configuring".to_string(),
|
||||
progress: 1.0,
|
||||
});
|
||||
emit_log(&handle, &log_id, "Post-Install", "Starting post-installation configuration...", "info");
|
||||
if let Err(e) = execute_post_install(&handle, &log_id, steps).await {
|
||||
emit_log(&handle, &log_id, "Post-Install Error", &e, "error");
|
||||
} else {
|
||||
emit_log(&handle, &log_id, "Post-Install", "Post-installation configuration completed.", "success");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
status_result
|
||||
},
|
||||
Err(e) => {
|
||||
emit_log(&h, &log_id, "Fatal Error", &e.to_string(), "error");
|
||||
"error"
|
||||
}
|
||||
};
|
||||
|
||||
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" });
|
||||
|
||||
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,
|
||||
get_software_info,
|
||||
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,9 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use base64::Engine;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
use crate::emit_log;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use crate::services::log_service::emit_log;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RegistryValue {
|
||||
@@ -44,6 +47,7 @@ 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>,
|
||||
@@ -176,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 {
|
||||
@@ -285,6 +223,111 @@ pub fn get_software_info(handle: &AppHandle, id: &str) -> Option<Software> {
|
||||
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");
|
||||
|
||||
@@ -329,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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,346 +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[],
|
||||
postInstallPrefs: {} as Record<string, boolean> // 记录用户对每个软件后安装配置的偏好
|
||||
}),
|
||||
getters: {
|
||||
mergedEssentials: (state) => {
|
||||
return state.essentials.map(item => {
|
||||
const installedInfo = state.allSoftware.find(s => s.id.toLowerCase() === item.id.toLowerCase());
|
||||
const wingetUpdate = state.updates.find(s => s.id.toLowerCase() === item.id.toLowerCase());
|
||||
|
||||
const task = state.activeTasks[item.id];
|
||||
const isInstalled = !!installedInfo;
|
||||
const currentVersion = installedInfo?.version;
|
||||
const recommendedVersion = item.version; // 清单里的推荐版本
|
||||
const availableVersion = wingetUpdate?.available_version; // Winget 查到的最新版
|
||||
|
||||
let displayStatus = task ? task.status : 'idle';
|
||||
let actionLabel = '安装';
|
||||
let targetVersion = recommendedVersion || availableVersion;
|
||||
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 })
|
||||
})
|
||||
|
||||
if (isInstalled) {
|
||||
const comp = compareVersions(currentVersion, recommendedVersion);
|
||||
if (comp >= 0) {
|
||||
displayStatus = task ? task.status : 'installed';
|
||||
actionLabel = '已安装';
|
||||
targetVersion = undefined;
|
||||
} else {
|
||||
actionLabel = '更新';
|
||||
targetVersion = recommendedVersion;
|
||||
}
|
||||
} else {
|
||||
actionLabel = '安装';
|
||||
targetVersion = recommendedVersion || availableVersion;
|
||||
}
|
||||
const generalEssentials = computed(() => categorizedEssentials.value.general)
|
||||
const specialEssentials = computed(() => categorizedEssentials.value.special)
|
||||
|
||||
// 获取偏好,默认开启
|
||||
const enablePostInstall = state.postInstallPrefs[item.id] !== false;
|
||||
const sortedUpdates = computed(() => [...updates.value].map(item => {
|
||||
const task = activeTasks.value[item.id]
|
||||
const enablePostInstall = postInstallPrefs.value[item.id] !== false
|
||||
|
||||
return {
|
||||
...item,
|
||||
version: currentVersion,
|
||||
recommended_version: recommendedVersion,
|
||||
available_version: availableVersion,
|
||||
status: displayStatus,
|
||||
progress: task ? task.progress : 0,
|
||||
actionLabel,
|
||||
targetVersion,
|
||||
enablePostInstall
|
||||
};
|
||||
});
|
||||
},
|
||||
sortedUpdates: (state) => {
|
||||
return [...state.updates].map(item => {
|
||||
const task = state.activeTasks[item.id];
|
||||
const enablePostInstall = state.postInstallPrefs[item.id] !== false;
|
||||
return {
|
||||
...item,
|
||||
status: task ? task.status : 'idle',
|
||||
progress: task ? task.progress : 0,
|
||||
actionLabel: '更新',
|
||||
targetVersion: item.available_version,
|
||||
enablePostInstall
|
||||
};
|
||||
}).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: {
|
||||
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) {
|
||||
// 根据偏好决定是否开启后安装配置
|
||||
const enablePostInstall = this.postInstallPrefs[id] !== false;
|
||||
|
||||
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,
|
||||
enable_post_install: enablePostInstall
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Invoke install failed:', err);
|
||||
this.activeTasks[id] = { status: 'error', progress: 0 };
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
togglePostInstallPref(id: string) {
|
||||
const current = this.postInstallPrefs[id] !== false;
|
||||
this.postInstallPrefs[id] = !current;
|
||||
},
|
||||
|
||||
startBatch(ids: string[]) {
|
||||
this.batchQueue = [...ids];
|
||||
},
|
||||
|
||||
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', async (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') {
|
||||
try {
|
||||
const latestInfo = await invoke('get_software_info', { id }) as any;
|
||||
if (latestInfo) {
|
||||
const index = this.allSoftware.findIndex(s => s.id.toLowerCase() === id.toLowerCase());
|
||||
if (index !== -1) {
|
||||
this.allSoftware[index] = { ...this.allSoftware[index], ...latestInfo };
|
||||
} else {
|
||||
this.allSoftware.push(latestInfo);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Partial refresh failed:', err);
|
||||
}
|
||||
|
||||
this.selectedEssentialIds = this.selectedEssentialIds.filter(i => i !== id);
|
||||
this.selectedUpdateIds = this.selectedUpdateIds.filter(i => i !== id);
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.activeTasks[id]?.status === 'success') {
|
||||
delete this.activeTasks[id];
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const index = this.batchQueue.indexOf(id);
|
||||
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
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="sticky-header">
|
||||
<header class="content-header">
|
||||
<div class="header-left">
|
||||
<h1>装机必备</h1>
|
||||
<h1>装机常用</h1>
|
||||
<span v-if="store.essentialsVersion" class="version-badge">版本: {{ store.essentialsVersion }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
@@ -50,14 +50,14 @@
|
||||
|
||||
<!-- 可滚动内容区域 -->
|
||||
<div class="scroll-content">
|
||||
<div v-if="store.loading && store.mergedEssentials.length === 0" class="loading-state">
|
||||
<div v-if="store.loading && store.generalEssentials.length === 0" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>正在读取必备软件列表...</p>
|
||||
<p>正在读取软件列表...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="software-list">
|
||||
<SoftwareCard
|
||||
v-for="item in store.mergedEssentials"
|
||||
v-for="item in store.generalEssentials"
|
||||
:key="item.id"
|
||||
:software="item"
|
||||
:action-label="item.actionLabel"
|
||||
@@ -81,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);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -107,8 +107,8 @@ const handleSave = async () => {
|
||||
|
||||
const handleSync = async () => {
|
||||
try {
|
||||
await store.syncEssentials()
|
||||
showToast('清单同步成功')
|
||||
const result = await store.syncEssentials()
|
||||
showToast(result.message, result.status === 'updated' ? 'success' : 'error')
|
||||
} catch (err) {
|
||||
showToast('同步失败,请检查网络或地址', 'error')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user