diff --git a/src-tauri/src/commands/app_commands.rs b/src-tauri/src/commands/app_commands.rs index ea8215e..063fa87 100644 --- a/src-tauri/src/commands/app_commands.rs +++ b/src-tauri/src/commands/app_commands.rs @@ -1,6 +1,9 @@ use tauri::AppHandle; -use crate::domain::models::{AppSettings, EssentialsRepo, LogPayload, SyncEssentialsResult}; +use crate::domain::models::{ + AppSettings, DashboardSnapshot, EssentialsRepo, EssentialsStatusItem, LogPayload, SyncEssentialsResult, + UpdateCandidate, +}; use crate::services::{essentials_service, settings_service, software_state_service}; use crate::winget::Software; @@ -39,6 +42,21 @@ pub async fn get_updates(app: AppHandle) -> Vec { software_state_service::get_updates(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) { + software_state_service::get_essentials_status(app).await +} + +#[tauri::command] +pub async fn get_update_candidates(app: AppHandle) -> Vec { + software_state_service::get_update_candidates(app).await +} + #[tauri::command] pub async fn get_software_info(app: AppHandle, id: String) -> Option { software_state_service::get_software_info(app, id).await diff --git a/src-tauri/src/domain/models.rs b/src-tauri/src/domain/models.rs index 6198b79..c66c3a9 100644 --- a/src-tauri/src/domain/models.rs +++ b/src-tauri/src/domain/models.rs @@ -63,3 +63,44 @@ pub struct ResolvedPostInstall { pub software: Software, pub steps: Vec, } + +#[derive(Clone, Serialize, Deserialize)] +pub struct EssentialsStatusItem { + pub id: String, + pub name: String, + pub description: Option, + pub version: Option, + pub recommended_version: Option, + pub available_version: Option, + pub icon_url: Option, + pub use_manifest: bool, + pub manifest_url: Option, + pub post_install: Option>, + pub post_install_url: Option, + pub action_label: String, + pub target_version: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct UpdateCandidate { + pub id: String, + pub name: String, + pub description: Option, + pub version: Option, + pub available_version: Option, + pub icon_url: Option, + pub use_manifest: bool, + pub manifest_url: Option, + pub post_install: Option>, + pub post_install_url: Option, + pub action_label: String, + pub target_version: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct DashboardSnapshot { + pub essentials_version: String, + pub essentials: Vec, + pub updates: Vec, + pub installed_software: Vec, +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7928b9d..b67b876 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,6 +24,9 @@ pub fn run() { commands::app_commands::get_essentials, commands::app_commands::get_installed_software, commands::app_commands::get_updates, + commands::app_commands::get_dashboard_snapshot, + commands::app_commands::get_essentials_status, + commands::app_commands::get_update_candidates, commands::app_commands::get_software_icon, commands::app_commands::get_software_info, tasks::install_queue::install_software, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index be67adb..6683600 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod essentials_service; pub mod log_service; +pub mod reconcile_service; pub mod settings_service; pub mod software_state_service; diff --git a/src-tauri/src/services/reconcile_service.rs b/src-tauri/src/services/reconcile_service.rs new file mode 100644 index 0000000..a60044d --- /dev/null +++ b/src-tauri/src/services/reconcile_service.rs @@ -0,0 +1,158 @@ +use std::collections::HashMap; + +use crate::domain::models::{DashboardSnapshot, EssentialsRepo, EssentialsStatusItem, UpdateCandidate}; +use crate::winget::Software; + +pub fn build_dashboard_snapshot( + repo: Option, + installed_software: Vec, + updates: Vec, +) -> 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 { + 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(), + 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, +) -> Vec { + let definition_map: HashMap = definitions + .iter() + .map(|item| (item.id.to_ascii_lowercase(), item)) + .collect(); + + let mut result: Vec = 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()), + 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: definition.and_then(|item| item.post_install.clone()), + post_install_url: definition.and_then(|item| item.post_install_url.clone()), + 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 { + 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::().unwrap_or(0)) + .collect::>() + }; + + 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) +} diff --git a/src-tauri/src/services/software_state_service.rs b/src-tauri/src/services/software_state_service.rs index 9cda0dd..c6b8e61 100644 --- a/src-tauri/src/services/software_state_service.rs +++ b/src-tauri/src/services/software_state_service.rs @@ -1,6 +1,8 @@ use tauri::AppHandle; +use crate::domain::models::{DashboardSnapshot, EssentialsStatusItem, UpdateCandidate}; use crate::providers::winget_client; +use crate::services::{essentials_service, reconcile_service}; use crate::winget::Software; pub async fn initialize_app(app: AppHandle) -> Result { @@ -33,3 +35,28 @@ pub async fn get_software_icon(app: AppHandle, id: String, name: String) -> Opti .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) { + let snapshot = get_dashboard_snapshot(app).await; + (snapshot.essentials_version, snapshot.essentials) +} + +pub async fn get_update_candidates(app: AppHandle) -> Vec { + get_dashboard_snapshot(app).await.updates +} diff --git a/src/store/software.ts b/src/store/software.ts index 916fe51..3756c4b 100644 --- a/src/store/software.ts +++ b/src/store/software.ts @@ -2,25 +2,6 @@ import { defineStore } from 'pinia' import { invoke } from '@tauri-apps/api/core' import { listen } from '@tauri-apps/api/event' -const sortByName = (a: any, b: any) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' }); - -// 版本比对工具函数 -const compareVersions = (v1: string | null | undefined, v2: string | null | undefined) => { - if (!v1 || !v2) return 0; - if (v1 === v2) return 0; - const cleanV = (v: string) => v.replace(/^v/i, '').split(/[-+]/)[0].split('.'); - const p1 = cleanV(v1); - const p2 = cleanV(v2); - const len = Math.max(p1.length, p2.length); - for (let i = 0; i < len; i++) { - const n1 = parseInt(p1[i] || '0', 10); - const n2 = parseInt(p2[i] || '0', 10); - if (n1 < n2) return -1; - if (n1 > n2) return 1; - } - return 0; -}; - export interface LogEntry { id: string; // 日志唯一标识 timestamp: string; @@ -34,6 +15,18 @@ interface SyncEssentialsResult { message: string; } +interface DashboardSnapshot { + essentials_version: string; + essentials: any[]; + updates: any[]; + installed_software: any[]; +} + +interface EssentialsStatusResponse extends Array { + 0: string; + 1: any[]; +} + export const useSoftwareStore = defineStore('software', { state: () => ({ essentials: [] as any[], @@ -58,46 +51,14 @@ export const useSoftwareStore = defineStore('software', { getters: { mergedEssentials: (state) => { return state.essentials.map(item => { - const installedInfo = state.allSoftware.find(s => s.id.toLowerCase() === item.id.toLowerCase()); - const wingetUpdate = state.updates.find(s => s.id.toLowerCase() === item.id.toLowerCase()); - const task = state.activeTasks[item.id]; - const isInstalled = !!installedInfo; - const currentVersion = installedInfo?.version; - const recommendedVersion = item.version; // 清单里的推荐版本 - const availableVersion = wingetUpdate?.available_version; // Winget 查到的最新版 - - let displayStatus = task ? task.status : 'idle'; - let actionLabel = '安装'; - let targetVersion = recommendedVersion || availableVersion; - - if (isInstalled) { - const 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 enablePostInstall = state.postInstallPrefs[item.id] !== false; + const baseStatus = item.actionLabel === '已安装' ? 'installed' : 'idle'; return { ...item, - version: currentVersion, - recommended_version: recommendedVersion, - available_version: availableVersion, - status: displayStatus, + status: task ? task.status : baseStatus, progress: task ? task.progress : 0, - actionLabel, - targetVersion, enablePostInstall }; }); @@ -110,11 +71,9 @@ export const useSoftwareStore = defineStore('software', { ...item, status: task ? task.status : 'idle', progress: task ? task.progress : 0, - actionLabel: '更新', - targetVersion: item.available_version, enablePostInstall }; - }).sort(sortByName); + }).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { sensitivity: 'accent' })); }, isBusy: (state) => { return state.loading || Object.values(state.activeTasks).some(task => @@ -183,18 +142,18 @@ export const useSoftwareStore = defineStore('software', { }, async fetchEssentials() { - let repo = await invoke('get_essentials') as any; - if (!repo) { + let response = await invoke('get_essentials_status') as EssentialsStatusResponse; + if ((!response || !response[1]) && !(await invoke('get_essentials') as any)) { try { await invoke('sync_essentials'); - repo = await invoke('get_essentials') as any; + response = await invoke('get_essentials_status') as EssentialsStatusResponse; } catch (err) { console.error('Initial sync failed:', err); } } - if (repo) { - this.essentials = repo.essentials; - this.essentialsVersion = repo.version; + if (response && Array.isArray(response[1])) { + this.essentialsVersion = response[0] || ''; + this.essentials = response[1]; } else { this.essentials = []; this.essentialsVersion = ''; @@ -204,7 +163,7 @@ export const useSoftwareStore = defineStore('software', { if (this.isBusy) return; this.loading = true try { - const res = await invoke('get_updates') + const res = await invoke('get_update_candidates') this.updates = res as any[] await this.loadIconsForUpdates() if (this.selectedUpdateIds.length === 0) this.selectAll('update'); @@ -225,13 +184,11 @@ export const useSoftwareStore = defineStore('software', { 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[]; + const snapshot = await invoke('get_dashboard_snapshot') as DashboardSnapshot; + this.essentialsVersion = snapshot.essentials_version; + this.essentials = snapshot.essentials; + this.allSoftware = snapshot.installed_software; + this.updates = snapshot.updates; await this.loadIconsForUpdates() this.lastFetched = Date.now(); if (this.selectedEssentialIds.length === 0) this.selectAll('essential');