Merge feat/per-distro-installation
This commit is contained in:
commit
38f900ad50
21 changed files with 1109 additions and 33 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -19,6 +19,7 @@ dependencies = [
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
"clap",
|
"clap",
|
||||||
"dialoguer",
|
"dialoguer",
|
||||||
|
"libc",
|
||||||
"predicates",
|
"predicates",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ version = "0.1.0"
|
||||||
clap = { version = "4.5.32", features = ["derive"] }
|
clap = { version = "4.5.32", features = ["derive"] }
|
||||||
assert_cmd = "2.0.16"
|
assert_cmd = "2.0.16"
|
||||||
dialoguer = "0.12.0"
|
dialoguer = "0.12.0"
|
||||||
|
libc = "0.2.171"
|
||||||
reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
reqwest = { version = "0.12.15", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_yaml = "0.9.34"
|
serde_yaml = "0.9.34"
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ By default `aim` auto-detects whether to use user or system scope. Override that
|
||||||
|
|
||||||
## Current Flow Shape
|
## 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
|
- bare `aim` and `aim update` build a review-first update plan
|
||||||
- `aim list` renders registered applications
|
- `aim list` renders registered applications
|
||||||
- `aim remove <QUERY>` resolves a registered application name before removal
|
- `aim remove <QUERY>` resolves a registered application name before removal
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ path = "src/main.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
dialoguer.workspace = true
|
dialoguer.workspace = true
|
||||||
|
libc.workspace = true
|
||||||
aim-core = { path = "../aim-core" }
|
aim-core = { path = "../aim-core" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,16 @@ pub mod cli;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
||||||
use std::env;
|
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::list::{ListRow, build_list_rows};
|
||||||
use aim_core::app::remove::remove_registered_app;
|
use aim_core::app::remove::remove_registered_app;
|
||||||
use aim_core::app::update::build_update_plan;
|
use aim_core::app::update::build_update_plan;
|
||||||
use aim_core::domain::app::AppRecord;
|
use aim_core::domain::app::AppRecord;
|
||||||
use aim_core::domain::source::SourceRef;
|
use aim_core::domain::update::UpdatePlan;
|
||||||
use aim_core::domain::update::{ArtifactCandidate, UpdatePlan};
|
|
||||||
use aim_core::registry::model::Registry;
|
use aim_core::registry::model::Registry;
|
||||||
use aim_core::registry::store::RegistryStore;
|
use aim_core::registry::store::RegistryStore;
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ pub fn parse() -> Cli {
|
||||||
|
|
||||||
pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
|
pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
|
||||||
let registry_path = registry_path();
|
let registry_path = registry_path();
|
||||||
|
let install_home = install_home(®istry_path);
|
||||||
let store = RegistryStore::new(registry_path);
|
let store = RegistryStore::new(registry_path);
|
||||||
let registry = store.load()?;
|
let registry = store.load()?;
|
||||||
let apps = registry.apps.clone();
|
let apps = registry.apps.clone();
|
||||||
|
|
@ -46,29 +48,26 @@ pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(query) = cli.query {
|
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)?;
|
let mut plan = build_add_plan(&query)?;
|
||||||
if !plan.interactions.is_empty() {
|
if !plan.interactions.is_empty() {
|
||||||
match ui::prompt::resolve_add_plan_interactions(plan.clone())? {
|
match ui::prompt::resolve_add_plan_interactions(plan.clone())? {
|
||||||
Some(resolved) => {
|
Some(resolved) => {
|
||||||
plan = 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();
|
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 {
|
store.save(&Registry {
|
||||||
version: registry.version,
|
version: registry.version,
|
||||||
apps: updated_apps,
|
apps: updated_apps,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
return Ok(DispatchResult::Added(AddedApp {
|
return Ok(DispatchResult::Added(Box::new(installed)));
|
||||||
record,
|
|
||||||
selected_artifact: plan.selected_artifact,
|
|
||||||
source: plan.resolution.source,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(DispatchResult::Noop)
|
Ok(DispatchResult::Noop)
|
||||||
|
|
@ -89,25 +88,18 @@ fn registry_path() -> PathBuf {
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
pub enum DispatchResult {
|
pub enum DispatchResult {
|
||||||
Added(AddedApp),
|
Added(Box<InstalledApp>),
|
||||||
List(Vec<ListRow>),
|
List(Vec<ListRow>),
|
||||||
PendingAdd(AddPlan),
|
PendingAdd(Box<AddPlan>),
|
||||||
Removed(String),
|
Removed(String),
|
||||||
UpdatePlan(UpdatePlan),
|
UpdatePlan(UpdatePlan),
|
||||||
Noop,
|
Noop,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq)]
|
|
||||||
pub struct AddedApp {
|
|
||||||
pub record: AppRecord,
|
|
||||||
pub selected_artifact: ArtifactCandidate,
|
|
||||||
pub source: SourceRef,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum DispatchError {
|
pub enum DispatchError {
|
||||||
AddPlan(aim_core::app::add::BuildAddPlanError),
|
AddPlan(aim_core::app::add::BuildAddPlanError),
|
||||||
AddRecord(aim_core::app::add::MaterializeAddRecordError),
|
AddInstall(aim_core::app::add::InstallAppError),
|
||||||
Prompt(ui::prompt::PromptError),
|
Prompt(ui::prompt::PromptError),
|
||||||
RemovePlan(aim_core::app::remove::ResolveRegisteredAppError),
|
RemovePlan(aim_core::app::remove::ResolveRegisteredAppError),
|
||||||
Registry(aim_core::registry::store::RegistryStoreError),
|
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 {
|
impl From<aim_core::app::add::InstallAppError> for DispatchError {
|
||||||
fn from(value: aim_core::app::add::MaterializeAddRecordError) -> Self {
|
fn from(value: aim_core::app::add::InstallAppError) -> Self {
|
||||||
Self::AddRecord(value)
|
Self::AddInstall(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,3 +153,32 @@ fn upsert_app_record(apps: &mut Vec<AppRecord>, record: AppRecord) {
|
||||||
|
|
||||||
apps.push(record);
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,16 +19,35 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_added_app(added: &crate::AddedApp) -> String {
|
fn render_added_app(added: &aim_core::app::add::InstalledApp) -> String {
|
||||||
format!(
|
let scope = match added.install_scope {
|
||||||
"tracked app: {} ({})\nsource: {} {}\nselected artifact: {} [{}]",
|
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.display_name,
|
||||||
added.record.stable_id,
|
added.record.stable_id,
|
||||||
added.source.kind.as_str(),
|
added.source.kind.as_str(),
|
||||||
added.source.locator,
|
added.source.locator,
|
||||||
added.selected_artifact.url,
|
added.selected_artifact.url,
|
||||||
added.selected_artifact.selection_reason,
|
added.selected_artifact.selection_reason,
|
||||||
)
|
);
|
||||||
|
|
||||||
|
if warning_lines.is_empty() {
|
||||||
|
summary
|
||||||
|
} else {
|
||||||
|
format!("{summary}\n{warning_lines}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_pending_add(plan: &AddPlan) -> String {
|
fn render_pending_add(plan: &AddPlan) -> String {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,8 @@ fn query_command_registers_unambiguous_app_in_registry_file() {
|
||||||
.env(FIXTURE_MODE_ENV, "1")
|
.env(FIXTURE_MODE_ENV, "1")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.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();
|
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||||
assert!(contents.contains("stable_id = \"sharkdp-bat\""));
|
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")
|
.env("AIM_TRACKING_PREFERENCE", "latest")
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.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();
|
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||||
assert!(contents.contains("stable_id = \"pingdotgg-t3code\""));
|
assert!(contents.contains("stable_id = \"pingdotgg-t3code\""));
|
||||||
assert!(contents.contains("locator = \"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"));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,26 @@
|
||||||
|
use std::env;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::adapters::traits::AdapterResolution;
|
use crate::adapters::traits::AdapterResolution;
|
||||||
use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity};
|
use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity};
|
||||||
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
use crate::app::interaction::{InteractionKind, InteractionRequest};
|
||||||
use crate::app::query::{ResolveQueryError, resolve_query};
|
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::source::{NormalizedSourceKind, ResolvedRelease, SourceKind};
|
||||||
use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy};
|
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::metadata::parse_document;
|
||||||
|
use crate::platform::probe_live_host;
|
||||||
use crate::source::github::{
|
use crate::source::github::{
|
||||||
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with,
|
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with,
|
||||||
};
|
};
|
||||||
use crate::update::channels::build_channels;
|
use crate::update::channels::build_channels;
|
||||||
use crate::update::ranking::{rank_channels, select_artifact, to_preference};
|
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> {
|
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
|
||||||
let transport = crate::source::github::default_transport();
|
let transport = crate::source::github::default_transport();
|
||||||
build_add_plan_with(query, transport.as_ref())
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum BuildAddPlanError {
|
pub enum BuildAddPlanError {
|
||||||
Query(ResolveQueryError),
|
Query(ResolveQueryError),
|
||||||
|
|
@ -182,3 +267,52 @@ pub enum BuildAddPlanError {
|
||||||
pub enum MaterializeAddRecordError {
|
pub enum MaterializeAddRecordError {
|
||||||
Identity(ResolveIdentityError),
|
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::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 {
|
pub fn staged_appimage_path(staging_root: &Path, app_id: &str) -> PathBuf {
|
||||||
staging_root.join(format!("{app_id}.download"))
|
staging_root.join(format!("{app_id}.download"))
|
||||||
|
|
@ -12,3 +20,144 @@ pub fn replacement_path(target: &Path) -> PathBuf {
|
||||||
file_name.push(".new");
|
file_name.push(".new");
|
||||||
target.with_file_name(file_name)
|
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 install;
|
||||||
pub mod paths;
|
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};
|
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 {
|
pub fn user_managed_appimages_dir(home_dir: &Path) -> PathBuf {
|
||||||
home_dir.join(".local/lib/aim/appimages")
|
home_dir.join(".local/lib/aim/appimages")
|
||||||
}
|
}
|
||||||
|
|
@ -23,3 +36,71 @@ pub fn system_applications_dir() -> PathBuf {
|
||||||
pub fn system_icons_dir() -> PathBuf {
|
pub fn system_icons_dir() -> PathBuf {
|
||||||
PathBuf::from("/usr/share/icons/hicolor/256x256/apps")
|
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