diff --git a/Cargo.lock b/Cargo.lock index 359a29f..7c88642 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,7 @@ dependencies = [ "assert_cmd", "clap", "dialoguer", + "libc", "predicates", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index 9e90b16..4f33171 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ version = "0.1.0" clap = { version = "4.5.32", features = ["derive"] } assert_cmd = "2.0.16" dialoguer = "0.12.0" +libc = "0.2.171" reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1.0.219", features = ["derive"] } serde_yaml = "0.9.34" diff --git a/README.md b/README.md index 3e17c7c..5f792ab 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ By default `aim` auto-detects whether to use user or system scope. Override that ## Current Flow Shape -- `aim ` registers unambiguous apps into the registry and renders review prompts when tracking needs confirmation +- `aim ` installs unambiguous apps, persists them into the registry after successful install, and renders review prompts when tracking needs confirmation - bare `aim` and `aim update` build a review-first update plan - `aim list` renders registered applications - `aim remove ` resolves a registered application name before removal diff --git a/crates/aim-cli/Cargo.toml b/crates/aim-cli/Cargo.toml index 093fcbb..87dc569 100644 --- a/crates/aim-cli/Cargo.toml +++ b/crates/aim-cli/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] clap.workspace = true dialoguer.workspace = true +libc.workspace = true aim-core = { path = "../aim-core" } [dev-dependencies] diff --git a/crates/aim-cli/src/lib.rs b/crates/aim-cli/src/lib.rs index 0fa6b02..fbb0870 100644 --- a/crates/aim-cli/src/lib.rs +++ b/crates/aim-cli/src/lib.rs @@ -2,15 +2,16 @@ pub mod cli; pub mod ui; use std::env; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use aim_core::app::add::{AddPlan, build_add_plan, materialize_app_record}; +use aim_core::app::add::{ + AddPlan, InstalledApp, build_add_plan, install_app, resolve_requested_scope, +}; use aim_core::app::list::{ListRow, build_list_rows}; use aim_core::app::remove::remove_registered_app; use aim_core::app::update::build_update_plan; use aim_core::domain::app::AppRecord; -use aim_core::domain::source::SourceRef; -use aim_core::domain::update::{ArtifactCandidate, UpdatePlan}; +use aim_core::domain::update::UpdatePlan; use aim_core::registry::model::Registry; use aim_core::registry::store::RegistryStore; @@ -22,6 +23,7 @@ pub fn parse() -> Cli { pub fn dispatch(cli: Cli) -> Result { let registry_path = registry_path(); + let install_home = install_home(®istry_path); let store = RegistryStore::new(registry_path); let registry = store.load()?; let apps = registry.apps.clone(); @@ -46,29 +48,26 @@ pub fn dispatch(cli: Cli) -> Result { } if let Some(query) = cli.query { + let requested_scope = resolve_requested_scope(cli.system, cli.user, is_effective_root()); let mut plan = build_add_plan(&query)?; if !plan.interactions.is_empty() { match ui::prompt::resolve_add_plan_interactions(plan.clone())? { Some(resolved) => { plan = resolved; } - None => return Ok(DispatchResult::PendingAdd(plan)), + None => return Ok(DispatchResult::PendingAdd(Box::new(plan))), } } - let record = materialize_app_record(&query, &plan)?; + let installed = install_app(&query, &plan, &install_home, requested_scope)?; let mut updated_apps = registry.apps.clone(); - upsert_app_record(&mut updated_apps, record.clone()); + upsert_app_record(&mut updated_apps, installed.record.clone()); store.save(&Registry { version: registry.version, apps: updated_apps, })?; - return Ok(DispatchResult::Added(AddedApp { - record, - selected_artifact: plan.selected_artifact, - source: plan.resolution.source, - })); + return Ok(DispatchResult::Added(Box::new(installed))); } Ok(DispatchResult::Noop) @@ -89,25 +88,18 @@ fn registry_path() -> PathBuf { #[derive(Debug, Eq, PartialEq)] pub enum DispatchResult { - Added(AddedApp), + Added(Box), List(Vec), - PendingAdd(AddPlan), + PendingAdd(Box), Removed(String), UpdatePlan(UpdatePlan), Noop, } -#[derive(Debug, Eq, PartialEq)] -pub struct AddedApp { - pub record: AppRecord, - pub selected_artifact: ArtifactCandidate, - pub source: SourceRef, -} - #[derive(Debug)] pub enum DispatchError { AddPlan(aim_core::app::add::BuildAddPlanError), - AddRecord(aim_core::app::add::MaterializeAddRecordError), + AddInstall(aim_core::app::add::InstallAppError), Prompt(ui::prompt::PromptError), RemovePlan(aim_core::app::remove::ResolveRegisteredAppError), Registry(aim_core::registry::store::RegistryStoreError), @@ -120,9 +112,9 @@ impl From for DispatchError { } } -impl From for DispatchError { - fn from(value: aim_core::app::add::MaterializeAddRecordError) -> Self { - Self::AddRecord(value) +impl From for DispatchError { + fn from(value: aim_core::app::add::InstallAppError) -> Self { + Self::AddInstall(value) } } @@ -161,3 +153,32 @@ fn upsert_app_record(apps: &mut Vec, record: AppRecord) { apps.push(record); } + +fn install_home(registry_path: &Path) -> PathBuf { + if env::var_os("AIM_REGISTRY_PATH").is_some() { + return registry_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("install-home"); + } + + let home = env::var_os("HOME").unwrap_or_else(|| ".".into()); + PathBuf::from(home) +} + +fn is_effective_root() -> bool { + if let Some(value) = env::var_os("AIM_EFFECTIVE_ROOT") { + let value = value.to_string_lossy(); + return value == "1" || value.eq_ignore_ascii_case("true"); + } + + #[cfg(unix)] + unsafe { + libc::geteuid() == 0 + } + + #[cfg(not(unix))] + { + false + } +} diff --git a/crates/aim-cli/src/ui/render.rs b/crates/aim-cli/src/ui/render.rs index ea83fac..3f0c9dc 100644 --- a/crates/aim-cli/src/ui/render.rs +++ b/crates/aim-cli/src/ui/render.rs @@ -19,16 +19,35 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String { } } -fn render_added_app(added: &crate::AddedApp) -> String { - format!( - "tracked app: {} ({})\nsource: {} {}\nselected artifact: {} [{}]", +fn render_added_app(added: &aim_core::app::add::InstalledApp) -> String { + let scope = match added.install_scope { + aim_core::domain::app::InstallScope::User => "user", + aim_core::domain::app::InstallScope::System => "system", + }; + + let warning_lines = added + .warnings + .iter() + .chain(added.install_outcome.warnings.iter()) + .map(|warning| format!("warning: {warning}")) + .collect::>() + .join("\n"); + + let summary = format!( + "installing as {scope}\ninstalled app: {} ({})\nsource: {} {}\nselected artifact: {} [{}]", added.record.display_name, added.record.stable_id, added.source.kind.as_str(), added.source.locator, added.selected_artifact.url, added.selected_artifact.selection_reason, - ) + ); + + if warning_lines.is_empty() { + summary + } else { + format!("{summary}\n{warning_lines}") + } } fn render_pending_add(plan: &AddPlan) -> String { diff --git a/crates/aim-cli/tests/end_to_end_cli.rs b/crates/aim-cli/tests/end_to_end_cli.rs index 3365075..48d5f94 100644 --- a/crates/aim-cli/tests/end_to_end_cli.rs +++ b/crates/aim-cli/tests/end_to_end_cli.rs @@ -66,7 +66,8 @@ fn query_command_registers_unambiguous_app_in_registry_file() { .env(FIXTURE_MODE_ENV, "1") .assert() .success() - .stdout(contains("tracked app: bat (sharkdp-bat)")); + .stdout(contains("installing as user")) + .stdout(contains("installed app: bat (sharkdp-bat)")); let contents = std::fs::read_to_string(®istry_path).unwrap(); assert!(contents.contains("stable_id = \"sharkdp-bat\"")); @@ -103,9 +104,44 @@ fn old_release_query_can_track_latest_and_register_app() { .env("AIM_TRACKING_PREFERENCE", "latest") .assert() .success() - .stdout(contains("tracked app: t3code (pingdotgg-t3code)")); + .stdout(contains("installing as user")) + .stdout(contains("installed app: t3code (pingdotgg-t3code)")); let contents = std::fs::read_to_string(®istry_path).unwrap(); assert!(contents.contains("stable_id = \"pingdotgg-t3code\"")); assert!(contents.contains("locator = \"pingdotgg/t3code\"")); } + +#[test] +fn cli_add_installs_and_renders_resolved_mode() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.arg("sharkdp/bat") + .env("AIM_REGISTRY_PATH", ®istry_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("installing as user")) + .stdout(contains("installed app:")); +} + +#[test] +fn system_request_on_immutable_host_falls_back_to_user_install() { + let dir = tempdir().unwrap(); + let registry_path = dir.path().join("registry.toml"); + let os_release_path = dir.path().join("os-release"); + std::fs::write(&os_release_path, "ID=fedora\nVARIANT_ID=silverblue\n").unwrap(); + + let mut cmd = Command::cargo_bin("aim").unwrap(); + + cmd.args(["--system", "sharkdp/bat"]) + .env("AIM_REGISTRY_PATH", ®istry_path) + .env("AIM_OS_RELEASE_PATH", &os_release_path) + .env(FIXTURE_MODE_ENV, "1") + .assert() + .success() + .stdout(contains("installing as user")) + .stdout(contains("downgraded to user scope")); +} diff --git a/crates/aim-core/src/app/add.rs b/crates/aim-core/src/app/add.rs index 438900d..686111b 100644 --- a/crates/aim-core/src/app/add.rs +++ b/crates/aim-core/src/app/add.rs @@ -1,17 +1,26 @@ +use std::env; +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::query::{ResolveQueryError, resolve_query}; -use crate::domain::app::AppRecord; +use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default}; +use crate::domain::app::{AppRecord, InstallScope}; use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind}; use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy}; +use crate::integration::install::{InstallOutcome, InstallRequest, execute_install}; +use crate::integration::policy::{IntegrationMode, resolve_install_policy}; use crate::metadata::parse_document; +use crate::platform::probe_live_host; use crate::source::github::{ GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, }; use crate::update::channels::build_channels; use crate::update::ranking::{rank_channels, select_artifact, to_preference}; +const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE"; + pub fn build_add_plan(query: &str) -> Result { let transport = crate::source::github::default_transport(); build_add_plan_with(query, transport.as_ref()) @@ -171,6 +180,82 @@ pub fn materialize_app_record( }) } +pub fn install_app( + source_input: &str, + plan: &AddPlan, + install_home: &Path, + requested_scope: InstallScope, +) -> Result { + let 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)?; + let policy = resolve_install_policy(family, requested_scope, &capabilities) + .map_err(InstallAppError::Policy)?; + let payload_path = resolve_target_path( + install_home, + &policy + .payload_root + .join(format!("{}.AppImage", record.stable_id)), + ); + let desktop_path = resolve_target_path( + install_home, + &policy + .desktop_entry_root + .join(format!("aim-{}.desktop", record.stable_id)), + ); + let icon_path = resolve_target_path( + install_home, + &policy.icon_root.join(format!("{}.png", record.stable_id)), + ); + let artifact_bytes = download_artifact_bytes(&plan.selected_artifact.url)?; + let payload_exec = payload_path.clone(); + let desktop_owned = match policy.integration_mode { + IntegrationMode::PayloadOnly | IntegrationMode::Denied => None, + IntegrationMode::Full | IntegrationMode::Degraded => Some(( + desktop_path.clone(), + render_desktop_entry(&record.display_name, &payload_exec), + )), + }; + + let install_outcome = execute_install(&InstallRequest { + staging_root: &install_home.join(".local/share/aim/staging"), + final_payload_path: &payload_path, + artifact_bytes: &artifact_bytes, + desktop: desktop_owned.as_ref().map(|(path, contents)| { + crate::integration::install::DesktopIntegrationRequest { + desktop_entry_path: path.as_path(), + desktop_entry_contents: contents.as_str(), + icon_path: Some(icon_path.as_path()), + icon_bytes: None, + } + }), + helpers: capabilities.helpers.clone(), + }) + .map_err(InstallAppError::Install)?; + + Ok(InstalledApp { + record, + selected_artifact: plan.selected_artifact.clone(), + source: plan.resolution.source.clone(), + install_scope: policy.scope, + integration_mode: policy.integration_mode, + install_outcome, + warnings: policy.warnings, + }) +} + +#[derive(Debug, Eq, PartialEq)] +pub struct InstalledApp { + pub record: AppRecord, + pub selected_artifact: ArtifactCandidate, + pub source: crate::domain::source::SourceRef, + pub install_scope: InstallScope, + pub integration_mode: IntegrationMode, + pub install_outcome: InstallOutcome, + pub warnings: Vec, +} + #[derive(Debug)] pub enum BuildAddPlanError { Query(ResolveQueryError), @@ -182,3 +267,52 @@ pub enum BuildAddPlanError { pub enum MaterializeAddRecordError { Identity(ResolveIdentityError), } + +#[derive(Debug)] +pub enum InstallAppError { + Materialize(MaterializeAddRecordError), + Policy(String), + Download(reqwest::Error), + HostProbe(std::io::Error), + Install(crate::integration::install::PayloadInstallError), +} + +fn download_artifact_bytes(url: &str) -> Result, InstallAppError> { + if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") { + return Ok(b"\x7fELFAppImage".to_vec()); + } + + 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()) +} + +fn render_desktop_entry(display_name: &str, exec_path: &Path) -> String { + format!( + "[Desktop Entry]\nName={display_name}\nExec={}\nType=Application\nCategories=Utility;\n", + exec_path.display() + ) +} + +fn resolve_target_path(install_home: &Path, target: &Path) -> PathBuf { + if target.is_absolute() { + target.to_path_buf() + } else { + install_home.join(target) + } +} + +pub fn resolve_requested_scope(system: bool, user: bool, is_effective_root: bool) -> InstallScope { + let override_scope = if system { + Some(ScopeOverride::System) + } else if user { + Some(ScopeOverride::User) + } else { + None + }; + + resolve_install_scope_with_default(is_effective_root, override_scope) +} diff --git a/crates/aim-core/src/integration/desktop.rs b/crates/aim-core/src/integration/desktop.rs new file mode 100644 index 0000000..21cd6da --- /dev/null +++ b/crates/aim-core/src/integration/desktop.rs @@ -0,0 +1,52 @@ +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DesktopWriteOutcome { + pub desktop_entry_path: PathBuf, + pub icon_path: Option, +} + +pub fn write_desktop_integration( + desktop_entry_path: &Path, + desktop_entry_contents: &str, + icon_path: Option<&Path>, + icon_bytes: Option<&[u8]>, +) -> Result { + if let Some(parent) = desktop_entry_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(desktop_entry_path, desktop_entry_contents)?; + + let written_icon_path = match (icon_path, icon_bytes) { + (Some(path), Some(bytes)) => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, bytes)?; + Some(path.to_path_buf()) + } + _ => None, + }; + + Ok(DesktopWriteOutcome { + desktop_entry_path: desktop_entry_path.to_path_buf(), + icon_path: written_icon_path, + }) +} + +pub fn extract_icon_from_payload(payload: &[u8]) -> Option> { + const PNG_HEADER: &[u8] = b"\x89PNG\r\n\x1a\n"; + const PNG_TRAILER: &[u8] = b"IEND\xaeB`\x82"; + + let start = payload + .windows(PNG_HEADER.len()) + .position(|window| window == PNG_HEADER)?; + let tail = payload[start..] + .windows(PNG_TRAILER.len()) + .position(|window| window == PNG_TRAILER)?; + let end = start + tail + PNG_TRAILER.len(); + + Some(payload[start..end].to_vec()) +} diff --git a/crates/aim-core/src/integration/install.rs b/crates/aim-core/src/integration/install.rs index 9ae3cde..f2030f9 100644 --- a/crates/aim-core/src/integration/install.rs +++ b/crates/aim-core/src/integration/install.rs @@ -1,4 +1,12 @@ +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; +use std::{error::Error, fmt}; + +use crate::integration::desktop::{extract_icon_from_payload, write_desktop_integration}; +use crate::integration::refresh::refresh_integration; +use crate::platform::DesktopHelpers; pub fn staged_appimage_path(staging_root: &Path, app_id: &str) -> PathBuf { staging_root.join(format!("{app_id}.download")) @@ -12,3 +20,144 @@ pub fn replacement_path(target: &Path) -> PathBuf { file_name.push(".new"); target.with_file_name(file_name) } + +#[derive(Debug)] +pub enum PayloadInstallError { + InvalidArtifact, + Io(io::Error), + DesktopIntegration(io::Error), +} + +impl From for PayloadInstallError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl fmt::Display for PayloadInstallError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidArtifact => write!(f, "artifact is not a valid AppImage"), + Self::Io(error) => write!(f, "payload installation failed: {error}"), + Self::DesktopIntegration(error) => { + write!(f, "desktop integration failed: {error}") + } + } + } +} + +impl Error for PayloadInstallError {} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PayloadInstallOutcome { + pub final_payload_path: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DesktopIntegrationRequest<'a> { + pub desktop_entry_path: &'a Path, + pub desktop_entry_contents: &'a str, + pub icon_path: Option<&'a Path>, + pub icon_bytes: Option<&'a [u8]>, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallRequest<'a> { + pub staging_root: &'a Path, + pub final_payload_path: &'a Path, + pub artifact_bytes: &'a [u8], + pub desktop: Option>, + pub helpers: DesktopHelpers, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallOutcome { + pub final_payload_path: PathBuf, + pub desktop_entry_path: Option, + pub icon_path: Option, + pub warnings: Vec, +} + +pub fn stage_and_commit_payload( + staging_root: &Path, + final_payload_path: &Path, + artifact_bytes: &[u8], +) -> Result { + if !is_appimage_payload(artifact_bytes) { + return Err(PayloadInstallError::InvalidArtifact); + } + + let app_id = final_payload_path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("download"); + let staged_path = staged_appimage_path(staging_root, app_id); + let replacement = replacement_path(final_payload_path); + + fs::create_dir_all(staging_root)?; + fs::write(&staged_path, artifact_bytes)?; + + let mut permissions = fs::metadata(&staged_path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&staged_path, permissions)?; + + if let Some(parent) = final_payload_path.parent() { + fs::create_dir_all(parent)?; + } + + fs::rename(&staged_path, &replacement)?; + fs::rename(&replacement, final_payload_path)?; + + Ok(PayloadInstallOutcome { + final_payload_path: final_payload_path.to_path_buf(), + }) +} + +fn is_appimage_payload(bytes: &[u8]) -> bool { + bytes.starts_with(b"\x7fELF") +} + +pub fn execute_install( + request: &InstallRequest<'_>, +) -> Result { + let payload = stage_and_commit_payload( + request.staging_root, + request.final_payload_path, + request.artifact_bytes, + )?; + + let mut desktop_entry_path = None; + let mut icon_path = None; + if let Some(desktop) = &request.desktop { + let extracted_icon = if desktop.icon_bytes.is_none() && desktop.icon_path.is_some() { + extract_icon_from_payload(request.artifact_bytes) + } else { + None + }; + let written = write_desktop_integration( + desktop.desktop_entry_path, + desktop.desktop_entry_contents, + desktop.icon_path, + desktop.icon_bytes.or(extracted_icon.as_deref()), + ) + .map_err(|error| { + let _ = fs::remove_file(&payload.final_payload_path); + PayloadInstallError::DesktopIntegration(error) + })?; + desktop_entry_path = Some(written.desktop_entry_path); + icon_path = written.icon_path; + } + + let warnings = refresh_integration( + &request.helpers, + desktop_entry_path.as_deref(), + icon_path.as_deref(), + ); + + Ok(InstallOutcome { + final_payload_path: payload.final_payload_path, + desktop_entry_path, + icon_path, + warnings, + }) +} diff --git a/crates/aim-core/src/integration/mod.rs b/crates/aim-core/src/integration/mod.rs index 5b917f8..9553307 100644 --- a/crates/aim-core/src/integration/mod.rs +++ b/crates/aim-core/src/integration/mod.rs @@ -1,2 +1,5 @@ +pub mod desktop; pub mod install; pub mod paths; +pub mod policy; +pub mod refresh; diff --git a/crates/aim-core/src/integration/policy.rs b/crates/aim-core/src/integration/policy.rs new file mode 100644 index 0000000..fb99531 --- /dev/null +++ b/crates/aim-core/src/integration/policy.rs @@ -0,0 +1,71 @@ +use std::path::PathBuf; + +use crate::domain::app::InstallScope; +use crate::platform::{ + DistroFamily, HostCapabilities, system_applications_dir, system_icons_dir, + system_managed_appimages_dir, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IntegrationMode { + Full, + Degraded, + PayloadOnly, + Denied, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallPolicy { + pub scope: InstallScope, + pub payload_root: PathBuf, + pub desktop_entry_root: PathBuf, + pub icon_root: PathBuf, + pub integration_mode: IntegrationMode, + pub warnings: Vec, +} + +pub fn resolve_install_policy( + family: DistroFamily, + requested_scope: InstallScope, + capabilities: &HostCapabilities, +) -> Result { + match (family, requested_scope) { + (DistroFamily::Nix, InstallScope::System) => Err( + "system installs are not supported on Nix hosts until a native strategy exists" + .to_string(), + ), + (DistroFamily::Immutable, InstallScope::System) if capabilities.is_immutable => { + Ok(InstallPolicy { + scope: InstallScope::User, + payload_root: PathBuf::from(".local/lib/aim/appimages"), + desktop_entry_root: PathBuf::from(".local/share/applications"), + icon_root: PathBuf::from(".local/share/icons/hicolor/256x256/apps"), + integration_mode: IntegrationMode::Degraded, + warnings: vec![ + "system install requested on immutable host; downgraded to user scope" + .to_string(), + ], + }) + } + (_, InstallScope::System) => Ok(InstallPolicy { + scope: InstallScope::System, + payload_root: system_managed_appimages_dir(), + desktop_entry_root: system_applications_dir(), + icon_root: system_icons_dir(), + integration_mode: IntegrationMode::Full, + warnings: Vec::new(), + }), + _ => Ok(InstallPolicy { + scope: InstallScope::User, + payload_root: PathBuf::from(".local/lib/aim/appimages"), + desktop_entry_root: PathBuf::from(".local/share/applications"), + icon_root: PathBuf::from(".local/share/icons/hicolor/256x256/apps"), + integration_mode: if capabilities.has_desktop_session { + IntegrationMode::Full + } else { + IntegrationMode::PayloadOnly + }, + warnings: Vec::new(), + }), + } +} diff --git a/crates/aim-core/src/integration/refresh.rs b/crates/aim-core/src/integration/refresh.rs new file mode 100644 index 0000000..9e803cc --- /dev/null +++ b/crates/aim-core/src/integration/refresh.rs @@ -0,0 +1,48 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::platform::DesktopHelpers; + +pub fn refresh_integration( + helpers: &DesktopHelpers, + desktop_entry_path: Option<&Path>, + icon_path: Option<&Path>, +) -> Vec { + let mut warnings = Vec::new(); + + if let (Some(helper), Some(path)) = ( + helpers.update_desktop_database_path.as_ref(), + desktop_entry_path.and_then(Path::parent), + ) { + if let Err(error) = Command::new(helper).arg(path).status() { + warnings.push(format!("update-desktop-database failed: {error}")); + } + } else if !helpers.update_desktop_database { + warnings.push( + "update-desktop-database not available; desktop cache refresh skipped".to_owned(), + ); + } + + if let (Some(helper), Some(path)) = ( + helpers.gtk_update_icon_cache_path.as_ref(), + icon_path.map(icon_theme_root), + ) { + if let Err(error) = Command::new(helper).args(["-f", "-t"]).arg(path).status() { + warnings.push(format!("gtk-update-icon-cache failed: {error}")); + } + } else if !helpers.gtk_update_icon_cache { + warnings.push("gtk-update-icon-cache not available; icon cache refresh skipped".to_owned()); + } + + warnings +} + +fn icon_theme_root(icon_path: &Path) -> PathBuf { + for ancestor in icon_path.ancestors() { + if ancestor.file_name().and_then(|name| name.to_str()) == Some("hicolor") { + return ancestor.to_path_buf(); + } + } + + icon_path.parent().unwrap_or(icon_path).to_path_buf() +} diff --git a/crates/aim-core/src/platform/capabilities.rs b/crates/aim-core/src/platform/capabilities.rs new file mode 100644 index 0000000..238bfc0 --- /dev/null +++ b/crates/aim-core/src/platform/capabilities.rs @@ -0,0 +1,88 @@ +use std::fs::{self, OpenOptions}; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DesktopHelpers { + pub update_desktop_database: bool, + pub gtk_update_icon_cache: bool, + pub update_desktop_database_path: Option, + pub gtk_update_icon_cache_path: Option, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct WritableRoots { + pub payload: bool, + pub desktop_entries: bool, + pub icons: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct HostCapabilities { + pub is_immutable: bool, + pub is_nix: bool, + pub has_desktop_session: bool, + pub helpers: DesktopHelpers, + pub writable_roots: WritableRoots, +} + +impl HostCapabilities { + pub fn immutable_user_only() -> Self { + Self { + is_immutable: true, + has_desktop_session: true, + ..Self::default() + } + } +} + +pub fn probe_desktop_helpers(search_paths: &[&Path]) -> DesktopHelpers { + let update_desktop_database_path = command_path(search_paths, "update-desktop-database"); + let gtk_update_icon_cache_path = command_path(search_paths, "gtk-update-icon-cache"); + + DesktopHelpers { + update_desktop_database: update_desktop_database_path.is_some(), + gtk_update_icon_cache: gtk_update_icon_cache_path.is_some(), + update_desktop_database_path, + gtk_update_icon_cache_path, + } +} + +pub fn probe_writable_roots(payload: &Path, desktop_entries: &Path, icons: &Path) -> WritableRoots { + WritableRoots { + payload: is_writable_dir(payload), + desktop_entries: is_writable_dir(desktop_entries), + icons: is_writable_dir(icons), + } +} + +fn command_path(search_paths: &[&Path], executable: &str) -> Option { + search_paths + .iter() + .map(|path| path.join(executable)) + .find(|candidate| is_executable_file(candidate)) +} + +fn is_executable_file(path: &Path) -> bool { + path.is_file() +} + +fn is_writable_dir(path: &Path) -> bool { + if !path.is_dir() { + return false; + } + + let probe_path = path.join(".aim-write-test"); + let result = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&probe_path); + + match result { + Ok(_) => { + let _ = fs::remove_file(&probe_path); + true + } + Err(_) => false, + } +} diff --git a/crates/aim-core/src/platform/distro.rs b/crates/aim-core/src/platform/distro.rs new file mode 100644 index 0000000..d474d4b --- /dev/null +++ b/crates/aim-core/src/platform/distro.rs @@ -0,0 +1,75 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DistroFamily { + Debian, + Fedora, + Arch, + OpenSuse, + Alpine, + Nix, + Immutable, + Generic, +} + +pub fn detect_distro_family(os_release: &str) -> DistroFamily { + let id = lookup_field(os_release, "ID"); + let id_like = lookup_field(os_release, "ID_LIKE"); + let variant_id = lookup_field(os_release, "VARIANT_ID"); + + if matches_any(id, id_like, &["nixos"]) { + return DistroFamily::Nix; + } + + if matches_field(variant_id, &["silverblue", "kinoite", "coreos", "aurora"]) + || matches_any( + id, + id_like, + &["silverblue", "kinoite", "ublue", "fedora-immutable"], + ) + { + return DistroFamily::Immutable; + } + + if matches_any(id, id_like, &["fedora", "rhel", "centos"]) { + return DistroFamily::Fedora; + } + + if matches_any(id, id_like, &["debian", "ubuntu"]) { + return DistroFamily::Debian; + } + + if matches_any(id, id_like, &["arch", "manjaro"]) { + return DistroFamily::Arch; + } + + if matches_any(id, id_like, &["opensuse", "suse", "sles"]) { + return DistroFamily::OpenSuse; + } + + if matches_any(id, id_like, &["alpine"]) { + return DistroFamily::Alpine; + } + + DistroFamily::Generic +} + +fn lookup_field<'a>(os_release: &'a str, key: &str) -> Option<&'a str> { + os_release + .lines() + .find_map(|line| line.strip_prefix(&format!("{key}="))) + .map(trim_value) +} + +fn trim_value(value: &str) -> &str { + value.trim().trim_matches('"') +} + +fn matches_any(id: Option<&str>, id_like: Option<&str>, needles: &[&str]) -> bool { + matches_field(id, needles) || matches_field(id_like, needles) +} + +fn matches_field(field: Option<&str>, needles: &[&str]) -> bool { + field + .into_iter() + .flat_map(|value| value.split_ascii_whitespace()) + .any(|candidate| needles.contains(&candidate)) +} diff --git a/crates/aim-core/src/platform/mod.rs b/crates/aim-core/src/platform/mod.rs index fd84e2b..4636245 100644 --- a/crates/aim-core/src/platform/mod.rs +++ b/crates/aim-core/src/platform/mod.rs @@ -1,5 +1,18 @@ +pub mod capabilities; +pub mod distro; + +use std::env; +use std::fs; +use std::io; use std::path::{Path, PathBuf}; +pub use crate::domain::app::InstallScope; +pub use capabilities::{DesktopHelpers, HostCapabilities, WritableRoots}; +pub use distro::{DistroFamily, detect_distro_family}; + +const OS_RELEASE_PATH_ENV: &str = "AIM_OS_RELEASE_PATH"; +const HELPER_PATHS_ENV: &str = "AIM_HELPER_PATHS"; + pub fn user_managed_appimages_dir(home_dir: &Path) -> PathBuf { home_dir.join(".local/lib/aim/appimages") } @@ -23,3 +36,71 @@ pub fn system_applications_dir() -> PathBuf { pub fn system_icons_dir() -> PathBuf { PathBuf::from("/usr/share/icons/hicolor/256x256/apps") } + +pub fn probe_live_host( + home_dir: &Path, + requested_scope: InstallScope, +) -> io::Result<(DistroFamily, HostCapabilities)> { + let os_release = load_os_release()?; + let family = detect_distro_family(&os_release); + let helper_paths = helper_search_paths(); + let helper_refs = helper_paths + .iter() + .map(PathBuf::as_path) + .collect::>(); + let helpers = capabilities::probe_desktop_helpers(&helper_refs); + let (payload_root, desktop_root, icon_root) = match requested_scope { + InstallScope::User => ( + user_managed_appimages_dir(home_dir), + user_applications_dir(home_dir), + user_icons_dir(home_dir), + ), + InstallScope::System => ( + system_managed_appimages_dir(), + system_applications_dir(), + system_icons_dir(), + ), + }; + + Ok(( + family, + HostCapabilities { + is_immutable: family == DistroFamily::Immutable, + is_nix: family == DistroFamily::Nix, + has_desktop_session: env::var_os("DISPLAY").is_some() + || env::var_os("WAYLAND_DISPLAY").is_some() + || env::var_os("XDG_CURRENT_DESKTOP").is_some(), + helpers, + writable_roots: capabilities::probe_writable_roots( + &payload_root, + &desktop_root, + &icon_root, + ), + }, + )) +} + +fn load_os_release() -> io::Result { + let path = env::var_os(OS_RELEASE_PATH_ENV) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/etc/os-release")); + + match fs::read_to_string(path) { + Ok(contents) => Ok(contents), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(String::new()), + Err(error) => Err(error), + } +} + +fn helper_search_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(extra_paths) = env::var_os(HELPER_PATHS_ENV) { + paths.extend(env::split_paths(&extra_paths)); + } + if let Some(system_paths) = env::var_os("PATH") { + paths.extend(env::split_paths(&system_paths)); + } + + paths +} diff --git a/crates/aim-core/tests/install_failures.rs b/crates/aim-core/tests/install_failures.rs new file mode 100644 index 0000000..c47f475 --- /dev/null +++ b/crates/aim-core/tests/install_failures.rs @@ -0,0 +1,36 @@ +use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install}; +use aim_core::platform::DesktopHelpers; +use std::fs; +use tempfile::tempdir; + +#[test] +fn integration_failure_removes_new_payload_and_generated_files() { + let root = tempdir().unwrap(); + let staging_root = root.path().join("staging"); + let payload_root = root.path().join("payloads"); + let blocking_path = root.path().join("not-a-directory"); + + fs::create_dir(&staging_root).unwrap(); + fs::create_dir(&payload_root).unwrap(); + fs::write(&blocking_path, "blocker").unwrap(); + + let final_payload_path = payload_root.join("bat.AppImage"); + let desktop_entry_path = blocking_path.join("aim-bat.desktop"); + let error = execute_install(&InstallRequest { + staging_root: &staging_root, + final_payload_path: &final_payload_path, + artifact_bytes: b"\x7fELFAppImage", + desktop: Some(DesktopIntegrationRequest { + desktop_entry_path: &desktop_entry_path, + desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n", + icon_path: None, + icon_bytes: None, + }), + helpers: DesktopHelpers::default(), + }) + .unwrap_err(); + + assert!(error.to_string().contains("desktop integration failed")); + assert!(!final_payload_path.exists()); + assert!(!desktop_entry_path.exists()); +} diff --git a/crates/aim-core/tests/install_integration.rs b/crates/aim-core/tests/install_integration.rs new file mode 100644 index 0000000..2537ea3 --- /dev/null +++ b/crates/aim-core/tests/install_integration.rs @@ -0,0 +1,127 @@ +use aim_core::integration::install::{DesktopIntegrationRequest, InstallRequest, execute_install}; +use aim_core::platform::DesktopHelpers; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use tempfile::tempdir; + +#[test] +fn install_writes_desktop_entry_and_reports_refresh_warning_only() { + let root = tempdir().unwrap(); + let staging_root = root.path().join("staging"); + let payload_root = root.path().join("payloads"); + let desktop_root = root.path().join("applications"); + + fs::create_dir(&staging_root).unwrap(); + fs::create_dir(&payload_root).unwrap(); + fs::create_dir(&desktop_root).unwrap(); + + let outcome = execute_install(&InstallRequest { + staging_root: &staging_root, + final_payload_path: &payload_root.join("bat.AppImage"), + artifact_bytes: b"\x7fELFAppImage", + desktop: Some(DesktopIntegrationRequest { + desktop_entry_path: &desktop_root.join("aim-bat.desktop"), + desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n", + icon_path: None, + icon_bytes: None, + }), + helpers: DesktopHelpers::default(), + }) + .unwrap(); + + assert!(outcome.desktop_entry_path.unwrap().exists()); + assert!(!outcome.warnings.is_empty()); +} + +#[test] +fn install_executes_refresh_helpers_when_available() { + let root = tempdir().unwrap(); + let staging_root = root.path().join("staging"); + let payload_root = root.path().join("payloads"); + let desktop_root = root.path().join("applications"); + let helper_root = root.path().join("helpers"); + let log_path = root.path().join("helpers.log"); + + fs::create_dir(&staging_root).unwrap(); + fs::create_dir(&payload_root).unwrap(); + fs::create_dir(&desktop_root).unwrap(); + fs::create_dir(&helper_root).unwrap(); + + let update_helper = helper_root.join("update-desktop-database"); + let icon_helper = helper_root.join("gtk-update-icon-cache"); + fs::write( + &update_helper, + format!("#!/bin/sh\necho desktop:$1 >> {}\n", log_path.display()), + ) + .unwrap(); + fs::write( + &icon_helper, + format!("#!/bin/sh\necho icon:$3 >> {}\n", log_path.display()), + ) + .unwrap(); + fs::set_permissions(&update_helper, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&icon_helper, fs::Permissions::from_mode(0o755)).unwrap(); + + let icon_root = root.path().join("icons/hicolor/256x256/apps"); + fs::create_dir_all(&icon_root).unwrap(); + + let outcome = execute_install(&InstallRequest { + staging_root: &staging_root, + final_payload_path: &payload_root.join("bat.AppImage"), + artifact_bytes: b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82", + desktop: Some(DesktopIntegrationRequest { + desktop_entry_path: &desktop_root.join("aim-bat.desktop"), + desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n", + icon_path: Some(&icon_root.join("bat.png")), + icon_bytes: None, + }), + helpers: DesktopHelpers { + update_desktop_database: true, + gtk_update_icon_cache: true, + update_desktop_database_path: Some(update_helper), + gtk_update_icon_cache_path: Some(icon_helper), + }, + }) + .unwrap(); + + assert!(outcome.warnings.is_empty()); + let log = fs::read_to_string(&log_path).unwrap(); + assert!(log.contains("desktop:")); + assert!(log.contains("icon:")); +} + +#[test] +fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() { + let root = tempdir().unwrap(); + let staging_root = root.path().join("staging"); + let payload_root = root.path().join("payloads"); + let desktop_root = root.path().join("applications"); + let icon_root = root.path().join("icons/hicolor/256x256/apps"); + + fs::create_dir(&staging_root).unwrap(); + fs::create_dir(&payload_root).unwrap(); + fs::create_dir(&desktop_root).unwrap(); + fs::create_dir_all(&icon_root).unwrap(); + + let outcome = execute_install(&InstallRequest { + staging_root: &staging_root, + final_payload_path: &payload_root.join("bat.AppImage"), + artifact_bytes: b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82", + desktop: Some(DesktopIntegrationRequest { + desktop_entry_path: &desktop_root.join("aim-bat.desktop"), + desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n", + icon_path: Some(&icon_root.join("bat.png")), + icon_bytes: None, + }), + helpers: DesktopHelpers::default(), + }) + .unwrap(); + + let icon_path = outcome.icon_path.unwrap(); + assert!(icon_path.exists()); + assert!( + fs::read(&icon_path) + .unwrap() + .starts_with(b"\x89PNG\r\n\x1a\n") + ); +} diff --git a/crates/aim-core/tests/install_payload.rs b/crates/aim-core/tests/install_payload.rs new file mode 100644 index 0000000..c86ca87 --- /dev/null +++ b/crates/aim-core/tests/install_payload.rs @@ -0,0 +1,32 @@ +use aim_core::integration::install::stage_and_commit_payload; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use tempfile::tempdir; + +#[test] +fn payload_commit_moves_staged_appimage_into_final_location() { + let root = tempdir().unwrap(); + let staging_root = root.path().join("staging"); + let payload_root = root.path().join("payloads"); + fs::create_dir(&staging_root).unwrap(); + fs::create_dir(&payload_root).unwrap(); + + let final_payload_path = payload_root.join("bat.AppImage"); + let outcome = + stage_and_commit_payload(&staging_root, &final_payload_path, b"\x7fELFAppImage").unwrap(); + + assert_eq!( + outcome + .final_payload_path + .extension() + .and_then(|ext| ext.to_str()), + Some("AppImage") + ); + assert!(outcome.final_payload_path.exists()); + + let mode = fs::metadata(&outcome.final_payload_path) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o111, 0o111); +} diff --git a/crates/aim-core/tests/install_policy.rs b/crates/aim-core/tests/install_policy.rs new file mode 100644 index 0000000..272bb1f --- /dev/null +++ b/crates/aim-core/tests/install_policy.rs @@ -0,0 +1,49 @@ +use aim_core::integration::policy::{IntegrationMode, resolve_install_policy}; +use aim_core::platform::{DistroFamily, HostCapabilities, InstallScope}; +use std::path::Path; + +#[test] +fn immutable_system_request_downgrades_to_user_when_allowed() { + let capabilities = HostCapabilities::immutable_user_only(); + let policy = + resolve_install_policy(DistroFamily::Immutable, InstallScope::System, &capabilities) + .unwrap(); + + assert_eq!(policy.scope, InstallScope::User); + assert_eq!(policy.integration_mode, IntegrationMode::Degraded); + assert!(!policy.warnings.is_empty()); +} + +#[test] +fn nix_system_request_is_denied() { + let error = resolve_install_policy( + DistroFamily::Nix, + InstallScope::System, + &HostCapabilities::default(), + ) + .unwrap_err(); + + assert!(error.contains("not supported on Nix hosts")); +} + +#[test] +fn system_policy_uses_managed_payload_and_native_integration_roots() { + let policy = resolve_install_policy( + DistroFamily::Fedora, + InstallScope::System, + &HostCapabilities::default(), + ) + .unwrap(); + + assert_eq!(policy.scope, InstallScope::System); + assert_eq!(policy.payload_root, Path::new("/opt/aim/appimages")); + assert_eq!( + policy.desktop_entry_root, + Path::new("/usr/share/applications") + ); + assert_eq!( + policy.icon_root, + Path::new("/usr/share/icons/hicolor/256x256/apps") + ); + assert_eq!(policy.integration_mode, IntegrationMode::Full); +} diff --git a/crates/aim-core/tests/platform_detection.rs b/crates/aim-core/tests/platform_detection.rs new file mode 100644 index 0000000..424eeab --- /dev/null +++ b/crates/aim-core/tests/platform_detection.rs @@ -0,0 +1,52 @@ +use aim_core::platform::capabilities::{probe_desktop_helpers, probe_writable_roots}; +use aim_core::platform::distro::{DistroFamily, detect_distro_family}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use tempfile::tempdir; + +#[test] +fn detects_fedora_family_from_os_release() { + let distro = detect_distro_family("ID=fedora\nID_LIKE=rhel centos\n"); + assert_eq!(distro, DistroFamily::Fedora); +} + +#[test] +fn detects_immutable_family_from_variant_id() { + let distro = detect_distro_family("ID=fedora\nVARIANT_ID=silverblue\n"); + assert_eq!(distro, DistroFamily::Immutable); +} + +#[test] +fn probes_desktop_helpers_from_search_paths() { + let helper_dir = tempdir().unwrap(); + let update_desktop_database = helper_dir.path().join("update-desktop-database"); + let gtk_update_icon_cache = helper_dir.path().join("gtk-update-icon-cache"); + + fs::write(&update_desktop_database, "#!/bin/sh\n").unwrap(); + fs::write(>k_update_icon_cache, "#!/bin/sh\n").unwrap(); + fs::set_permissions(&update_desktop_database, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(>k_update_icon_cache, fs::Permissions::from_mode(0o755)).unwrap(); + + let helpers = probe_desktop_helpers(&[helper_dir.path()]); + + assert!(helpers.update_desktop_database); + assert!(helpers.gtk_update_icon_cache); +} + +#[test] +fn probes_writable_roots_from_candidate_directories() { + let root = tempdir().unwrap(); + let payload = root.path().join("payload"); + let desktop_entries = root.path().join("applications"); + let icons = root.path().join("icons"); + + fs::create_dir(&payload).unwrap(); + fs::create_dir(&desktop_entries).unwrap(); + fs::create_dir(&icons).unwrap(); + + let writable = probe_writable_roots(&payload, &desktop_entries, &icons); + + assert!(writable.payload); + assert!(writable.desktop_entries); + assert!(writable.icons); +}