diff --git a/src-tauri/src/domain/models.rs b/src-tauri/src/domain/models.rs index c66c3a9..00b3ab1 100644 --- a/src-tauri/src/domain/models.rs +++ b/src-tauri/src/domain/models.rs @@ -64,6 +64,18 @@ pub struct ResolvedPostInstall { pub steps: Vec, } +#[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, + pub message: Option, +} + #[derive(Clone, Serialize, Deserialize)] pub struct EssentialsStatusItem { pub id: String, diff --git a/src-tauri/src/tasks/install_queue.rs b/src-tauri/src/tasks/install_queue.rs index b14a458..6f97ab5 100644 --- a/src-tauri/src/tasks/install_queue.rs +++ b/src-tauri/src/tasks/install_queue.rs @@ -11,17 +11,19 @@ use tokio::sync::mpsc; use winreg::enums::*; use winreg::RegKey; -use crate::domain::models::{InstallProgress, InstallTask}; +use crate::domain::models::{InstallProgress, InstallTask, TaskEventPayload}; use crate::services::essentials_service; use crate::services::log_service::emit_log; use crate::winget::PostInstallStep; pub struct AppState { pub install_tx: mpsc::Sender, + pub app_handle: AppHandle, } pub fn create_install_state(handle: AppHandle) -> AppState { let (tx, mut rx) = mpsc::channel::(100); + let runtime_handle = handle.clone(); tauri::async_runtime::spawn(async move { let perc_re = Regex::new(r"(\d+)\s*%").unwrap(); @@ -35,14 +37,16 @@ pub fn create_install_state(handle: AppHandle) -> AppState { 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, - }, + emit_task_event( + &runtime_handle, + &log_id, + &task_id, + "install", + "running", + "installing", + 0.0, + task_version.clone(), + None, ); let mut args = vec!["install".to_string()]; @@ -52,8 +56,19 @@ pub fn create_install_state(handle: AppHandle) -> AppState { 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()), + ); emit_log( - &handle, + &runtime_handle, &log_id, &display_cmd, "Downloading remote manifest...", @@ -85,19 +100,22 @@ pub fn create_install_state(handle: AppHandle) -> AppState { if temp_manifest_path.is_none() { emit_log( - &handle, + &runtime_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, - }, + 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()), ); continue; } @@ -128,12 +146,23 @@ pub fn create_install_state(handle: AppHandle) -> AppState { let full_command = format!("winget {}", args.join(" ")); emit_log( - &handle, + &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, + ); let child = Command::new("winget") .args(&args) @@ -147,7 +176,7 @@ pub fn create_install_state(handle: AppHandle) -> AppState { let stdout_handle = child_proc.stdout.take().map(|stdout| { spawn_install_stream_reader( stdout, - handle.clone(), + runtime_handle.clone(), log_id.clone(), task_id.clone(), "stdout", @@ -158,7 +187,7 @@ pub fn create_install_state(handle: AppHandle) -> AppState { let stderr_handle = child_proc.stderr.take().map(|stderr| { spawn_install_stream_reader( stderr, - handle.clone(), + runtime_handle.clone(), log_id.clone(), task_id.clone(), "stderr", @@ -177,7 +206,7 @@ pub fn create_install_state(handle: AppHandle) -> AppState { 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) + 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 { @@ -188,7 +217,7 @@ pub fn create_install_state(handle: AppHandle) -> AppState { } } else if let Some(url) = sw.post_install_url { emit_log( - &handle, + &runtime_handle, &log_id, "Post-Install", "Local config not found, fetching remote config...", @@ -206,7 +235,7 @@ pub fn create_install_state(handle: AppHandle) -> AppState { match serde_json::from_str::>(&text) { Ok(steps) => { emit_log( - &handle, + &runtime_handle, &log_id, "Post-Install", &format!( @@ -219,7 +248,7 @@ pub fn create_install_state(handle: AppHandle) -> AppState { } Err(e) => { emit_log( - &handle, + &runtime_handle, &log_id, "Post-Install Error", &format!("JSON Parse Error: {}. Raw Content: {}", e, text), @@ -230,7 +259,7 @@ pub fn create_install_state(handle: AppHandle) -> AppState { } } else { emit_log( - &handle, + &runtime_handle, &log_id, "Post-Install Error", &format!("Remote config HTTP Error: {}", resp.status()), @@ -241,26 +270,29 @@ pub fn create_install_state(handle: AppHandle) -> AppState { } if let Some(steps) = final_steps { - let _ = handle.emit( - "install-status", - InstallProgress { - id: task_id.clone(), - status: "configuring".to_string(), - progress: 1.0, - }, + emit_task_event( + &runtime_handle, + &log_id, + &task_id, + "install", + "running", + "configuring", + 1.0, + task_version.clone(), + Some("Starting post-installation configuration".to_string()), ); emit_log( - &handle, + &runtime_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"); + 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( - &handle, + &runtime_handle, &log_id, "Post-Install", "Post-installation configuration completed.", @@ -273,21 +305,35 @@ pub fn create_install_state(handle: AppHandle) -> AppState { status_result } Err(e) => { - emit_log(&handle, &log_id, "Fatal Error", &e.to_string(), "error"); + 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()), + ); "error" } }; - let _ = handle.emit( - "install-status", - InstallProgress { - id: task_id.clone(), - status: status_result.to_string(), - progress: 1.0, - }, + 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)), ); emit_log( - &handle, + &runtime_handle, &log_id, "Result", &format!("Execution finished: {}", status_result), @@ -304,7 +350,7 @@ pub fn create_install_state(handle: AppHandle) -> AppState { } }); - AppState { install_tx: tx } + AppState { install_tx: tx, app_handle: handle } } #[tauri::command] @@ -312,6 +358,18 @@ 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, + ); state.install_tx.send(task).await.map_err(|e| e.to_string()) } @@ -346,6 +404,19 @@ fn spawn_install_stream_reader( progress: p_val / 100.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: p_val / 100.0, + target_version: None, + message: None, + }, + ); is_progress = true; } } else if let Some(caps) = size_re.captures(clean_line) { @@ -360,6 +431,19 @@ fn spawn_install_stream_reader( progress: (current / total).min(1.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, + }, + ); is_progress = true; } } @@ -375,6 +459,48 @@ fn spawn_install_stream_reader( }) } +fn emit_task_event( + handle: &AppHandle, + task_id: &str, + software_id: &str, + task_type: &str, + status: &str, + stage: &str, + progress: f32, + target_version: Option, + message: Option, +) { + 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: target_version.clone(), + message, + }, + ); + + let legacy_status = match status { + "queued" => "pending".to_string(), + "completed" => "success".to_string(), + "failed" => "error".to_string(), + _ => stage.to_string(), + }; + + let _ = handle.emit( + "install-status", + InstallProgress { + id: software_id.to_string(), + status: legacy_status, + progress, + }, + ); +} + fn expand_win_path(path: &str) -> PathBuf { let mut expanded = path.to_string(); let env_vars = [ diff --git a/src/store/taskRuntime.ts b/src/store/taskRuntime.ts index 0dbb37c..b8e17e9 100644 --- a/src/store/taskRuntime.ts +++ b/src/store/taskRuntime.ts @@ -3,11 +3,11 @@ import { invoke } from '@tauri-apps/api/core' import { listen } from '@tauri-apps/api/event' import { useCatalogStore } from './catalog' -import type { ActiveTaskState, LogEntry } from './types' +import type { ActiveTaskState, LogEntry, TaskEventPayload, TaskRecord } from './types' export const useTaskRuntimeStore = defineStore('task-runtime', { state: () => ({ - activeTasks: {} as Record, + taskRecords: {} as Record, selectedEssentialIds: [] as string[], selectedUpdateIds: [] as string[], logs: [] as LogEntry[], @@ -16,9 +16,21 @@ export const useTaskRuntimeStore = defineStore('task-runtime', { postInstallPrefs: {} as Record }), getters: { - isTaskBusy: (state) => Object.values(state.activeTasks).some(task => - task.status === 'pending' || task.status === 'installing' - ) + activeTasks: (state): Record => { + return Object.values(state.taskRecords).reduce>((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' | 'update') { @@ -48,7 +60,6 @@ export const useTaskRuntimeStore = defineStore('task-runtime', { if (!software) return const enablePostInstall = this.postInstallPrefs[id] !== false - this.activeTasks[id] = { status: 'pending', progress: 0, targetVersion } try { await invoke('install_software', { task: { @@ -61,7 +72,16 @@ export const useTaskRuntimeStore = defineStore('task-runtime', { }) } catch (err) { console.error('Invoke install failed:', err) - this.activeTasks[id] = { status: 'error', progress: 0 } + this.taskRecords[`install-${id}`] = { + taskId: `install-${id}`, + softwareId: id, + taskType: 'install', + status: 'failed', + stage: 'invoke_error', + progress: 0, + targetVersion, + message: String(err) + } } }, @@ -71,10 +91,10 @@ export const useTaskRuntimeStore = defineStore('task-runtime', { this.refreshTimer = setTimeout(async () => { await catalog.fetchAllData() - Object.keys(this.activeTasks).forEach(id => { - const status = this.activeTasks[id].status - if (status === 'success' || status === 'error') { - delete this.activeTasks[id] + Object.keys(this.taskRecords).forEach(taskId => { + const status = this.taskRecords[taskId].status + if (status === 'completed' || status === 'failed') { + delete this.taskRecords[taskId] } }) this.refreshTimer = null @@ -85,19 +105,28 @@ export const useTaskRuntimeStore = defineStore('task-runtime', { if ((window as Window & { __tauri_listener_init?: boolean }).__tauri_listener_init) return ;(window as Window & { __tauri_listener_init?: boolean }).__tauri_listener_init = true - listen('install-status', async (event: { payload: { id: string, status: string, progress: number } }) => { + listen('task-event', async (event: { payload: TaskEventPayload }) => { const catalog = useCatalogStore() - const { id, status, progress } = event.payload - const task = this.activeTasks[id] + 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.activeTasks[id] = { status, progress, targetVersion: task?.targetVersion } + this.taskRecords[payload.task_id] = taskRecord - if (status === 'success' || status === 'error') { - if (status === 'success') { + if (payload.status === 'completed' || payload.status === 'failed') { + if (payload.status === 'completed') { try { - const latestInfo = await invoke('get_software_info', { id }) as Record | null + const latestInfo = await invoke('get_software_info', { id: payload.software_id }) as Record | null if (latestInfo) { - const index = catalog.allSoftware.findIndex(s => s.id.toLowerCase() === id.toLowerCase()) + const index = catalog.allSoftware.findIndex(s => s.id.toLowerCase() === payload.software_id.toLowerCase()) if (index !== -1) { catalog.allSoftware[index] = { ...catalog.allSoftware[index], ...latestInfo } } else { @@ -108,17 +137,17 @@ export const useTaskRuntimeStore = defineStore('task-runtime', { console.error('Partial refresh failed:', err) } - this.selectedEssentialIds = this.selectedEssentialIds.filter(item => item !== id) - this.selectedUpdateIds = this.selectedUpdateIds.filter(item => item !== id) + this.selectedEssentialIds = this.selectedEssentialIds.filter(item => item !== payload.software_id) + this.selectedUpdateIds = this.selectedUpdateIds.filter(item => item !== payload.software_id) setTimeout(() => { - if (this.activeTasks[id]?.status === 'success') { - delete this.activeTasks[id] + if (this.taskRecords[payload.task_id]?.status === 'completed') { + delete this.taskRecords[payload.task_id] } }, 3000) } - const index = this.batchQueue.indexOf(id) + const index = this.batchQueue.indexOf(payload.software_id) if (index !== -1) { this.batchQueue.splice(index, 1) if (this.batchQueue.length === 0) { @@ -128,6 +157,11 @@ export const useTaskRuntimeStore = defineStore('task-runtime', { } }) + listen('install-status', () => { + // Compatibility event is still emitted by the backend, but task-runtime + // now derives runtime state from the richer task-event stream. + }) + listen('log-event', (event: { payload: LogEntry }) => { const payload = event.payload const existingLog = this.logs.find(item => item.id === payload.id) @@ -142,3 +176,11 @@ export const useTaskRuntimeStore = defineStore('task-runtime', { } } }) + +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' +} diff --git a/src/store/types.ts b/src/store/types.ts index c09ce2f..032ee4f 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -47,6 +47,28 @@ export interface ActiveTaskState { 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 +} + export interface AppSettings { repo_url: string }