feat: redesign cli ux progress

This commit is contained in:
stoorps 2026-03-20 17:47:32 +00:00
parent ab60ee641f
commit c63b2917da
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
21 changed files with 994 additions and 99 deletions

View file

@ -1,9 +1,13 @@
use std::env;
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::adapters::traits::AdapterResolution;
use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity};
use crate::app::interaction::{InteractionKind, InteractionRequest};
use crate::app::progress::{
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
};
use crate::app::query::{ResolveQueryError, resolve_query};
use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default};
use crate::domain::app::{AppRecord, InstallMetadata, InstallScope};
@ -187,6 +191,27 @@ pub fn install_app(
install_home: &Path,
requested_scope: InstallScope,
) -> Result<InstalledApp, InstallAppError> {
let mut reporter = NoopReporter;
install_app_with_reporter(
source_input,
plan,
install_home,
requested_scope,
&mut reporter,
)
}
pub fn install_app_with_reporter(
source_input: &str,
plan: &AddPlan,
install_home: &Path,
requested_scope: InstallScope,
reporter: &mut impl ProgressReporter,
) -> Result<InstalledApp, InstallAppError> {
reporter.report(&OperationEvent::Started {
kind: OperationKind::Add,
label: source_input.to_owned(),
});
let mut record =
materialize_app_record(source_input, plan).map_err(InstallAppError::Materialize)?;
let (family, capabilities) =
@ -209,7 +234,12 @@ pub fn install_app(
install_home,
&policy.icon_root.join(format!("{}.png", record.stable_id)),
);
let artifact_bytes = download_artifact_bytes(&plan.selected_artifact.url)?;
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::DownloadArtifact,
message: "downloading artifact".to_owned(),
});
let artifact_bytes =
download_artifact_bytes_with_reporter(&plan.selected_artifact.url, reporter)?;
let payload_exec = payload_path.clone();
let desktop_owned = match policy.integration_mode {
IntegrationMode::PayloadOnly | IntegrationMode::Denied => None,
@ -219,6 +249,21 @@ pub fn install_app(
)),
};
if desktop_owned.is_some() {
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::WriteDesktopEntry,
message: "writing desktop entry".to_owned(),
});
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ExtractIcon,
message: "extracting icon".to_owned(),
});
}
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::StagePayload,
message: "staging payload".to_owned(),
});
let install_outcome = execute_install(&InstallRequest {
staging_root: &install_home.join(".local/share/aim/staging"),
final_payload_path: &payload_path,
@ -235,6 +280,18 @@ pub fn install_app(
})
.map_err(InstallAppError::Install)?;
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::RefreshIntegration,
message: "refreshing desktop integration".to_owned(),
});
if !install_outcome.warnings.is_empty() {
for warning in &install_outcome.warnings {
reporter.report(&OperationEvent::Warning {
message: warning.clone(),
});
}
}
record.install = Some(InstallMetadata {
scope: policy.scope,
payload_path: Some(install_outcome.final_payload_path.display().to_string()),
@ -248,7 +305,7 @@ pub fn install_app(
.map(|path| path.display().to_string()),
});
Ok(InstalledApp {
let installed = InstalledApp {
record,
selected_artifact: plan.selected_artifact.clone(),
source: plan.resolution.source.clone(),
@ -256,7 +313,13 @@ pub fn install_app(
integration_mode: policy.integration_mode,
install_outcome,
warnings: policy.warnings,
})
};
reporter.report(&OperationEvent::Finished {
summary: format!("installed {}", installed.record.stable_id),
});
Ok(installed)
}
#[derive(Debug, Eq, PartialEq)]
@ -287,21 +350,48 @@ pub enum InstallAppError {
Materialize(MaterializeAddRecordError),
Policy(String),
Download(reqwest::Error),
DownloadIo(std::io::Error),
HostProbe(std::io::Error),
Install(crate::integration::install::PayloadInstallError),
}
fn download_artifact_bytes(url: &str) -> Result<Vec<u8>, InstallAppError> {
fn download_artifact_bytes_with_reporter(
url: &str,
reporter: &mut impl ProgressReporter,
) -> Result<Vec<u8>, InstallAppError> {
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
return Ok(b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82".to_vec());
let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82".to_vec();
reporter.report(&OperationEvent::Progress {
current: bytes.len() as u64,
total: Some(bytes.len() as u64),
});
return Ok(bytes);
}
let response = reqwest::blocking::get(url).map_err(InstallAppError::Download)?;
let response = response
.error_for_status()
.map_err(InstallAppError::Download)?;
let bytes = response.bytes().map_err(InstallAppError::Download)?;
Ok(bytes.to_vec())
let total = response.content_length();
let mut response = response;
let mut bytes = Vec::new();
let mut buffer = [0_u8; 16 * 1024];
let mut current = 0_u64;
loop {
let read = response
.read(&mut buffer)
.map_err(InstallAppError::DownloadIo)?;
if read == 0 {
break;
}
bytes.extend_from_slice(&buffer[..read]);
current += read as u64;
reporter.report(&OperationEvent::Progress { current, total });
}
Ok(bytes)
}
fn render_desktop_entry(display_name: &str, exec_path: &Path) -> String {

View file

@ -2,6 +2,7 @@ pub mod add;
pub mod identity;
pub mod interaction;
pub mod list;
pub mod progress;
pub mod query;
pub mod remove;
pub mod scope;

View file

@ -0,0 +1,67 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OperationKind {
Add,
UpdateBatch,
UpdateItem,
Remove,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OperationStage {
ResolveQuery,
DiscoverRelease,
SelectArtifact,
DownloadArtifact,
StagePayload,
WriteDesktopEntry,
ExtractIcon,
RefreshIntegration,
SaveRegistry,
Finalize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum OperationEvent {
Started {
kind: OperationKind,
label: String,
},
StageChanged {
stage: OperationStage,
message: String,
},
Progress {
current: u64,
total: Option<u64>,
},
Warning {
message: String,
},
Finished {
summary: String,
},
Failed {
stage: OperationStage,
reason: String,
},
}
pub trait ProgressReporter {
fn report(&mut self, event: &OperationEvent);
}
impl<F> ProgressReporter for F
where
F: FnMut(&OperationEvent),
{
fn report(&mut self, event: &OperationEvent) {
self(event);
}
}
#[derive(Default)]
pub struct NoopReporter;
impl ProgressReporter for NoopReporter {
fn report(&mut self, _event: &OperationEvent) {}
}

View file

@ -3,6 +3,9 @@ use std::io;
use std::path::{Path, PathBuf};
use crate::app::interaction::{InteractionKind, InteractionRequest};
use crate::app::progress::{
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
};
use crate::domain::app::{AppRecord, InstallScope};
use crate::integration::paths::{desktop_entry_path, icon_path, managed_appimage_path};
use crate::integration::refresh::refresh_integration;
@ -66,8 +69,30 @@ pub fn remove_registered_app(
apps: &[AppRecord],
install_home: &Path,
) -> Result<RemovalResult, RemoveRegisteredAppError> {
let mut reporter = NoopReporter;
remove_registered_app_with_reporter(query, apps, install_home, &mut reporter)
}
pub fn remove_registered_app_with_reporter(
query: &str,
apps: &[AppRecord],
install_home: &Path,
reporter: &mut impl ProgressReporter,
) -> Result<RemovalResult, RemoveRegisteredAppError> {
reporter.report(&OperationEvent::Started {
kind: OperationKind::Remove,
label: query.to_owned(),
});
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
message: format!("resolving {query}"),
});
let app = resolve_registered_app(query, apps).map_err(RemoveRegisteredAppError::Resolve)?;
let plan = build_removal_plan(app, install_home);
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::StagePayload,
message: "removing managed artifacts".to_owned(),
});
let warnings = delete_artifacts(&plan)?;
let remaining_apps = apps
.iter()
@ -75,11 +100,21 @@ pub fn remove_registered_app(
.cloned()
.collect();
Ok(RemovalResult {
let result = RemovalResult {
removed: plan,
remaining_apps,
warnings,
})
};
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::Finalize,
message: format!("removed {}", result.removed.stable_id),
});
reporter.report(&OperationEvent::Finished {
summary: format!("removed {}", result.removed.stable_id),
});
Ok(result)
}
#[derive(Debug, Eq, PartialEq)]

View file

@ -1,6 +1,9 @@
use std::path::Path;
use crate::app::add::{build_add_plan, install_app};
use crate::app::add::{build_add_plan, install_app_with_reporter};
use crate::app::progress::{
NoopReporter, OperationEvent, OperationKind, OperationStage, ProgressReporter,
};
use crate::domain::app::{AppRecord, InstallScope};
use crate::domain::update::{
ChannelPreference, ExecutedUpdate, PlannedUpdate, UpdateChannelKind, UpdateExecutionResult,
@ -17,11 +20,28 @@ pub fn execute_updates(
apps: &[AppRecord],
install_home: &Path,
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
let mut reporter = NoopReporter;
execute_updates_with_reporter(apps, install_home, &mut reporter)
}
pub fn execute_updates_with_reporter(
apps: &[AppRecord],
install_home: &Path,
reporter: &mut impl ProgressReporter,
) -> Result<UpdateExecutionResult, ExecuteUpdatesError> {
reporter.report(&OperationEvent::Started {
kind: OperationKind::UpdateBatch,
label: format!("{} apps", apps.len()),
});
let mut updated_apps = Vec::with_capacity(apps.len());
let mut items = Vec::with_capacity(apps.len());
for app in apps {
match execute_update(app, install_home) {
reporter.report(&OperationEvent::Started {
kind: OperationKind::UpdateItem,
label: app.stable_id.clone(),
});
match execute_update(app, install_home, reporter) {
Ok(updated) => {
let warnings = updated
.warnings
@ -39,6 +59,9 @@ pub fn execute_updates(
status: UpdateExecutionStatus::Updated,
});
updated_apps.push(record);
reporter.report(&OperationEvent::Finished {
summary: format!("updated {}", app.stable_id),
});
}
Err(reason) => {
items.push(ExecutedUpdate {
@ -54,10 +77,20 @@ pub fn execute_updates(
}
}
Ok(UpdateExecutionResult {
let result = UpdateExecutionResult {
apps: updated_apps,
items,
})
};
reporter.report(&OperationEvent::Finished {
summary: format!(
"updated {}, failed {}",
result.updated_count(),
result.failed_count()
),
});
Ok(result)
}
#[derive(Debug, Eq, PartialEq)]
@ -107,18 +140,44 @@ fn plan_update(app: &AppRecord) -> PlannedUpdate {
fn execute_update(
app: &AppRecord,
install_home: &Path,
reporter: &mut impl ProgressReporter,
) -> Result<crate::app::add::InstalledApp, String> {
let query = update_query(app).ok_or_else(|| "missing install source".to_owned())?;
reporter.report(&OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
message: format!("resolving {}", app.stable_id),
});
let query = update_query(app).ok_or_else(|| {
let reason = "missing install source".to_owned();
reporter.report(&OperationEvent::Failed {
stage: OperationStage::ResolveQuery,
reason: reason.clone(),
});
reason
})?;
let requested_scope = app
.install
.as_ref()
.map(|install| install.scope)
.unwrap_or(InstallScope::User);
let plan = build_add_plan(&query)
.map_err(|error| format!("failed to build update plan: {error:?}"))?;
let plan = build_add_plan(&query).map_err(|error| {
let reason = format!("failed to build update plan: {error:?}");
reporter.report(&OperationEvent::Failed {
stage: OperationStage::ResolveQuery,
reason: reason.clone(),
});
reason
})?;
install_app(&query, &plan, install_home, requested_scope)
.map_err(|error| format!("failed to install update: {error:?}"))
install_app_with_reporter(&query, &plan, install_home, requested_scope, reporter).map_err(
|error| {
let reason = format!("failed to install update: {error:?}");
reporter.report(&OperationEvent::Failed {
stage: OperationStage::Finalize,
reason: reason.clone(),
});
reason
},
)
}
fn update_query(app: &AppRecord) -> Option<String> {

View file

@ -1,5 +1,9 @@
use aim_core::app::add::{build_add_plan_with, install_app_with_reporter};
use aim_core::app::progress::{OperationEvent, OperationStage};
use aim_core::domain::app::InstallScope;
use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install};
use aim_core::platform::DesktopHelpers;
use aim_core::source::github::FixtureGitHubTransport;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
@ -125,3 +129,57 @@ fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
.starts_with(b"\x89PNG\r\n\x1a\n")
);
}
#[test]
fn install_app_reports_operation_stages_in_order() {
let root = tempdir().unwrap();
let plan = build_add_plan_with("sharkdp/bat", &FixtureGitHubTransport).unwrap();
let mut events: Vec<OperationEvent> = Vec::new();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
}
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let installed = install_app_with_reporter(
"sharkdp/bat",
&plan,
root.path(),
InstallScope::User,
&mut reporter,
)
.unwrap();
assert_eq!(installed.record.stable_id, "sharkdp-bat");
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::DownloadArtifact,
message: "downloading artifact".to_owned(),
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::StagePayload,
message: "staging payload".to_owned(),
}));
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::Progress {
current,
total: Some(total)
} if *current == *total
)
}));
assert!(events.contains(&OperationEvent::StageChanged {
stage: OperationStage::WriteDesktopEntry,
message: "writing desktop entry".to_owned(),
}));
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::StageChanged {
stage: OperationStage::RefreshIntegration,
..
}
)
}));
}

View file

@ -1,8 +1,12 @@
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
use aim_core::app::list::build_list_rows;
use aim_core::app::remove::{build_removal_plan, resolve_registered_app};
use aim_core::app::progress::{OperationEvent, OperationStage};
use aim_core::app::remove::{
build_removal_plan, remove_registered_app_with_reporter, resolve_registered_app,
};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use std::path::Path;
use tempfile::tempdir;
#[test]
fn remove_flow_rejects_unknown_app_names() {
@ -125,3 +129,55 @@ fn removal_plan_falls_back_to_derived_managed_user_paths() {
]
);
}
#[test]
fn remove_flow_reports_resolution_and_cleanup_events() {
let install_home = tempdir().unwrap();
let app = AppRecord {
stable_id: "bat".to_owned(),
display_name: "Bat".to_owned(),
source_input: None,
source: None,
installed_version: None,
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: Some(
install_home
.path()
.join(".local/lib/aim/appimages/bat.AppImage")
.display()
.to_string(),
),
desktop_entry_path: None,
icon_path: None,
}),
};
let mut events: Vec<OperationEvent> = Vec::new();
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let result =
remove_registered_app_with_reporter("bat", &[app], install_home.path(), &mut reporter)
.unwrap();
assert_eq!(result.removed.stable_id, "bat");
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
..
}
)
}));
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::StageChanged {
stage: OperationStage::Finalize,
..
}
)
}));
}

View file

@ -1,4 +1,5 @@
use aim_core::app::update::{build_update_plan, execute_updates};
use aim_core::app::progress::{OperationEvent, OperationStage};
use aim_core::app::update::{build_update_plan, execute_updates, execute_updates_with_reporter};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
use tempfile::tempdir;
@ -88,3 +89,52 @@ fn failed_update_keeps_previous_app_record() {
assert_eq!(result.updated_count(), 0);
assert_eq!(result.failed_count(), 1);
}
#[test]
fn update_execution_reports_per_app_lifecycle_events() {
let install_home = tempdir().unwrap();
let app = AppRecord {
stable_id: "legacy-bat".to_owned(),
display_name: "Legacy Bat".to_owned(),
source_input: None,
source: None,
installed_version: Some("0.9.0".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
};
let mut events: Vec<OperationEvent> = Vec::new();
let mut reporter = |event: &OperationEvent| events.push(event.clone());
let result = execute_updates_with_reporter(
std::slice::from_ref(&app),
install_home.path(),
&mut reporter,
)
.unwrap();
assert_eq!(result.failed_count(), 1);
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::StageChanged {
stage: OperationStage::ResolveQuery,
..
}
)
}));
assert!(events.iter().any(|event| {
matches!(
event,
OperationEvent::Failed {
stage: OperationStage::ResolveQuery,
..
}
)
}));
}