Implement per-distro installation flow
This commit is contained in:
parent
caf870d05e
commit
b9b60e9b6c
21 changed files with 1109 additions and 33 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
52
crates/aim-core/src/integration/desktop.rs
Normal file
52
crates/aim-core/src/integration/desktop.rs
Normal 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())
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,5 @@
|
|||
pub mod desktop;
|
||||
pub mod install;
|
||||
pub mod paths;
|
||||
pub mod policy;
|
||||
pub mod refresh;
|
||||
|
|
|
|||
71
crates/aim-core/src/integration/policy.rs
Normal file
71
crates/aim-core/src/integration/policy.rs
Normal 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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
48
crates/aim-core/src/integration/refresh.rs
Normal file
48
crates/aim-core/src/integration/refresh.rs
Normal 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()
|
||||
}
|
||||
88
crates/aim-core/src/platform/capabilities.rs
Normal file
88
crates/aim-core/src/platform/capabilities.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
75
crates/aim-core/src/platform/distro.rs
Normal file
75
crates/aim-core/src/platform/distro.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
36
crates/aim-core/tests/install_failures.rs
Normal file
36
crates/aim-core/tests/install_failures.rs
Normal 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());
|
||||
}
|
||||
127
crates/aim-core/tests/install_integration.rs
Normal file
127
crates/aim-core/tests/install_integration.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
32
crates/aim-core/tests/install_payload.rs
Normal file
32
crates/aim-core/tests/install_payload.rs
Normal 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);
|
||||
}
|
||||
49
crates/aim-core/tests/install_policy.rs
Normal file
49
crates/aim-core/tests/install_policy.rs
Normal 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);
|
||||
}
|
||||
52
crates/aim-core/tests/platform_detection.rs
Normal file
52
crates/aim-core/tests/platform_detection.rs
Normal 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(>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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue