feat: finalize search UX and release hardening
This commit is contained in:
parent
c63b2917da
commit
34f9543a78
44 changed files with 4983 additions and 94 deletions
|
|
@ -8,9 +8,12 @@ license.workspace = true
|
|||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
fs2.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
sha2.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use std::env;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
|
|
@ -13,12 +14,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};
|
||||
|
|
@ -100,6 +103,7 @@ pub fn build_add_plan_with<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 {
|
||||
|
|
@ -238,8 +242,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);
|
||||
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,
|
||||
|
|
@ -265,9 +274,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(),
|
||||
|
|
@ -355,43 +364,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 {
|
||||
|
|
|
|||
|
|
@ -6,4 +6,5 @@ pub mod progress;
|
|||
pub mod query;
|
||||
pub mod remove;
|
||||
pub mod scope;
|
||||
pub mod search;
|
||||
pub mod update;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum OperationKind {
|
||||
Add,
|
||||
Search,
|
||||
UpdateBatch,
|
||||
UpdateItem,
|
||||
Remove,
|
||||
|
|
|
|||
322
crates/aim-core/src/app/search.rs
Normal file
322
crates/aim-core/src/app/search.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod app;
|
||||
pub mod search;
|
||||
pub mod source;
|
||||
pub mod update;
|
||||
|
|
|
|||
68
crates/aim-core/src/domain/search.rs
Normal file
68
crates/aim-core/src/domain/search.rs
Normal 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>,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(®istry)?;
|
||||
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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(['-', '_'])
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
crates/aim-core/tests/checksum_verification.rs
Normal file
95
crates/aim-core/tests/checksum_verification.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
use std::fs;
|
||||
|
||||
use aim_core::integration::install::{InstallRequest, PayloadInstallError, execute_install};
|
||||
use aim_core::platform::DesktopHelpers;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const VALID_FIXTURE_SHA512: &str =
|
||||
"ZZma4ZD+9XB4GGTHCNZu8I92OY02YrEvIG89ZtRNi99W8SZKwWkmGZz/QyNBxqAt0XeiKtcR80/dMnKlwpcIWw==";
|
||||
|
||||
#[test]
|
||||
fn install_succeeds_with_valid_trusted_checksum() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(
|
||||
root.path(),
|
||||
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
|
||||
);
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: Some(VALID_FIXTURE_SHA512),
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.final_payload_path, final_payload_path);
|
||||
assert!(outcome.final_payload_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_succeeds_without_trusted_checksum() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(outcome.final_payload_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_fails_before_commit_when_trusted_checksum_mismatches() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let error = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: Some(VALID_FIXTURE_SHA512),
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PayloadInstallError::ChecksumMismatch));
|
||||
assert!(!final_payload_path.exists());
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_trusted_checksum_fails_before_commit() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
|
||||
let error = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: Some("not-base64"),
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, PayloadInstallError::InvalidTrustedChecksum));
|
||||
assert!(!final_payload_path.exists());
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
fn write_staged_payload(root: &std::path::Path, bytes: &[u8]) -> std::path::PathBuf {
|
||||
let staged_path = root.join("staging/bat.download");
|
||||
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
|
||||
fs::write(&staged_path, bytes).unwrap();
|
||||
staged_path
|
||||
}
|
||||
180
crates/aim-core/tests/download_pipeline.rs
Normal file
180
crates/aim-core/tests/download_pipeline.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
use std::fs;
|
||||
use std::io::{self, Cursor, Read};
|
||||
use std::time::Duration;
|
||||
|
||||
use aim_core::app::add::{
|
||||
InstallAppError, download_to_staged_path_with_retries,
|
||||
stream_payload_to_staged_file_with_reporter,
|
||||
};
|
||||
use aim_core::app::progress::{NoopReporter, OperationEvent};
|
||||
use aim_core::integration::install::{InstallRequest, execute_install};
|
||||
use aim_core::platform::DesktopHelpers;
|
||||
use aim_core::source::github::HttpClientPolicy;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn payload_streaming_writes_staged_file_and_reports_progress() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = root.path().join("staging/bat.download");
|
||||
let bytes = b"\x7fELFAppImage";
|
||||
let mut reader = Cursor::new(bytes.as_slice());
|
||||
let mut events = Vec::new();
|
||||
let mut reporter = |event: &OperationEvent| events.push(event.clone());
|
||||
|
||||
let written = stream_payload_to_staged_file_with_reporter(
|
||||
&mut reader,
|
||||
Some(bytes.len() as u64),
|
||||
&staged_path,
|
||||
&mut reporter,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(written, bytes.len() as u64);
|
||||
assert_eq!(
|
||||
fs::metadata(&staged_path).unwrap().len(),
|
||||
bytes.len() as u64
|
||||
);
|
||||
assert!(events.iter().any(|event| {
|
||||
matches!(
|
||||
event,
|
||||
OperationEvent::Progress {
|
||||
current,
|
||||
total: Some(total)
|
||||
} if *current == bytes.len() as u64 && *total == bytes.len() as u64
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_commits_from_staged_payload_path() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = root.path().join("staging/bat.download");
|
||||
let final_payload_path = root.path().join("payloads/bat.AppImage");
|
||||
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
|
||||
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
trusted_checksum: None,
|
||||
desktop: None,
|
||||
helpers: DesktopHelpers::default(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.final_payload_path, final_payload_path);
|
||||
assert!(outcome.final_payload_path.exists());
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_streaming_download_removes_partial_staged_payload() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = root.path().join("staging/bat.download");
|
||||
let mut reader = FailingReader::new(b"\x7fELFpartial".to_vec(), 4);
|
||||
let mut reporter = NoopReporter;
|
||||
|
||||
let result = stream_payload_to_staged_file_with_reporter(
|
||||
&mut reader,
|
||||
Some(12),
|
||||
&staged_path,
|
||||
&mut reporter,
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_policy_retries_transient_failures_before_success() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = root.path().join("staging/bat.download");
|
||||
let bytes = b"\x7fELFAppImage";
|
||||
let mut attempts = 0;
|
||||
|
||||
let written = download_to_staged_path_with_retries(
|
||||
&staged_path,
|
||||
&mut NoopReporter,
|
||||
HttpClientPolicy {
|
||||
timeout: Duration::from_secs(30),
|
||||
max_retries: 3,
|
||||
},
|
||||
|| {
|
||||
attempts += 1;
|
||||
if attempts == 1 {
|
||||
return Err(InstallAppError::DownloadIo(io::Error::other(
|
||||
"transient failure",
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((
|
||||
Box::new(Cursor::new(bytes.to_vec())) as Box<dyn Read>,
|
||||
Some(bytes.len() as u64),
|
||||
))
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(attempts, 2);
|
||||
assert_eq!(written, bytes.len() as u64);
|
||||
assert!(staged_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_exhaustion_returns_error_and_cleans_staged_payload() {
|
||||
let root = tempdir().unwrap();
|
||||
let staged_path = root.path().join("staging/bat.download");
|
||||
let mut attempts = 0;
|
||||
|
||||
let result = download_to_staged_path_with_retries(
|
||||
&staged_path,
|
||||
&mut NoopReporter,
|
||||
HttpClientPolicy {
|
||||
timeout: Duration::from_secs(30),
|
||||
max_retries: 2,
|
||||
},
|
||||
|| {
|
||||
attempts += 1;
|
||||
Ok((
|
||||
Box::new(FailingReader::new(b"\x7fELFpartial".to_vec(), 4)) as Box<dyn Read>,
|
||||
Some(12),
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(attempts, 2);
|
||||
assert!(!staged_path.exists());
|
||||
}
|
||||
|
||||
struct FailingReader {
|
||||
bytes: Vec<u8>,
|
||||
chunk_size: usize,
|
||||
position: usize,
|
||||
}
|
||||
|
||||
impl FailingReader {
|
||||
fn new(bytes: Vec<u8>, chunk_size: usize) -> Self {
|
||||
Self {
|
||||
bytes,
|
||||
chunk_size,
|
||||
position: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for FailingReader {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
if self.position >= self.chunk_size {
|
||||
return Err(io::Error::other("fixture read failure"));
|
||||
}
|
||||
|
||||
let remaining = self.chunk_size - self.position;
|
||||
let to_read = remaining
|
||||
.min(buffer.len())
|
||||
.min(self.bytes.len() - self.position);
|
||||
buffer[..to_read].copy_from_slice(&self.bytes[self.position..self.position + to_read]);
|
||||
self.position += to_read;
|
||||
Ok(to_read)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
use aim_core::app::query::resolve_query;
|
||||
use aim_core::source::github::{FixtureGitHubTransport, discover_github_candidates_with};
|
||||
use aim_core::source::github::{
|
||||
FixtureGitHubTransport, discover_github_candidates_with, http_client_policy,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn discovery_reports_appimage_assets_and_latest_linux_yml() {
|
||||
|
|
@ -31,3 +34,11 @@ fn discovery_marks_explicit_older_release_against_latest_fixture_release() {
|
|||
assert_eq!(discovery.releases[0].tag, "v0.0.12");
|
||||
assert!(discovery.requested_is_older_release);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_http_policy_uses_explicit_timeout_and_retry_defaults() {
|
||||
let policy = http_client_policy();
|
||||
|
||||
assert_eq!(policy.timeout, Duration::from_secs(30));
|
||||
assert_eq!(policy.max_retries, 3);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,15 @@ fn integration_failure_removes_new_payload_and_generated_files() {
|
|||
fs::create_dir(&staging_root).unwrap();
|
||||
fs::create_dir(&payload_root).unwrap();
|
||||
fs::write(&blocking_path, "blocker").unwrap();
|
||||
let staged_path = staging_root.join("bat.download");
|
||||
fs::write(&staged_path, b"\x7fELFAppImage").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,
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &final_payload_path,
|
||||
artifact_bytes: b"\x7fELFAppImage",
|
||||
trusted_checksum: None,
|
||||
desktop: Some(DesktopIntegrationRequest {
|
||||
desktop_entry_path: &desktop_entry_path,
|
||||
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
|
||||
|
|
|
|||
|
|
@ -8,21 +8,27 @@ use std::fs;
|
|||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn write_staged_payload(root: &std::path::Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
|
||||
let staged_path = root.join("staging").join(format!("{name}.download"));
|
||||
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();
|
||||
fs::write(&staged_path, bytes).unwrap();
|
||||
staged_path
|
||||
}
|
||||
|
||||
#[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 staged_path = write_staged_payload(root.path(), "bat", b"\x7fELFAppImage");
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staging_root: &staging_root,
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &payload_root.join("bat.AppImage"),
|
||||
artifact_bytes: b"\x7fELFAppImage",
|
||||
trusted_checksum: None,
|
||||
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",
|
||||
|
|
@ -40,16 +46,19 @@ fn install_writes_desktop_entry_and_reports_refresh_warning_only() {
|
|||
#[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 staged_path = write_staged_payload(
|
||||
root.path(),
|
||||
"bat",
|
||||
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
|
||||
);
|
||||
|
||||
let update_helper = helper_root.join("update-desktop-database");
|
||||
let icon_helper = helper_root.join("gtk-update-icon-cache");
|
||||
|
|
@ -70,9 +79,9 @@ fn install_executes_refresh_helpers_when_available() {
|
|||
fs::create_dir_all(&icon_root).unwrap();
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staging_root: &staging_root,
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &payload_root.join("bat.AppImage"),
|
||||
artifact_bytes: b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
|
||||
trusted_checksum: None,
|
||||
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",
|
||||
|
|
@ -97,20 +106,23 @@ fn install_executes_refresh_helpers_when_available() {
|
|||
#[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 staged_path = write_staged_payload(
|
||||
root.path(),
|
||||
"bat",
|
||||
b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
|
||||
);
|
||||
|
||||
let outcome = execute_install(&InstallRequest {
|
||||
staging_root: &staging_root,
|
||||
staged_payload_path: &staged_path,
|
||||
final_payload_path: &payload_root.join("bat.AppImage"),
|
||||
artifact_bytes: b"\x7fELFAppImage\x89PNG\r\n\x1a\nicondataIEND\xaeB`\x82",
|
||||
trusted_checksum: None,
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ fn payload_commit_moves_staged_appimage_into_final_location() {
|
|||
fs::create_dir(&staging_root).unwrap();
|
||||
fs::create_dir(&payload_root).unwrap();
|
||||
|
||||
let staged_path = staging_root.join("bat.download");
|
||||
fs::write(&staged_path, b"\x7fELFAppImage").unwrap();
|
||||
let final_payload_path = payload_root.join("bat.AppImage");
|
||||
let outcome =
|
||||
stage_and_commit_payload(&staging_root, &final_payload_path, b"\x7fELFAppImage").unwrap();
|
||||
let outcome = stage_and_commit_payload(&staged_path, &final_payload_path).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
outcome
|
||||
|
|
|
|||
|
|
@ -101,3 +101,84 @@ fn registry_round_trips_install_metadata() {
|
|||
Some("/tmp/install-home/.local/share/icons/hicolor/256x256/apps/t3code.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_save_is_atomic_and_cleans_up_temp_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
let store = RegistryStore::new(registry_path.clone());
|
||||
|
||||
store
|
||||
.save(&aim_core::registry::model::Registry {
|
||||
version: 1,
|
||||
apps: vec![aim_core::domain::app::AppRecord {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(registry_path.exists());
|
||||
assert!(!dir.path().join("registry.toml.tmp").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_exclusive_lock_rejects_second_mutator() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||
let _guard = store.lock_exclusive().unwrap();
|
||||
|
||||
let error = store.lock_exclusive().unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
aim_core::registry::store::RegistryStoreError::LockUnavailable
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_mutate_exclusive_reloads_and_writes_latest_state() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = RegistryStore::new(dir.path().join("registry.toml"));
|
||||
store
|
||||
.save(&aim_core::registry::model::Registry {
|
||||
version: 1,
|
||||
apps: vec![aim_core::domain::app::AppRecord {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}],
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.mutate_exclusive(|registry| {
|
||||
registry.apps.push(aim_core::domain::app::AppRecord {
|
||||
stable_id: "t3code".to_owned(),
|
||||
display_name: "T3 Code".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: None,
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let loaded = store.load().unwrap();
|
||||
assert_eq!(loaded.apps.len(), 2);
|
||||
assert_eq!(loaded.apps[0].stable_id, "bat");
|
||||
assert_eq!(loaded.apps[1].stable_id, "t3code");
|
||||
}
|
||||
|
|
|
|||
212
crates/aim-core/tests/search_github.rs
Normal file
212
crates/aim-core/tests/search_github.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
use aim_core::app::search::{
|
||||
GitHubSearchProvider, SearchProvider, SearchProviderError, build_search_results_with,
|
||||
};
|
||||
use aim_core::domain::app::AppRecord;
|
||||
use aim_core::domain::search::{SearchInstallStatus, SearchQuery};
|
||||
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
|
||||
use aim_core::source::github::{FixtureGitHubTransport, search_github_repositories_with};
|
||||
|
||||
#[test]
|
||||
fn github_fixtures_return_normalized_remote_hits() {
|
||||
let query = SearchQuery::new("bat");
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
|
||||
|
||||
assert_eq!(query.remote_limit, 10);
|
||||
assert!(results.installed_matches.is_empty());
|
||||
assert!(results.warnings.is_empty());
|
||||
assert_eq!(results.remote_hits.len(), 3);
|
||||
|
||||
let first = &results.remote_hits[0];
|
||||
assert_eq!(first.provider_id, "github");
|
||||
assert_eq!(first.display_name, "sharkdp/bat");
|
||||
assert_eq!(
|
||||
first.description.as_deref(),
|
||||
Some("A cat(1) clone with wings.")
|
||||
);
|
||||
assert_eq!(first.source_locator, "https://github.com/sharkdp/bat");
|
||||
assert_eq!(first.install_query, "sharkdp/bat");
|
||||
assert_eq!(first.canonical_locator, "sharkdp/bat");
|
||||
assert_eq!(first.version.as_deref(), Some("1.0.0"));
|
||||
assert_eq!(first.install_status, SearchInstallStatus::Available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_respects_limit_and_fixture_order() {
|
||||
let query = SearchQuery::with_remote_limit("bat", 2);
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
|
||||
|
||||
let locators = results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.map(|hit| hit.canonical_locator.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(locators, vec!["sharkdp/bat", "astatine/bat"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_ranks_full_name_matches_above_description_only_matches() {
|
||||
let query = SearchQuery::new("pingdotgg");
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
|
||||
|
||||
let locators = results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.map(|hit| hit.canonical_locator.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(locators[0], "pingdotgg/t3code");
|
||||
assert_eq!(locators, vec!["pingdotgg/t3code"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_backfills_description_matches_after_name_matches() {
|
||||
let query = SearchQuery::with_remote_limit("pingdotgg", 3);
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
|
||||
|
||||
let locators = results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.map(|hit| hit.canonical_locator.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(locators, vec!["pingdotgg/t3code"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_only_returns_repositories_with_appimage_release_assets() {
|
||||
let query = SearchQuery::new("pingdotgg");
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &[], &[&provider]).unwrap();
|
||||
|
||||
assert!(
|
||||
results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.all(|hit| hit.canonical_locator == "pingdotgg/t3code")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_name_only_search_excludes_description_only_matches() {
|
||||
let hits =
|
||||
search_github_repositories_with("pingdotgg in:name", 10, &FixtureGitHubTransport).unwrap();
|
||||
|
||||
let locators = hits
|
||||
.iter()
|
||||
.map(|hit| hit.full_name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(locators, vec!["pingdotgg/t3code"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_search_results_can_carry_local_matches_and_warnings() {
|
||||
let query = SearchQuery::new("bat");
|
||||
let installed = vec![AppRecord {
|
||||
stable_id: "bat".to_owned(),
|
||||
display_name: "Bat".to_owned(),
|
||||
source_input: None,
|
||||
source: None,
|
||||
installed_version: Some("1.0.0".to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}];
|
||||
let provider = FailingProvider;
|
||||
|
||||
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
|
||||
|
||||
assert!(results.remote_hits.is_empty());
|
||||
assert_eq!(results.installed_matches.len(), 1);
|
||||
assert_eq!(results.installed_matches[0].stable_id, "bat");
|
||||
assert_eq!(results.installed_matches[0].display_name, "Bat");
|
||||
assert_eq!(results.warnings.len(), 1);
|
||||
assert_eq!(results.warnings[0].provider_id.as_deref(), Some("github"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_marks_matching_current_install_as_installed() {
|
||||
let query = SearchQuery::new("bat");
|
||||
let installed = vec![installed_github_app("sharkdp/bat", "1.0.0")];
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
|
||||
let bat = results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.find(|hit| hit.install_query == "sharkdp/bat")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
bat.install_status,
|
||||
SearchInstallStatus::Installed {
|
||||
installed_version: Some("1.0.0".to_owned()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_search_marks_older_install_as_update_available() {
|
||||
let query = SearchQuery::new("pingdotgg");
|
||||
let installed = vec![installed_github_app("pingdotgg/t3code", "0.0.11")];
|
||||
let provider = GitHubSearchProvider::new(&FixtureGitHubTransport);
|
||||
|
||||
let results = build_search_results_with(&query, &installed, &[&provider]).unwrap();
|
||||
let t3code = results
|
||||
.remote_hits
|
||||
.iter()
|
||||
.find(|hit| hit.install_query == "pingdotgg/t3code")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(t3code.version.as_deref(), Some("0.0.12"));
|
||||
assert_eq!(
|
||||
t3code.install_status,
|
||||
SearchInstallStatus::UpdateAvailable {
|
||||
installed_version: Some("0.0.11".to_owned()),
|
||||
latest_version: Some("0.0.12".to_owned()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn installed_github_app(locator: &str, installed_version: &str) -> AppRecord {
|
||||
AppRecord {
|
||||
stable_id: locator.replace('/', "-"),
|
||||
display_name: locator.split('/').next_back().unwrap().to_owned(),
|
||||
source_input: Some(locator.to_owned()),
|
||||
source: Some(SourceRef {
|
||||
kind: SourceKind::GitHub,
|
||||
locator: locator.to_owned(),
|
||||
input_kind: SourceInputKind::RepoShorthand,
|
||||
normalized_kind: NormalizedSourceKind::GitHubRepository,
|
||||
canonical_locator: Some(locator.to_owned()),
|
||||
requested_tag: None,
|
||||
requested_asset_name: None,
|
||||
tracks_latest: true,
|
||||
}),
|
||||
installed_version: Some(installed_version.to_owned()),
|
||||
update_strategy: None,
|
||||
metadata: Vec::new(),
|
||||
install: None,
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingProvider;
|
||||
|
||||
impl SearchProvider for FailingProvider {
|
||||
fn search(
|
||||
&self,
|
||||
_query: &SearchQuery,
|
||||
) -> Result<Vec<aim_core::domain::search::SearchResult>, SearchProviderError> {
|
||||
Err(SearchProviderError::new("github", "fixture rate limit"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue