Implement per-distro installation flow

This commit is contained in:
stoorps 2026-03-19 21:59:18 +00:00
parent caf870d05e
commit b9b60e9b6c
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
21 changed files with 1109 additions and 33 deletions

1
Cargo.lock generated
View file

@ -19,6 +19,7 @@ dependencies = [
"assert_cmd",
"clap",
"dialoguer",
"libc",
"predicates",
"tempfile",
]

View file

@ -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"

View file

@ -39,7 +39,7 @@ By default `aim` auto-detects whether to use user or system scope. Override that
## Current Flow Shape
- `aim <QUERY>` registers unambiguous apps into the registry and renders review prompts when tracking needs confirmation
- `aim <QUERY>` 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 <QUERY>` resolves a registered application name before removal

View file

@ -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]

View file

@ -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<DispatchResult, DispatchError> {
let registry_path = registry_path();
let install_home = install_home(&registry_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<DispatchResult, DispatchError> {
}
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<InstalledApp>),
List(Vec<ListRow>),
PendingAdd(AddPlan),
PendingAdd(Box<AddPlan>),
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<aim_core::app::add::BuildAddPlanError> for DispatchError {
}
}
impl From<aim_core::app::add::MaterializeAddRecordError> for DispatchError {
fn from(value: aim_core::app::add::MaterializeAddRecordError) -> Self {
Self::AddRecord(value)
impl From<aim_core::app::add::InstallAppError> 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<AppRecord>, 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
}
}

View file

@ -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::<Vec<_>>()
.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 {

View file

@ -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(&registry_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(&registry_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", &registry_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", &registry_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"));
}

View file

@ -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<AddPlan, BuildAddPlanError> {
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<InstalledApp, InstallAppError> {
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<String>,
}
#[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<Vec<u8>, 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)
}

View file

@ -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<PathBuf>,
}
pub fn write_desktop_integration(
desktop_entry_path: &Path,
desktop_entry_contents: &str,
icon_path: Option<&Path>,
icon_bytes: Option<&[u8]>,
) -> Result<DesktopWriteOutcome, io::Error> {
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<Vec<u8>> {
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())
}

View file

@ -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<io::Error> 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<DesktopIntegrationRequest<'a>>,
pub helpers: DesktopHelpers,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstallOutcome {
pub final_payload_path: PathBuf,
pub desktop_entry_path: Option<PathBuf>,
pub icon_path: Option<PathBuf>,
pub warnings: Vec<String>,
}
pub fn stage_and_commit_payload(
staging_root: &Path,
final_payload_path: &Path,
artifact_bytes: &[u8],
) -> Result<PayloadInstallOutcome, PayloadInstallError> {
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<InstallOutcome, PayloadInstallError> {
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,
})
}

View file

@ -1,2 +1,5 @@
pub mod desktop;
pub mod install;
pub mod paths;
pub mod policy;
pub mod refresh;

View file

@ -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<String>,
}
pub fn resolve_install_policy(
family: DistroFamily,
requested_scope: InstallScope,
capabilities: &HostCapabilities,
) -> Result<InstallPolicy, String> {
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(),
}),
}
}

View file

@ -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<String> {
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()
}

View file

@ -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<PathBuf>,
pub gtk_update_icon_cache_path: Option<PathBuf>,
}
#[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<PathBuf> {
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,
}
}

View file

@ -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))
}

View file

@ -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::<Vec<_>>();
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<String> {
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<PathBuf> {
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
}

View file

@ -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());
}

View file

@ -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")
);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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(&gtk_update_icon_cache, "#!/bin/sh\n").unwrap();
fs::set_permissions(&update_desktop_database, fs::Permissions::from_mode(0o755)).unwrap();
fs::set_permissions(&gtk_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);
}