refactor 1

This commit is contained in:
Julian Freeman
2026-04-18 15:55:03 -04:00
parent fe86431899
commit 2aaa330c9a
19 changed files with 1031 additions and 640 deletions

View File

@@ -0,0 +1,55 @@
use tauri::AppHandle;
use crate::domain::models::{AppSettings, EssentialsRepo, LogPayload, SyncEssentialsResult};
use crate::services::{essentials_service, settings_service, software_state_service};
use crate::winget::Software;
#[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_installed_software(app: AppHandle) -> Vec<Software> {
software_state_service::get_installed_software(app).await
}
#[tauri::command]
pub async fn get_updates(app: AppHandle) -> Vec<Software> {
software_state_service::get_updates(app).await
}
#[tauri::command]
pub async fn get_software_info(app: AppHandle, id: String) -> Option<Software> {
software_state_service::get_software_info(app, id).await
}
#[tauri::command]
pub async fn get_software_icon(app: AppHandle, id: String, name: String) -> Option<String> {
software_state_service::get_software_icon(app, id, name).await
}
#[tauri::command]
pub fn get_logs_history() -> Vec<LogPayload> {
vec![]
}

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
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, Serialize)]
pub struct InstallProgress {
pub id: String,
pub status: String,
pub progress: f32,
}
#[derive(Clone)]
pub struct ResolvedPostInstall {
pub software: Software,
pub steps: Vec<PostInstallStep>,
}

View File

@@ -1,651 +1,33 @@
use tauri::Manager;
pub mod commands;
pub mod domain;
pub mod providers;
pub mod services;
pub mod storage;
pub mod tasks;
pub mod winget; pub mod winget;
use std::fs;
use std::io::Read;
use std::process::{Command, Stdio};
use std::os::windows::process::CommandExt;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::thread;
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, get_cached_or_extract_icon};
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, Deserialize)]
pub struct SyncEssentialsResult {
pub status: String,
pub message: String,
}
#[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<SyncEssentialsResult, String> {
let settings = get_settings(app.clone());
let url = format!("{}/setup-essentials.json", settings.repo_url.trim_end_matches('/'));
let cache_path = get_essentials_path(&app);
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() {
fs::write(cache_path, content).map_err(|e| e.to_string())?;
emit_log(&app, "sync-essentials", "Result", "Essentials list updated successfully.", "success");
Ok(SyncEssentialsResult {
status: "updated".to_string(),
message: "清单同步成功".to_string(),
})
} 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) => {
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(),
})
} else {
let err_msg = format!("Network issue: {}", e);
emit_log(&app, "sync-essentials", "Error", &err_msg, "error");
Err(err_msg)
}
}
}
}
#[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 get_software_icon(app: AppHandle, id: String, name: String) -> Option<String> {
tokio::task::spawn_blocking(move || get_cached_or_extract_icon(&app, &id, &name)).await.unwrap_or(None)
}
#[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 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("install-status", InstallProgress {
id: task_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 _ = handle.emit("install-status", InstallProgress {
id: task_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(&handle, &log_id, "", clean_line, "info");
}
} else {
emit_log(&handle, &log_id, stream_name, clean_line, "error");
}
}
}
})
}
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() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.setup(move |app| { .setup(move |app| {
let handle = app.handle().clone(); let install_state = tasks::install_queue::create_install_state(app.handle().clone());
let (tx, mut rx) = mpsc::channel::<InstallTask>(100); app.manage(install_state);
app.manage(AppState { install_tx: tx });
tauri::async_runtime::spawn(async move {
let perc_re = Regex::new(r"(\d+)\s*%").unwrap();
let size_re = Regex::new(r"([\d\.]+)\s*[a-zA-Z]+\s*/\s*([\d\.]+)\s*[a-zA-Z]+").unwrap();
while let Some(task) = rx.recv().await {
let task_id = task.id.clone();
let task_version = task.version.clone();
let use_manifest = task.use_manifest;
let manifest_url = task.manifest_url.clone();
let 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 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,
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,
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 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(&handle, &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);
}
}
});
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
initialize_app, commands::app_commands::initialize_app,
get_settings, commands::app_commands::get_settings,
save_settings, commands::app_commands::save_settings,
sync_essentials, commands::app_commands::sync_essentials,
get_essentials, commands::app_commands::get_essentials,
get_installed_software, commands::app_commands::get_installed_software,
get_updates, commands::app_commands::get_updates,
get_software_icon, commands::app_commands::get_software_icon,
get_software_info, commands::app_commands::get_software_info,
install_software, tasks::install_queue::install_software,
get_logs_history commands::app_commands::get_logs_history
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

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

View File

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

View File

@@ -0,0 +1,85 @@
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::storage::{essentials_store, paths};
pub async fn sync_essentials(app: &AppHandle) -> Result<SyncEssentialsResult, String> {
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(),
})
} 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) => {
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(),
})
} else {
let err_msg = format!("Network issue: {}", e);
emit_log(app, "sync-essentials", "Error", &err_msg, "error");
Err(err_msg)
}
}
}
}
pub fn get_essentials(app: &AppHandle) -> Option<EssentialsRepo> {
essentials_store::load_essentials(app)
}

View File

@@ -0,0 +1,17 @@
use tauri::{AppHandle, Emitter};
use crate::domain::models::LogPayload;
pub fn emit_log(handle: &AppHandle, id: &str, command: &str, output: &str, status: &str) {
let now = chrono::Local::now().format("%H:%M:%S").to_string();
let _ = handle.emit(
"log-event",
LogPayload {
id: id.to_string(),
timestamp: now,
command: command.to_string(),
output: output.to_string(),
status: status.to_string(),
},
);
}

View File

@@ -0,0 +1,4 @@
pub mod essentials_service;
pub mod log_service;
pub mod settings_service;
pub mod software_state_service;

View File

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

View File

@@ -0,0 +1,35 @@
use tauri::AppHandle;
use crate::providers::winget_client;
use crate::winget::Software;
pub async fn initialize_app(app: AppHandle) -> Result<bool, String> {
let app_clone = app.clone();
tokio::task::spawn_blocking(move || winget_client::ensure_environment_ready(&app_clone).map(|_| true))
.await
.unwrap_or(Err("Initialization Task Panicked".to_string()))
}
pub async fn get_installed_software(app: AppHandle) -> Vec<Software> {
tokio::task::spawn_blocking(move || winget_client::list_installed_packages(&app))
.await
.unwrap_or_default()
}
pub async fn get_updates(app: AppHandle) -> Vec<Software> {
tokio::task::spawn_blocking(move || winget_client::list_upgrade_candidates(&app))
.await
.unwrap_or_default()
}
pub async fn get_software_info(app: AppHandle, id: String) -> Option<Software> {
tokio::task::spawn_blocking(move || winget_client::get_package_by_id(&app, &id))
.await
.unwrap_or(None)
}
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)
}

View File

@@ -0,0 +1,21 @@
use std::fs;
use tauri::AppHandle;
use crate::domain::models::EssentialsRepo;
use crate::storage::paths::get_essentials_path;
pub fn load_essentials(app: &AppHandle) -> Option<EssentialsRepo> {
let file_path = get_essentials_path(app);
if !file_path.exists() {
return None;
}
let content = fs::read_to_string(file_path).unwrap_or_default();
serde_json::from_str(&content).ok()
}
pub fn save_essentials(app: &AppHandle, content: &str) -> Result<(), String> {
let path = get_essentials_path(app);
fs::write(path, content).map_err(|e| e.to_string())
}

View File

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

View File

@@ -0,0 +1,20 @@
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
pub fn get_app_data_dir(app: &AppHandle) -> PathBuf {
let app_data_dir = app.path().app_data_dir().unwrap_or_default();
if !app_data_dir.exists() {
let _ = fs::create_dir_all(&app_data_dir);
}
app_data_dir
}
pub fn get_settings_path(app: &AppHandle) -> PathBuf {
get_app_data_dir(app).join("settings.json")
}
pub fn get_essentials_path(app: &AppHandle) -> PathBuf {
get_app_data_dir(app).join("setup-essentials.json")
}

View File

@@ -0,0 +1,24 @@
use std::fs;
use tauri::AppHandle;
use crate::domain::models::AppSettings;
use crate::storage::paths::get_settings_path;
pub fn get_settings(app: &AppHandle) -> AppSettings {
let path = get_settings_path(app);
if !path.exists() {
let default_settings = AppSettings::default();
let _ = fs::write(&path, serde_json::to_string_pretty(&default_settings).unwrap());
return default_settings;
}
let content = fs::read_to_string(path).unwrap_or_default();
serde_json::from_str(&content).unwrap_or_default()
}
pub fn save_settings(app: &AppHandle, settings: &AppSettings) -> Result<(), String> {
let path = get_settings_path(app);
let content = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
fs::write(path, content).map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,641 @@
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::{InstallProgress, InstallTask};
use crate::services::essentials_service;
use crate::services::log_service::emit_log;
use crate::winget::PostInstallStep;
pub struct AppState {
pub install_tx: mpsc::Sender<InstallTask>,
}
pub fn create_install_state(handle: AppHandle) -> AppState {
let (tx, mut rx) = mpsc::channel::<InstallTask>(100);
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 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,
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,
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(&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(
&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(&handle, &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);
}
}
});
AppState { install_tx: tx }
}
#[tauri::command]
pub async fn install_software(
task: InstallTask,
state: State<'_, AppState>,
) -> Result<(), String> {
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(
"install-status",
InstallProgress {
id: task_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 _ = handle.emit(
"install-status",
InstallProgress {
id: task_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(&handle, &log_id, "", clean_line, "info");
}
} else {
emit_log(&handle, &log_id, stream_name, clean_line, "error");
}
}
}
})
}
fn expand_win_path(path: &str) -> PathBuf {
let mut expanded = path.to_string();
let env_vars = [
"AppData",
"LocalAppData",
"ProgramData",
"SystemRoot",
"SystemDrive",
"TEMP",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
];
for var in env_vars {
let re = Regex::new(&format!(r"(?i)%{}%", var)).unwrap();
if re.is_match(&expanded) {
if let Ok(val) = std::env::var(var) {
expanded = re.replace_all(&expanded, val.as_str()).to_string();
}
}
}
PathBuf::from(expanded)
}
async fn execute_post_install(
handle: &AppHandle,
log_id: &str,
steps: Vec<PostInstallStep>,
) -> Result<(), String> {
let steps_len = steps.len();
for (i, step) in steps.into_iter().enumerate() {
let step_prefix = format!("Step {}/{}: ", i + 1, steps_len);
let delay = match &step {
PostInstallStep::RegistryBatch { delay_ms, .. } => *delay_ms,
PostInstallStep::FileCopy { delay_ms, .. } => *delay_ms,
PostInstallStep::FileDelete { delay_ms, .. } => *delay_ms,
PostInstallStep::Command { delay_ms, .. } => *delay_ms,
};
match step {
PostInstallStep::RegistryBatch {
root,
base_path,
values,
..
} => {
emit_log(
handle,
log_id,
"Registry Update",
&format!("{}Applying batch registry settings to {}...", step_prefix, base_path),
"info",
);
let hive = match root.as_str() {
"HKCU" => RegKey::predef(HKEY_CURRENT_USER),
"HKLM" => RegKey::predef(HKEY_LOCAL_MACHINE),
_ => {
emit_log(
handle,
log_id,
"Registry Error",
&format!("Unknown root hive: {}", root),
"error",
);
continue;
}
};
match hive.create_subkey(&base_path) {
Ok((key, _)) => {
for (name, val) in values {
let res = match val.v_type.as_str() {
"String" => key.set_value(&name, &val.data.as_str().unwrap_or_default()),
"Dword" => key.set_value(&name, &(val.data.as_u64().unwrap_or(0) as u32)),
"Qword" => key.set_value(&name, &(val.data.as_u64().unwrap_or(0))),
"MultiString" => {
let strings: Vec<String> = val
.data
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
key.set_value(&name, &strings)
}
"ExpandString" => {
key.set_value(&name, &val.data.as_str().unwrap_or_default())
}
"Delete" => key.delete_value(&name),
_ => Err(std::io::Error::other("Unsupported type")),
};
if let Err(e) = res {
emit_log(
handle,
log_id,
"Registry Error",
&format!("Failed to apply {}: {}", name, e),
"error",
);
}
}
}
Err(e) => {
emit_log(
handle,
log_id,
"Registry Error",
&format!("Failed to create/open key {}: {}", base_path, e),
"error",
);
}
}
}
PostInstallStep::FileCopy { src, dest, .. } => {
let dest_path = expand_win_path(&dest);
let src_is_url = src.starts_with("http://") || src.starts_with("https://");
if src_is_url {
emit_log(
handle,
log_id,
"File Download",
&format!("{}Downloading {:?} to {:?}...", step_prefix, src, dest_path),
"info",
);
let client = reqwest::Client::new();
match client
.get(&src)
.timeout(std::time::Duration::from_secs(60))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
if let Ok(bytes) = resp.bytes().await {
if let Some(parent) = dest_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::write(&dest_path, bytes) {
emit_log(
handle,
log_id,
"File Error",
&format!("Failed to write to {:?}: {}", dest_path, e),
"error",
);
} else {
emit_log(
handle,
log_id,
"Success",
"File downloaded and saved successfully.",
"success",
);
}
}
}
Ok(resp) => emit_log(
handle,
log_id,
"Download Error",
&format!("HTTP Status: {}", resp.status()),
"error",
),
Err(e) => emit_log(handle, log_id, "Download Error", &e.to_string(), "error"),
}
} else {
let src_path = expand_win_path(&src);
emit_log(
handle,
log_id,
"File Copy",
&format!("{}Copying {:?} to {:?}...", step_prefix, src_path, dest_path),
"info",
);
if let Some(parent) = dest_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::copy(&src_path, &dest_path) {
emit_log(
handle,
log_id,
"File Error",
&format!("Failed to copy file: {}", e),
"error",
);
} else {
emit_log(handle, log_id, "Success", "File copied successfully.", "success");
}
}
}
PostInstallStep::FileDelete { path, .. } => {
let full_path = expand_win_path(&path);
emit_log(
handle,
log_id,
"File Delete",
&format!("{}Deleting {:?}...", step_prefix, full_path),
"info",
);
if full_path.exists() {
if let Err(e) = fs::remove_file(&full_path) {
emit_log(
handle,
log_id,
"File Error",
&format!("Failed to delete file: {}", e),
"error",
);
} else {
emit_log(handle, log_id, "Success", "File deleted successfully.", "success");
}
} else {
emit_log(handle, log_id, "File Info", "File does not exist, skipping.", "info");
}
}
PostInstallStep::Command { run, .. } => {
emit_log(
handle,
log_id,
"Command Execution",
&format!("{}Executing: {}", step_prefix, run),
"info",
);
let output = Command::new("cmd")
.arg("/C")
.raw_arg(&run)
.creation_flags(0x08000000)
.output();
match output {
Ok(out) => {
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
emit_log(handle, log_id, "Command Failed", &err, "error");
} else {
emit_log(handle, log_id, "Success", "Command executed successfully.", "success");
}
}
Err(e) => {
emit_log(handle, log_id, "Execution Error", &e.to_string(), "error");
}
}
}
}
if let Some(ms) = delay {
if ms > 0 {
emit_log(
handle,
log_id,
"Post-Install",
&format!("Waiting for {}ms...", ms),
"info",
);
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
}
}
}
Ok(())
}

View File

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

View File

@@ -6,7 +6,7 @@ use std::os::windows::process::CommandExt;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};
use crate::emit_log; use crate::services::log_service::emit_log;
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RegistryValue { pub struct RegistryValue {