Merge branch 'feat/cli-ux-progress'

This commit is contained in:
stoorps 2026-03-21 16:56:28 +00:00
commit 27a1b806cd
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
44 changed files with 4995 additions and 106 deletions

View file

@ -1,4 +1,5 @@
use std::env;
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
@ -17,12 +18,14 @@ use crate::app::scope::{ScopeOverride, resolve_install_scope_with_default};
use crate::domain::app::{AppRecord, InstallMetadata, InstallScope};
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind};
use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy};
use crate::integration::install::{InstallOutcome, InstallRequest, execute_install};
use crate::integration::install::{
InstallOutcome, InstallRequest, execute_install, staged_appimage_path,
};
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,
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with, http_client_policy,
};
use crate::update::channels::build_channels;
use crate::update::ranking::{rank_channels, select_artifact, to_preference};
@ -147,6 +150,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
url: artifact_url,
version: resolution.release.version.clone(),
arch: None,
trusted_checksum: None,
selection_reason: "provider-release".to_owned(),
};
@ -171,6 +175,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
url: resolution.source.locator.clone(),
version: resolution.release.version.clone(),
arch: None,
trusted_checksum: None,
selection_reason: "exact-input".to_owned(),
};
let strategy = UpdateStrategy {
@ -210,6 +215,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
url: artifact_url.clone(),
version: resolution.release.version.clone(),
arch: None,
trusted_checksum: None,
selection_reason: "provider-release".to_owned(),
};
let strategy = UpdateStrategy {
@ -239,6 +245,7 @@ pub fn build_add_plan_with_reporter<T: GitHubTransport + ?Sized>(
url: source.locator.clone(),
version: "unresolved".to_owned(),
arch: None,
trusted_checksum: None,
selection_reason: "heuristic-match".to_owned(),
};
let strategy = UpdateStrategy {
@ -377,8 +384,13 @@ pub fn install_app_with_reporter(
stage: OperationStage::DownloadArtifact,
message: "downloading artifact".to_owned(),
});
let artifact_bytes =
download_artifact_bytes_with_reporter(&plan.selected_artifact.url, reporter)?;
let staging_root = install_home.join(".local/share/aim/staging");
let staged_payload_path = staged_appimage_path(&staging_root, &record.stable_id);
let artifact_size_bytes = download_artifact_to_staged_path_with_reporter(
&plan.selected_artifact.url,
&staged_payload_path,
reporter,
)?;
let payload_exec = payload_path.clone();
let desktop_owned = match policy.integration_mode {
IntegrationMode::PayloadOnly | IntegrationMode::Denied => None,
@ -393,9 +405,9 @@ pub fn install_app_with_reporter(
message: "staging payload".to_owned(),
});
let install_outcome = execute_install(&InstallRequest {
staging_root: &install_home.join(".local/share/aim/staging"),
staged_payload_path: &staged_payload_path,
final_payload_path: &payload_path,
artifact_bytes: &artifact_bytes,
trusted_checksum: plan.selected_artifact.trusted_checksum.as_deref(),
desktop: desktop_owned.as_ref().map(|(path, contents)| {
crate::integration::install::DesktopIntegrationRequest {
desktop_entry_path: path.as_path(),
@ -450,7 +462,7 @@ pub fn install_app_with_reporter(
let installed = InstalledApp {
record,
selected_artifact: plan.selected_artifact.clone(),
artifact_size_bytes: artifact_bytes.len() as u64,
artifact_size_bytes,
source: plan.resolution.source.clone(),
install_scope: policy.scope,
integration_mode: policy.integration_mode,
@ -503,43 +515,117 @@ pub enum InstallAppError {
Install(crate::integration::install::PayloadInstallError),
}
fn download_artifact_bytes_with_reporter(
fn download_artifact_to_staged_path_with_reporter(
url: &str,
staged_payload_path: &Path,
reporter: &mut impl ProgressReporter,
) -> Result<Vec<u8>, InstallAppError> {
) -> Result<u64, InstallAppError> {
let policy = http_client_policy();
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82".to_vec();
reporter.report(&OperationEvent::Progress {
current: bytes.len() as u64,
total: Some(bytes.len() as u64),
let bytes = b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82";
return download_to_staged_path_with_retries(staged_payload_path, reporter, policy, || {
Ok((
Box::new(std::io::Cursor::new(bytes.to_vec())) as Box<dyn Read>,
Some(bytes.len() as u64),
))
});
return Ok(bytes);
}
let response = reqwest::blocking::get(url).map_err(InstallAppError::Download)?;
let response = response
.error_for_status()
let client = reqwest::blocking::Client::builder()
.timeout(policy.timeout)
.build()
.map_err(InstallAppError::Download)?;
let total = response.content_length();
let mut response = response;
let mut bytes = Vec::new();
download_to_staged_path_with_retries(staged_payload_path, reporter, policy, || {
let response = client.get(url).send().map_err(InstallAppError::Download)?;
let response = response
.error_for_status()
.map_err(InstallAppError::Download)?;
let total = response.content_length();
Ok((Box::new(response) as Box<dyn Read>, total))
})
}
pub fn download_to_staged_path_with_retries(
staged_payload_path: &Path,
reporter: &mut impl ProgressReporter,
policy: crate::source::github::HttpClientPolicy,
mut open_stream: impl FnMut() -> Result<(Box<dyn Read>, Option<u64>), InstallAppError>,
) -> Result<u64, InstallAppError> {
let mut last_error = None;
let attempts = policy.max_retries.max(1);
for attempt in 0..attempts {
match open_stream() {
Ok((mut reader, total)) => {
match stream_payload_to_staged_file_with_reporter(
&mut reader,
total,
staged_payload_path,
reporter,
) {
Ok(written) => return Ok(written),
Err(error) if attempt + 1 < attempts && is_retryable_download_error(&error) => {
last_error = Some(error);
}
Err(error) => return Err(error),
}
}
Err(error) if attempt + 1 < attempts && is_retryable_download_error(&error) => {
last_error = Some(error);
}
Err(error) => return Err(error),
}
}
Err(last_error.unwrap_or_else(|| {
InstallAppError::DownloadIo(std::io::Error::other("download failed after retries"))
}))
}
pub fn stream_payload_to_staged_file_with_reporter<R: Read>(
reader: &mut R,
total: Option<u64>,
staged_payload_path: &Path,
reporter: &mut impl ProgressReporter,
) -> Result<u64, InstallAppError> {
if let Some(parent) = staged_payload_path.parent() {
fs::create_dir_all(parent).map_err(InstallAppError::DownloadIo)?;
}
let mut file = File::create(staged_payload_path).map_err(InstallAppError::DownloadIo)?;
let mut buffer = [0_u8; 16 * 1024];
let mut current = 0_u64;
loop {
let read = response
.read(&mut buffer)
.map_err(InstallAppError::DownloadIo)?;
let read = match reader.read(&mut buffer) {
Ok(read) => read,
Err(error) => {
let _ = fs::remove_file(staged_payload_path);
return Err(InstallAppError::DownloadIo(error));
}
};
if read == 0 {
break;
}
bytes.extend_from_slice(&buffer[..read]);
if let Err(error) = std::io::Write::write_all(&mut file, &buffer[..read]) {
let _ = fs::remove_file(staged_payload_path);
return Err(InstallAppError::DownloadIo(error));
}
current += read as u64;
reporter.report(&OperationEvent::Progress { current, total });
}
Ok(bytes)
Ok(current)
}
fn is_retryable_download_error(error: &InstallAppError) -> bool {
matches!(
error,
InstallAppError::Download(_) | InstallAppError::DownloadIo(_)
)
}
fn render_desktop_entry(display_name: &str, exec_path: &Path) -> String {

View file

@ -6,4 +6,5 @@ pub mod progress;
pub mod query;
pub mod remove;
pub mod scope;
pub mod search;
pub mod update;

View file

@ -1,6 +1,7 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OperationKind {
Add,
Search,
UpdateBatch,
UpdateItem,
Remove,

View file

@ -0,0 +1,322 @@
use crate::domain::app::AppRecord;
use crate::domain::search::{
InstalledSearchMatch, SearchInstallStatus, SearchQuery, SearchResult, SearchResults,
SearchWarning,
};
use crate::source::github::{
GitHubSearchError, GitHubTransport, TransportRelease, default_transport,
search_github_repositories_with,
};
use std::collections::HashSet;
pub trait SearchProvider {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchProviderError {
pub provider_id: String,
pub message: String,
}
impl SearchProviderError {
pub fn new(provider_id: &str, message: &str) -> Self {
Self {
provider_id: provider_id.to_owned(),
message: message.to_owned(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SearchError {
ProviderFailures(Vec<SearchWarning>),
}
pub fn build_search_results(
query: &SearchQuery,
installed_apps: &[AppRecord],
) -> Result<SearchResults, SearchError> {
let transport = default_transport();
let provider = GitHubSearchProvider::new(transport.as_ref());
build_search_results_with(query, installed_apps, &[&provider])
}
pub fn build_search_results_with(
query: &SearchQuery,
installed_apps: &[AppRecord],
providers: &[&dyn SearchProvider],
) -> Result<SearchResults, SearchError> {
let installed_matches = collect_installed_matches(query, installed_apps);
let mut remote_hits = Vec::new();
let mut warnings = Vec::new();
for provider in providers {
match provider.search(query) {
Ok(mut hits) => remote_hits.append(&mut hits),
Err(error) => warnings.push(SearchWarning {
provider_id: Some(error.provider_id),
message: error.message,
}),
}
}
annotate_remote_hits_with_install_status(&mut remote_hits, installed_apps);
if remote_hits.is_empty() && installed_matches.is_empty() && !warnings.is_empty() {
return Err(SearchError::ProviderFailures(warnings));
}
Ok(SearchResults {
query_text: query.text.clone(),
remote_hits,
installed_matches,
warnings,
})
}
pub struct GitHubSearchProvider<'a, T: GitHubTransport + ?Sized> {
transport: &'a T,
}
impl<'a, T: GitHubTransport + ?Sized> GitHubSearchProvider<'a, T> {
pub fn new(transport: &'a T) -> Self {
Self { transport }
}
}
impl<T: GitHubTransport + ?Sized> SearchProvider for GitHubSearchProvider<'_, T> {
fn search(&self, query: &SearchQuery) -> Result<Vec<SearchResult>, SearchProviderError> {
let name_only_query = format!("{} in:name", query.text);
let mut ranked_hits =
search_github_repositories_with(&name_only_query, query.remote_limit, self.transport)
.map_err(|error| {
SearchProviderError::new("github", &render_github_search_error(&error))
})?;
if ranked_hits.len() < query.remote_limit {
let mut seen = ranked_hits
.iter()
.map(|hit| hit.full_name.clone())
.collect::<HashSet<_>>();
let backfill =
search_github_repositories_with(&query.text, query.remote_limit, self.transport)
.map_err(|error| {
SearchProviderError::new("github", &render_github_search_error(&error))
})?;
for hit in backfill {
if ranked_hits.len() >= query.remote_limit {
break;
}
if seen.insert(hit.full_name.clone()) {
ranked_hits.push(hit);
}
}
}
let normalized_query = normalize_lookup(&query.text);
let mut ranked_hits = ranked_hits
.into_iter()
.enumerate()
.map(|(index, hit)| {
(
github_remote_match_rank(&normalized_query, &hit),
index,
hit,
)
})
.collect::<Vec<_>>();
ranked_hits.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
Ok(ranked_hits
.into_iter()
.filter_map(|(_, _, hit)| {
let full_name = hit.full_name;
let release = latest_appimage_release(self.transport, &full_name)?;
Some(SearchResult {
provider_id: "github".to_owned(),
display_name: full_name.clone(),
description: hit.description,
source_locator: hit.html_url,
install_query: full_name.clone(),
canonical_locator: full_name.clone(),
version: Some(release.tag.trim_start_matches('v').to_owned()),
install_status: SearchInstallStatus::Available,
})
})
.collect())
}
}
fn latest_appimage_release<T: GitHubTransport + ?Sized>(
transport: &T,
repo: &str,
) -> Option<TransportRelease> {
transport.fetch_releases(repo).ok().and_then(|releases| {
releases.into_iter().find(|release| {
release
.assets
.iter()
.any(|asset| asset.name.ends_with(".AppImage"))
})
})
}
fn collect_installed_matches(
query: &SearchQuery,
installed_apps: &[AppRecord],
) -> Vec<InstalledSearchMatch> {
let normalized_query = normalize_lookup(&query.text);
let mut matches = installed_apps
.iter()
.filter_map(|app| {
match_rank(&normalized_query, &app.stable_id, &app.display_name).map(|rank| {
(
rank,
normalize_lookup(&app.stable_id),
InstalledSearchMatch {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
installed_version: app.installed_version.clone(),
},
)
})
})
.collect::<Vec<_>>();
matches.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
matches
.into_iter()
.map(|(_, _, installed_match)| installed_match)
.collect()
}
fn match_rank(query: &str, stable_id: &str, display_name: &str) -> Option<u8> {
let stable_id = normalize_lookup(stable_id);
let display_name = normalize_lookup(display_name);
[stable_id, display_name]
.into_iter()
.filter_map(|candidate| {
if candidate == query {
Some(0)
} else if candidate.starts_with(query) {
Some(1)
} else if candidate.contains(query) {
Some(2)
} else {
None
}
})
.min()
}
fn normalize_lookup(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn annotate_remote_hits_with_install_status(
remote_hits: &mut [SearchResult],
installed_apps: &[AppRecord],
) {
for hit in remote_hits.iter_mut() {
if let Some(installed) = installed_apps
.iter()
.find(|app| app_matches_remote_hit(app, hit))
{
if installed.installed_version == hit.version {
hit.install_status = SearchInstallStatus::Installed {
installed_version: installed.installed_version.clone(),
};
} else {
hit.install_status = SearchInstallStatus::UpdateAvailable {
installed_version: installed.installed_version.clone(),
latest_version: hit.version.clone(),
};
}
}
}
}
fn app_matches_remote_hit(app: &AppRecord, hit: &SearchResult) -> bool {
let Some(locator) = app_search_locator(app) else {
return false;
};
locator == normalize_lookup(&hit.install_query)
|| locator == normalize_lookup(&hit.canonical_locator)
}
fn app_search_locator(app: &AppRecord) -> Option<String> {
if let Some(source) = &app.source
&& source.kind == crate::domain::source::SourceKind::GitHub
{
if let Some(locator) = source.canonical_locator.as_deref() {
return Some(normalize_lookup(locator));
}
return Some(normalize_lookup(&source.locator));
}
app.source_input.as_deref().and_then(|input| {
if input.contains('/') && !input.contains("://") {
Some(normalize_lookup(input))
} else {
None
}
})
}
fn github_remote_match_rank(
query: &str,
repository: &crate::source::github::TransportRepository,
) -> u8 {
let full_name = normalize_lookup(&repository.full_name);
let description = repository.description.as_deref().map(normalize_lookup);
let mut parts = full_name.split('/');
let owner = parts.next().unwrap_or_default();
let repo = parts.next().unwrap_or_default();
if full_name == query {
return 0;
}
if owner == query || repo == query {
return 1;
}
if full_name.starts_with(query) || owner.starts_with(query) || repo.starts_with(query) {
return 2;
}
if full_name.contains(query) || owner.contains(query) || repo.contains(query) {
return 3;
}
if description
.as_deref()
.map(|description| description.starts_with(query))
.unwrap_or(false)
{
return 4;
}
if description
.as_deref()
.map(|description| description.contains(query))
.unwrap_or(false)
{
return 5;
}
6
}
fn render_github_search_error(error: &GitHubSearchError) -> String {
match error {
GitHubSearchError::Transport(inner) => inner.to_string(),
}
}

View file

@ -1,3 +1,4 @@
pub mod app;
pub mod search;
pub mod source;
pub mod update;

View file

@ -0,0 +1,68 @@
pub const DEFAULT_REMOTE_LIMIT: usize = 10;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SearchInstallStatus {
Available,
Installed {
installed_version: Option<String>,
},
UpdateAvailable {
installed_version: Option<String>,
latest_version: Option<String>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchQuery {
pub text: String,
pub remote_limit: usize,
}
impl SearchQuery {
pub fn new(text: &str) -> Self {
Self {
text: text.to_owned(),
remote_limit: DEFAULT_REMOTE_LIMIT,
}
}
pub fn with_remote_limit(text: &str, remote_limit: usize) -> Self {
Self {
text: text.to_owned(),
remote_limit,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchResult {
pub provider_id: String,
pub display_name: String,
pub description: Option<String>,
pub source_locator: String,
pub install_query: String,
pub canonical_locator: String,
pub version: Option<String>,
pub install_status: SearchInstallStatus,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstalledSearchMatch {
pub stable_id: String,
pub display_name: String,
pub installed_version: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchWarning {
pub provider_id: Option<String>,
pub message: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SearchResults {
pub query_text: String,
pub remote_hits: Vec<SearchResult>,
pub installed_matches: Vec<InstalledSearchMatch>,
pub warnings: Vec<SearchWarning>,
}

View file

@ -85,6 +85,7 @@ pub struct ArtifactCandidate {
pub url: String,
pub version: String,
pub arch: Option<String>,
pub trusted_checksum: Option<String>,
pub selection_reason: String,
}

View file

@ -1,9 +1,13 @@
use std::fs;
use std::io;
use std::io::Read;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::{error::Error, fmt};
use base64::Engine;
use sha2::{Digest, Sha512};
use crate::integration::desktop::{extract_icon_from_payload, write_desktop_integration};
use crate::integration::refresh::refresh_integration;
use crate::platform::DesktopHelpers;
@ -24,6 +28,8 @@ pub fn replacement_path(target: &Path) -> PathBuf {
#[derive(Debug)]
pub enum PayloadInstallError {
InvalidArtifact,
ChecksumMismatch,
InvalidTrustedChecksum,
Io(io::Error),
DesktopIntegration(io::Error),
}
@ -38,6 +44,8 @@ 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::ChecksumMismatch => write!(f, "artifact checksum did not match trusted metadata"),
Self::InvalidTrustedChecksum => write!(f, "trusted checksum metadata is malformed"),
Self::Io(error) => write!(f, "payload installation failed: {error}"),
Self::DesktopIntegration(error) => {
write!(f, "desktop integration failed: {error}")
@ -63,9 +71,9 @@ pub struct DesktopIntegrationRequest<'a> {
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstallRequest<'a> {
pub staging_root: &'a Path,
pub staged_payload_path: &'a Path,
pub final_payload_path: &'a Path,
pub artifact_bytes: &'a [u8],
pub trusted_checksum: Option<&'a str>,
pub desktop: Option<DesktopIntegrationRequest<'a>>,
pub helpers: DesktopHelpers,
}
@ -79,33 +87,25 @@ pub struct InstallOutcome {
}
pub fn stage_and_commit_payload(
staging_root: &Path,
staged_payload_path: &Path,
final_payload_path: &Path,
artifact_bytes: &[u8],
) -> Result<PayloadInstallOutcome, PayloadInstallError> {
if !is_appimage_payload(artifact_bytes) {
if !is_appimage_payload_path(staged_payload_path)? {
let _ = fs::remove_file(staged_payload_path);
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();
let mut permissions = fs::metadata(staged_payload_path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&staged_path, permissions)?;
fs::set_permissions(staged_payload_path, permissions)?;
if let Some(parent) = final_payload_path.parent() {
fs::create_dir_all(parent)?;
}
fs::rename(&staged_path, &replacement)?;
fs::rename(staged_payload_path, &replacement)?;
fs::rename(&replacement, final_payload_path)?;
Ok(PayloadInstallOutcome {
@ -113,24 +113,25 @@ pub fn stage_and_commit_payload(
})
}
fn is_appimage_payload(bytes: &[u8]) -> bool {
bytes.starts_with(b"\x7fELF")
fn is_appimage_payload_path(path: &Path) -> Result<bool, io::Error> {
let mut file = fs::File::open(path)?;
let mut header = [0_u8; 4];
let read = file.read(&mut header)?;
Ok(read == header.len() && header == *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,
)?;
verify_trusted_checksum(request.staged_payload_path, request.trusted_checksum)?;
let payload =
stage_and_commit_payload(request.staged_payload_path, request.final_payload_path)?;
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)
extract_icon_from_payload_path(&payload.final_payload_path)
} else {
None
};
@ -161,3 +162,38 @@ pub fn execute_install(
warnings,
})
}
fn extract_icon_from_payload_path(path: &Path) -> Option<Vec<u8>> {
fs::read(path)
.ok()
.and_then(|payload| extract_icon_from_payload(&payload))
}
fn verify_trusted_checksum(
staged_payload_path: &Path,
trusted_checksum: Option<&str>,
) -> Result<(), PayloadInstallError> {
let Some(trusted_checksum) = trusted_checksum.map(str::trim) else {
return Ok(());
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(trusted_checksum)
.map_err(|_| {
let _ = fs::remove_file(staged_payload_path);
PayloadInstallError::InvalidTrustedChecksum
})?;
if decoded.len() != 64 {
let _ = fs::remove_file(staged_payload_path);
return Err(PayloadInstallError::InvalidTrustedChecksum);
}
let payload = fs::read(staged_payload_path)?;
let actual_checksum = base64::engine::general_purpose::STANDARD.encode(Sha512::digest(payload));
if actual_checksum != trusted_checksum {
let _ = fs::remove_file(staged_payload_path);
return Err(PayloadInstallError::ChecksumMismatch);
}
Ok(())
}

View file

@ -1,6 +1,8 @@
use std::fs;
use std::fs::{self, File, OpenOptions};
use std::path::PathBuf;
use fs2::FileExt;
use crate::registry::model::Registry;
pub struct RegistryStore {
@ -28,14 +30,71 @@ impl RegistryStore {
}
let contents = toml::to_string(registry)?;
fs::write(&self.path, contents)?;
let temporary_path = self.temporary_path();
fs::write(&temporary_path, contents)?;
fs::rename(&temporary_path, &self.path).map_err(|error| {
let _ = fs::remove_file(&temporary_path);
RegistryStoreError::Io(error)
})?;
Ok(())
}
pub fn lock_exclusive(&self) -> Result<RegistryLock, RegistryStoreError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let lock_file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(self.lock_path())?;
match lock_file.try_lock_exclusive() {
Ok(()) => Ok(RegistryLock { file: lock_file }),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
Err(RegistryStoreError::LockUnavailable)
}
Err(error) => Err(RegistryStoreError::Io(error)),
}
}
pub fn mutate_exclusive<F>(&self, apply: F) -> Result<Registry, RegistryStoreError>
where
F: FnOnce(&mut Registry),
{
let _lock = self.lock_exclusive()?;
let mut registry = self.load()?;
apply(&mut registry);
self.save(&registry)?;
Ok(registry)
}
fn lock_path(&self) -> PathBuf {
self.path.with_extension("toml.lock")
}
fn temporary_path(&self) -> PathBuf {
self.path.with_extension("toml.tmp")
}
}
#[derive(Debug)]
pub struct RegistryLock {
file: File,
}
impl Drop for RegistryLock {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}
#[derive(Debug)]
pub enum RegistryStoreError {
Io(std::io::Error),
LockUnavailable,
SerializeToml(toml::ser::Error),
Toml(toml::de::Error),
}

View file

@ -1,14 +1,36 @@
use std::env;
use std::time::Duration;
use crate::domain::source::{ResolvedRelease, SourceRef};
use crate::metadata::MetadataDocument;
const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com";
const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 30;
const DEFAULT_HTTP_MAX_RETRIES: usize = 3;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct HttpClientPolicy {
pub timeout: Duration,
pub max_retries: usize,
}
pub fn http_client_policy() -> HttpClientPolicy {
HttpClientPolicy {
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
max_retries: DEFAULT_HTTP_MAX_RETRIES,
}
}
pub trait GitHubTransport {
fn fetch_releases(&self, repo: &str) -> Result<Vec<TransportRelease>, GitHubDiscoveryError>;
fn search_repositories(
&self,
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError>;
fn fetch_document(
&self,
url: &str,
@ -30,6 +52,13 @@ pub struct TransportRelease {
pub assets: Vec<TransportAsset>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportRepository {
pub full_name: String,
pub description: Option<String>,
pub html_url: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitHubAsset {
pub name: String,
@ -130,6 +159,22 @@ pub fn discover_github_candidates_with<T: GitHubTransport + ?Sized>(
})
}
pub fn search_github_repositories(
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
let transport = default_transport();
search_github_repositories_with(query, limit, transport.as_ref())
}
pub fn search_github_repositories_with<T: GitHubTransport + ?Sized>(
query: &str,
limit: usize,
transport: &T,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
transport.search_repositories(query, limit)
}
pub fn default_transport() -> Box<dyn GitHubTransport> {
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
Box::new(FixtureGitHubTransport)
@ -151,6 +196,7 @@ impl Default for ReqwestGitHubTransport {
impl ReqwestGitHubTransport {
pub fn new() -> Self {
let policy = http_client_policy();
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
reqwest::header::USER_AGENT,
@ -171,6 +217,7 @@ impl ReqwestGitHubTransport {
Self {
client: reqwest::blocking::Client::builder()
.default_headers(default_headers)
.timeout(policy.timeout)
.build()
.expect("reqwest client should build"),
api_base: env::var("AIM_GITHUB_API_BASE")
@ -210,6 +257,34 @@ impl GitHubTransport for ReqwestGitHubTransport {
.collect())
}
fn search_repositories(
&self,
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
let url = format!("{}/search/repositories", self.api_base);
let response = self
.client
.get(url)
.query(&[("q", query), ("per_page", &limit.to_string())])
.send()
.map_err(GitHubSearchError::Transport)?
.error_for_status()
.map_err(GitHubSearchError::Transport)?
.json::<ApiRepositorySearchResponse>()
.map_err(GitHubSearchError::Transport)?;
Ok(response
.items
.into_iter()
.map(|repository| TransportRepository {
full_name: repository.full_name,
description: repository.description,
html_url: repository.html_url,
})
.collect())
}
fn fetch_document(
&self,
url: &str,
@ -246,6 +321,14 @@ impl GitHubTransport for FixtureGitHubTransport {
Ok(fixture_releases(repo))
}
fn search_repositories(
&self,
query: &str,
limit: usize,
) -> Result<Vec<TransportRepository>, GitHubSearchError> {
Ok(fixture_repository_search(query, limit))
}
fn fetch_document(
&self,
url: &str,
@ -269,6 +352,11 @@ pub enum GitHubDiscoveryError {
Transport(reqwest::Error),
}
#[derive(Debug)]
pub enum GitHubSearchError {
Transport(reqwest::Error),
}
#[derive(serde::Deserialize)]
struct ApiRelease {
tag_name: String,
@ -283,6 +371,18 @@ struct ApiAsset {
content_type: Option<String>,
}
#[derive(serde::Deserialize)]
struct ApiRepositorySearchResponse {
items: Vec<ApiRepository>,
}
#[derive(serde::Deserialize)]
struct ApiRepository {
full_name: String,
description: Option<String>,
html_url: String,
}
fn is_appimage_asset(name: &str) -> bool {
name.ends_with(".AppImage")
}
@ -308,6 +408,16 @@ fn fixture_releases(repo: &str) -> Vec<TransportRelease> {
fixture_release(repo, "v0.0.11", "T3-Code-0.0.11-x86_64.AppImage"),
],
"sharkdp/bat" => vec![fixture_release(repo, "v1.0.0", "Bat-1.0.0-x86_64.AppImage")],
"fero1xd/uploadstuff-server" => vec![fixture_release_without_appimage(
repo,
"v1.0.0",
"uploadstuff-server-linux-x86_64.tar.gz",
)],
"Socialure/lawn" => vec![fixture_release_without_appimage(
repo,
"v1.0.0",
"lawn-linux-x86_64.tar.gz",
)],
_ => {
let repo_name = repo.split('/').next_back().unwrap_or("app");
let title = title_case(repo_name);
@ -339,6 +449,25 @@ fn fixture_release(repo: &str, tag: &str, asset_name: &str) -> TransportRelease
}
}
fn fixture_release_without_appimage(repo: &str, tag: &str, asset_name: &str) -> TransportRelease {
TransportRelease {
tag: tag.to_owned(),
prerelease: false,
assets: vec![
TransportAsset {
name: asset_name.to_owned(),
url: format!("https://github.com/{repo}/releases/download/{tag}/{asset_name}"),
content_type: Some("application/gzip".to_owned()),
},
TransportAsset {
name: "latest-linux.yml".to_owned(),
url: format!("https://github.com/{repo}/releases/download/{tag}/latest-linux.yml"),
content_type: Some("application/yaml".to_owned()),
},
],
}
}
fn fixture_document(url: &str) -> Option<Vec<u8>> {
let tag = url.split("/releases/download/").nth(1)?.split('/').next()?;
let name = url.split('/').next_back()?;
@ -352,13 +481,85 @@ fn fixture_document(url: &str) -> Option<Vec<u8>> {
};
let version = tag.trim_start_matches('v');
Some(
format!("version: {version}\npath: {appimage}\nsha512: fixture-sha\n").into_bytes(),
format!("version: {version}\npath: {appimage}\nsha512: ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw==\n").into_bytes(),
)
}
_ => None,
}
}
fn fixture_repository_search(query: &str, limit: usize) -> Vec<TransportRepository> {
let (normalized_query, name_only) = parse_fixture_repository_query(query);
fixture_repository_catalog()
.into_iter()
.filter(|repository| {
let full_name_matches = repository
.full_name
.to_ascii_lowercase()
.contains(&normalized_query);
if name_only {
return full_name_matches;
}
full_name_matches
|| repository
.description
.as_deref()
.map(|description| description.to_ascii_lowercase().contains(&normalized_query))
.unwrap_or(false)
})
.take(limit)
.collect()
}
fn parse_fixture_repository_query(query: &str) -> (String, bool) {
let trimmed = query.trim();
if let Some(value) = trimmed.strip_suffix(" in:name") {
return (value.trim().to_ascii_lowercase(), true);
}
(trimmed.to_ascii_lowercase(), false)
}
fn fixture_repository_catalog() -> Vec<TransportRepository> {
vec![
TransportRepository {
full_name: "sharkdp/bat".to_owned(),
description: Some("A cat(1) clone with wings.".to_owned()),
html_url: "https://github.com/sharkdp/bat".to_owned(),
},
TransportRepository {
full_name: "astatine/bat".to_owned(),
description: Some("A small fixture repository for bat-shaped searches.".to_owned()),
html_url: "https://github.com/astatine/bat".to_owned(),
},
TransportRepository {
full_name: "eth-p/bat-extras".to_owned(),
description: Some("Bash scripts that integrate with bat.".to_owned()),
html_url: "https://github.com/eth-p/bat-extras".to_owned(),
},
TransportRepository {
full_name: "fero1xd/uploadstuff-server".to_owned(),
description: Some("Custom Server for UploadThing by pingdotgg".to_owned()),
html_url: "https://github.com/fero1xd/uploadstuff-server".to_owned(),
},
TransportRepository {
full_name: "Socialure/lawn".to_owned(),
description: Some(
"Video review for creative teams — Socialure-branded fork of pingdotgg/lawn"
.to_owned(),
),
html_url: "https://github.com/Socialure/lawn".to_owned(),
},
TransportRepository {
full_name: "pingdotgg/t3code".to_owned(),
description: Some("The T3 desktop app.".to_owned()),
html_url: "https://github.com/pingdotgg/t3code".to_owned(),
},
]
}
fn title_case(value: &str) -> String {
value
.split(['-', '_'])

View file

@ -70,6 +70,7 @@ pub fn select_artifact(
.clone()
.unwrap_or_else(|| "latest".to_owned()),
arch: Some("x86_64".to_owned()),
trusted_checksum: hints.and_then(|value| value.checksum.clone()),
selection_reason: selection_reason.to_owned(),
}
}