feat: implement uninstall functionality for managed artifacts and persist install metadata
This commit is contained in:
parent
38f900ad50
commit
842c390260
11 changed files with 626 additions and 21 deletions
|
|
@ -6,7 +6,7 @@ use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_ident
|
|||
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
||||
use crate::app::query::{ResolveQueryError, resolve_query};
|
||||
use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default};
|
||||
use crate::domain::app::{AppRecord, InstallScope};
|
||||
use crate::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind};
|
||||
use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy};
|
||||
use crate::integration::install::{InstallOutcome, InstallRequest, execute_install};
|
||||
|
|
@ -177,6 +177,7 @@ pub fn materialize_app_record(
|
|||
installed_version: Some(plan.selected_artifact.version.clone()),
|
||||
update_strategy: Some(plan.update_strategy.clone()),
|
||||
metadata: plan.metadata.clone(),
|
||||
install: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +187,7 @@ pub fn install_app(
|
|||
install_home: &Path,
|
||||
requested_scope: InstallScope,
|
||||
) -> Result<InstalledApp, InstallAppError> {
|
||||
let record =
|
||||
let mut record =
|
||||
materialize_app_record(source_input, plan).map_err(InstallAppError::Materialize)?;
|
||||
let (family, capabilities) =
|
||||
probe_live_host(install_home, requested_scope).map_err(InstallAppError::HostProbe)?;
|
||||
|
|
@ -234,6 +235,19 @@ pub fn install_app(
|
|||
})
|
||||
.map_err(InstallAppError::Install)?;
|
||||
|
||||
record.install = Some(InstallMetadata {
|
||||
scope: policy.scope,
|
||||
payload_path: Some(install_outcome.final_payload_path.display().to_string()),
|
||||
desktop_entry_path: install_outcome
|
||||
.desktop_entry_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string()),
|
||||
icon_path: install_outcome
|
||||
.icon_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string()),
|
||||
});
|
||||
|
||||
Ok(InstalledApp {
|
||||
record,
|
||||
selected_artifact: plan.selected_artifact.clone(),
|
||||
|
|
@ -279,7 +293,7 @@ pub enum InstallAppError {
|
|||
|
||||
fn download_artifact_bytes(url: &str) -> Result<Vec<u8>, InstallAppError> {
|
||||
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
|
||||
return Ok(b"\x7fELFAppImage".to_vec());
|
||||
return Ok(b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82".to_vec());
|
||||
}
|
||||
|
||||
let response = reqwest::blocking::get(url).map_err(InstallAppError::Download)?;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
||||
use crate::domain::app::AppRecord;
|
||||
use crate::domain::app::{AppRecord, InstallScope};
|
||||
use crate::integration::paths::{desktop_entry_path, icon_path, managed_appimage_path};
|
||||
use crate::integration::refresh::refresh_integration;
|
||||
use crate::platform::probe_live_host;
|
||||
|
||||
pub fn resolve_registered_app<'a>(
|
||||
query: &str,
|
||||
|
|
@ -41,19 +48,27 @@ pub struct RemovalPlan {
|
|||
pub artifact_paths: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn build_removal_plan(app: &AppRecord) -> RemovalPlan {
|
||||
pub fn build_removal_plan(app: &AppRecord, install_home: &Path) -> RemovalPlan {
|
||||
let artifact_paths = removal_artifact_paths(app, install_home)
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect();
|
||||
|
||||
RemovalPlan {
|
||||
stable_id: app.stable_id.clone(),
|
||||
display_name: app.display_name.clone(),
|
||||
artifact_paths: Vec::new(),
|
||||
artifact_paths,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_registered_app(
|
||||
query: &str,
|
||||
apps: &[AppRecord],
|
||||
) -> Result<RemovalResult, ResolveRegisteredAppError> {
|
||||
let app = resolve_registered_app(query, apps)?;
|
||||
install_home: &Path,
|
||||
) -> Result<RemovalResult, RemoveRegisteredAppError> {
|
||||
let app = resolve_registered_app(query, apps).map_err(RemoveRegisteredAppError::Resolve)?;
|
||||
let plan = build_removal_plan(app, install_home);
|
||||
let warnings = delete_artifacts(&plan)?;
|
||||
let remaining_apps = apps
|
||||
.iter()
|
||||
.filter(|candidate| candidate.stable_id != app.stable_id)
|
||||
|
|
@ -61,8 +76,9 @@ pub fn remove_registered_app(
|
|||
.collect();
|
||||
|
||||
Ok(RemovalResult {
|
||||
removed: build_removal_plan(app),
|
||||
removed: plan,
|
||||
remaining_apps,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +86,13 @@ pub fn remove_registered_app(
|
|||
pub struct RemovalResult {
|
||||
pub removed: RemovalPlan,
|
||||
pub remaining_apps: Vec<AppRecord>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RemoveRegisteredAppError {
|
||||
Resolve(ResolveRegisteredAppError),
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
|
|
@ -81,3 +104,48 @@ pub enum ResolveRegisteredAppError {
|
|||
fn normalize_lookup(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn removal_artifact_paths(app: &AppRecord, install_home: &Path) -> Vec<PathBuf> {
|
||||
if let Some(install) = &app.install {
|
||||
return [
|
||||
install.payload_path.as_deref(),
|
||||
install.desktop_entry_path.as_deref(),
|
||||
install.icon_path.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
}
|
||||
|
||||
let scope = InstallScope::User;
|
||||
vec![
|
||||
managed_appimage_path(install_home, scope, &app.stable_id),
|
||||
desktop_entry_path(install_home, scope, &app.stable_id),
|
||||
icon_path(install_home, scope, &app.stable_id),
|
||||
]
|
||||
}
|
||||
|
||||
fn delete_artifacts(plan: &RemovalPlan) -> Result<Vec<String>, RemoveRegisteredAppError> {
|
||||
let desktop_path = plan.artifact_paths.get(1).map(PathBuf::from);
|
||||
let icon_path = plan.artifact_paths.get(2).map(PathBuf::from);
|
||||
|
||||
for artifact_path in &plan.artifact_paths {
|
||||
match fs::remove_file(artifact_path) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(RemoveRegisteredAppError::Io(error)),
|
||||
}
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if let Ok((_, capabilities)) = probe_live_host(Path::new("/"), InstallScope::User) {
|
||||
warnings.extend(refresh_integration(
|
||||
&capabilities.helpers,
|
||||
desktop_path.as_deref(),
|
||||
icon_path.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(warnings)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use crate::domain::source::SourceRef;
|
||||
use crate::domain::update::{ParsedMetadata, UpdateStrategy};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub enum InstallScope {
|
||||
User,
|
||||
System,
|
||||
|
|
@ -21,6 +21,17 @@ pub struct AppIdentity {
|
|||
pub confidence: IdentityConfidence,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct InstallMetadata {
|
||||
pub scope: InstallScope,
|
||||
#[serde(default)]
|
||||
pub payload_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub desktop_entry_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub icon_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
|
||||
pub struct AppRecord {
|
||||
pub stable_id: String,
|
||||
|
|
@ -35,4 +46,6 @@ pub struct AppRecord {
|
|||
pub update_strategy: Option<UpdateStrategy>,
|
||||
#[serde(default)]
|
||||
pub metadata: Vec<ParsedMetadata>,
|
||||
#[serde(default)]
|
||||
pub install: Option<InstallMetadata>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ fn registry_round_trips_update_strategy_and_alternates() {
|
|||
],
|
||||
}),
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}],
|
||||
};
|
||||
|
||||
|
|
@ -51,3 +52,52 @@ fn registry_round_trips_update_strategy_and_alternates() {
|
|||
assert_eq!(strategy.preferred.reason, "install-origin-match");
|
||||
assert_eq!(strategy.alternates.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_round_trips_install_metadata() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||
let registry = aim_core::registry::model::Registry {
|
||||
version: 1,
|
||||
apps: vec![aim_core::domain::app::AppRecord {
|
||||
stable_id: "t3code".to_owned(),
|
||||
display_name: "T3 Code".to_owned(),
|
||||
source_input: Some("pingdotgg/t3code".to_owned()),
|
||||
source: None,
|
||||
installed_version: Some("0.0.11".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: Some(aim_core::domain::app::InstallMetadata {
|
||||
scope: aim_core::domain::app::InstallScope::User,
|
||||
payload_path: Some(
|
||||
"/tmp/install-home/.local/lib/aim/appimages/t3code.AppImage".to_owned(),
|
||||
),
|
||||
desktop_entry_path: Some(
|
||||
"/tmp/install-home/.local/share/applications/aim-t3code.desktop".to_owned(),
|
||||
),
|
||||
icon_path: Some(
|
||||
"/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png"
|
||||
.to_owned(),
|
||||
),
|
||||
}),
|
||||
}],
|
||||
};
|
||||
|
||||
store.save(®istry).unwrap();
|
||||
let loaded = store.load().unwrap();
|
||||
|
||||
let install = loaded.apps[0].install.as_ref().unwrap();
|
||||
assert_eq!(install.scope, aim_core::domain::app::InstallScope::User);
|
||||
assert_eq!(
|
||||
install.payload_path.as_deref(),
|
||||
Some("/tmp/install-home/.local/lib/aim/appimages/t3code.AppImage")
|
||||
);
|
||||
assert_eq!(
|
||||
install.desktop_entry_path.as_deref(),
|
||||
Some("/tmp/install-home/.local/share/applications/aim-t3code.desktop")
|
||||
);
|
||||
assert_eq!(
|
||||
install.icon_path.as_deref(),
|
||||
Some("/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
|
||||
use aim_core::app::list::build_list_rows;
|
||||
use aim_core::app::remove::resolve_registered_app;
|
||||
use aim_core::domain::app::AppRecord;
|
||||
use aim_core::app::remove::{build_removal_plan, resolve_registered_app};
|
||||
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn remove_flow_rejects_unknown_app_names() {
|
||||
|
|
@ -20,6 +21,7 @@ fn list_flow_returns_display_rows_for_registered_apps() {
|
|||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}]);
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
|
|
@ -38,6 +40,7 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
|
|||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
AppRecord {
|
||||
stable_id: "bat-nightly".to_owned(),
|
||||
|
|
@ -47,6 +50,7 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
|
|||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -65,3 +69,59 @@ fn ambiguous_remove_matches_include_stable_ids_for_client_choice() {
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_plan_prefers_persisted_install_metadata_paths() {
|
||||
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::System,
|
||||
payload_path: Some("/opt/aim/appimages/bat.AppImage".to_owned()),
|
||||
desktop_entry_path: Some("/usr/share/applications/aim-bat.desktop".to_owned()),
|
||||
icon_path: Some("/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned()),
|
||||
}),
|
||||
};
|
||||
|
||||
let plan = build_removal_plan(&app, Path::new("/home/test"));
|
||||
|
||||
assert_eq!(plan.stable_id, "bat");
|
||||
assert_eq!(
|
||||
plan.artifact_paths,
|
||||
vec![
|
||||
"/opt/aim/appimages/bat.AppImage".to_owned(),
|
||||
"/usr/share/applications/aim-bat.desktop".to_owned(),
|
||||
"/usr/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_plan_falls_back_to_derived_managed_user_paths() {
|
||||
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: None,
|
||||
};
|
||||
|
||||
let plan = build_removal_plan(&app, Path::new("/home/test"));
|
||||
|
||||
assert_eq!(
|
||||
plan.artifact_paths,
|
||||
vec![
|
||||
"/home/test/.local/lib/aim/appimages/bat.AppImage".to_owned(),
|
||||
"/home/test/.local/share/applications/aim-bat.desktop".to_owned(),
|
||||
"/home/test/.local/share/icons/hicolor/256x256/apps/bat.png".to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ fn installed_apps_are_carried_into_review_plan() {
|
|||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}];
|
||||
|
||||
let plan = build_update_plan(&apps).unwrap();
|
||||
|
|
@ -49,6 +50,7 @@ fn update_plan_uses_alternate_channel_after_preferred_failure() {
|
|||
}],
|
||||
}),
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}];
|
||||
|
||||
let plan = build_update_plan(&apps).unwrap();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue