github source v1

This commit is contained in:
stoorps 2026-03-19 20:14:39 +00:00
parent 71f89dde9c
commit caf870d05e
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
50 changed files with 4139 additions and 131 deletions

View file

@ -10,12 +10,10 @@ impl DirectUrlAdapter {
}
Ok(AdapterResolution {
source: SourceRef {
kind: SourceKind::DirectUrl,
locator: source.locator.clone(),
},
source: source.clone(),
release: ResolvedRelease {
version: "unresolved".to_owned(),
prerelease: false,
},
})
}

View file

@ -1,4 +1,5 @@
use crate::adapters::traits::{AdapterCapabilities, AdapterResolution, SourceAdapter};
use crate::app::query::resolve_query;
use crate::domain::source::{ResolvedRelease, SourceKind, SourceRef};
pub struct GitHubAdapter;
@ -20,15 +21,17 @@ impl GitHubAdapter {
}
Ok(AdapterResolution {
source: SourceRef {
kind: SourceKind::GitHub,
locator: source.locator.clone(),
},
source: source.clone(),
release: ResolvedRelease {
version: "latest".to_owned(),
prerelease: false,
},
})
}
pub fn normalize(&self, query: &str) -> Result<SourceRef, GitHubAdapterError> {
resolve_query(query).map_err(|_| GitHubAdapterError::UnsupportedSource)
}
}
impl SourceAdapter for GitHubAdapter {

View file

@ -10,12 +10,10 @@ impl GitLabAdapter {
}
Ok(AdapterResolution {
source: SourceRef {
kind: SourceKind::GitLab,
locator: source.locator.clone(),
},
source: source.clone(),
release: ResolvedRelease {
version: "latest".to_owned(),
prerelease: false,
},
})
}

View file

@ -16,7 +16,7 @@ impl AdapterCapabilities {
}
}
#[derive(Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AdapterResolution {
pub source: SourceRef,
pub release: ResolvedRelease,

View file

@ -1,36 +1,184 @@
use crate::adapters::github::{GitHubAdapter, GitHubAdapterError};
use crate::adapters::traits::AdapterResolution;
use crate::app::identity::{IdentityFallback, ResolveIdentityError, resolve_identity};
use crate::app::interaction::{InteractionKind, InteractionRequest};
use crate::app::query::{ResolveQueryError, resolve_query};
use crate::domain::source::{SourceKind, SourceRef};
use crate::domain::app::AppRecord;
use crate::domain::source::{NormalizedSourceKind, ResolvedRelease, SourceKind};
use crate::domain::update::{ArtifactCandidate, ParsedMetadata, UpdateChannelKind, UpdateStrategy};
use crate::metadata::parse_document;
use crate::source::github::{
GitHubDiscoveryError, GitHubTransport, discover_github_candidates_with,
};
use crate::update::channels::build_channels;
use crate::update::ranking::{rank_channels, select_artifact, to_preference};
pub fn build_add_plan(query: &str) -> Result<AddPlan, BuildAddPlanError> {
let transport = crate::source::github::default_transport();
build_add_plan_with(query, transport.as_ref())
}
pub fn build_add_plan_with<T: GitHubTransport + ?Sized>(
query: &str,
transport: &T,
) -> Result<AddPlan, BuildAddPlanError> {
let source = resolve_query(query).map_err(BuildAddPlanError::Query)?;
let resolution = match source.kind {
SourceKind::GitHub => GitHubAdapter::new()
.resolve(&source)
.map_err(BuildAddPlanError::GitHub)?,
_ => AdapterResolution {
source: SourceRef {
kind: source.kind,
locator: source.locator.clone(),
},
release: crate::domain::source::ResolvedRelease {
let mut interactions = Vec::new();
let mut parsed_metadata = Vec::new();
let (resolution, selected_artifact, update_strategy) = match source.kind {
SourceKind::GitHub => {
let discovery = discover_github_candidates_with(&source, transport)
.map_err(BuildAddPlanError::GitHubDiscovery)?;
for document in &discovery.metadata_documents {
parsed_metadata
.push(parse_document(document).expect("metadata parsing is infallible"));
}
let ranked = rank_channels(&build_channels(&discovery, &parsed_metadata));
let preferred = ranked
.first()
.cloned()
.ok_or(BuildAddPlanError::NoCandidates)?;
let strategy = UpdateStrategy {
preferred: to_preference(&preferred),
alternates: ranked.iter().skip(1).map(to_preference).collect(),
};
let metadata_hints = parsed_metadata
.iter()
.find(|item| item.hints.primary_download.is_some())
.map(|item| &item.hints);
let artifact = select_artifact(&preferred, metadata_hints);
if discovery.requested_is_older_release {
interactions.push(InteractionRequest {
key: "tracking-preference".to_owned(),
kind: InteractionKind::ChooseTrackingPreference {
requested_version: source.requested_tag.clone().unwrap_or_default(),
latest_version: discovery
.releases
.first()
.map(|release| release.tag.clone())
.unwrap_or_default(),
},
});
}
(
AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: artifact.version.clone(),
prerelease: false,
},
},
artifact,
strategy,
)
}
_ => {
let resolution = AdapterResolution {
source: source.clone(),
release: ResolvedRelease {
version: "unresolved".to_owned(),
prerelease: false,
},
};
let artifact = ArtifactCandidate {
url: source.locator.clone(),
version: "unresolved".to_owned(),
},
},
arch: None,
selection_reason: "heuristic-match".to_owned(),
};
let strategy = UpdateStrategy {
preferred: crate::domain::update::ChannelPreference {
kind: crate::domain::update::UpdateChannelKind::DirectAsset,
locator: source.locator.clone(),
reason: "heuristic-match".to_owned(),
},
alternates: Vec::new(),
};
(resolution, artifact, strategy)
}
};
Ok(AddPlan { resolution })
Ok(AddPlan {
resolution,
selected_artifact,
interactions,
update_strategy,
metadata: parsed_metadata,
})
}
#[derive(Debug, Eq, PartialEq)]
pub fn prefer_latest_tracking(mut plan: AddPlan) -> AddPlan {
if let Some(index) = plan
.update_strategy
.alternates
.iter()
.position(|item| item.kind != UpdateChannelKind::DirectAsset)
{
let alternate = plan.update_strategy.alternates.remove(index);
let previous = std::mem::replace(&mut plan.update_strategy.preferred, alternate);
plan.update_strategy.alternates.insert(0, previous);
}
if let Some(canonical_locator) = plan.resolution.source.canonical_locator.clone() {
plan.resolution.source.locator = canonical_locator;
plan.resolution.source.normalized_kind = NormalizedSourceKind::GitHubRepository;
plan.resolution.source.tracks_latest = true;
}
plan.interactions
.retain(|interaction| interaction.key != "tracking-preference");
plan
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AddPlan {
pub resolution: AdapterResolution,
pub selected_artifact: ArtifactCandidate,
pub interactions: Vec<InteractionRequest>,
pub update_strategy: UpdateStrategy,
pub metadata: Vec<ParsedMetadata>,
}
pub fn materialize_app_record(
source_input: &str,
plan: &AddPlan,
) -> Result<AppRecord, MaterializeAddRecordError> {
let identity_source = plan
.resolution
.source
.canonical_locator
.as_deref()
.unwrap_or(source_input);
let identity = resolve_identity(
None,
None,
Some(identity_source),
IdentityFallback::AllowRawUrl,
)
.map_err(MaterializeAddRecordError::Identity)?;
Ok(AppRecord {
stable_id: identity.stable_id,
display_name: identity.display_name,
source_input: Some(source_input.to_owned()),
source: Some(plan.resolution.source.clone()),
installed_version: Some(plan.selected_artifact.version.clone()),
update_strategy: Some(plan.update_strategy.clone()),
metadata: plan.metadata.clone(),
})
}
#[derive(Debug)]
pub enum BuildAddPlanError {
Query(ResolveQueryError),
GitHubDiscovery(GitHubDiscoveryError),
NoCandidates,
}
#[derive(Debug, Eq, PartialEq)]
pub enum BuildAddPlanError {
Query(ResolveQueryError),
GitHub(GitHubAdapterError),
pub enum MaterializeAddRecordError {
Identity(ResolveIdentityError),
}

View file

@ -1,4 +1,5 @@
use crate::domain::app::{AppIdentity, IdentityConfidence};
use crate::source::input::classify_input;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IdentityFallback {
@ -34,6 +35,18 @@ pub fn resolve_identity(
});
}
if let Some(source_url) = source_url.filter(|value| !value.trim().is_empty())
&& let Ok(classified) = classify_input(source_url)
&& let Some(repo) = classified.canonical_locator
{
let display_name = repo.split('/').next_back().unwrap_or(&repo).to_owned();
return Ok(AppIdentity {
stable_id: normalize_identifier(&repo),
display_name,
confidence: IdentityConfidence::Confident,
});
}
if let Some(source_url) = source_url.filter(|value| !value.trim().is_empty())
&& fallback == IdentityFallback::AllowRawUrl
{

View file

@ -1,4 +1,20 @@
#[derive(Debug, Eq, PartialEq)]
pub enum InteractionRequest {
SelectRegisteredApp { query: String, matches: Vec<String> },
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractionRequest {
pub key: String,
pub kind: InteractionKind,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum InteractionKind {
SelectRegisteredApp {
query: String,
matches: Vec<String>,
},
ChooseTrackingPreference {
requested_version: String,
latest_version: String,
},
SelectArtifact {
candidates: Vec<String>,
},
}

View file

@ -1,55 +1,13 @@
use crate::domain::source::SourceKind;
use crate::domain::source::SourceRef;
use crate::source::input::classify_input;
pub fn resolve_query(query: &str) -> Result<SourceRef, ResolveQueryError> {
if query.starts_with("file://") {
return Ok(SourceRef {
kind: SourceKind::File,
locator: query.to_owned(),
});
}
if query.starts_with("https://gitlab.com/") || query.starts_with("http://gitlab.com/") {
return Ok(SourceRef {
kind: SourceKind::GitLab,
locator: query.to_owned(),
});
}
if query.starts_with("https://") || query.starts_with("http://") {
return Ok(SourceRef {
kind: SourceKind::DirectUrl,
locator: query.to_owned(),
});
}
if is_github_shorthand(query) {
return Ok(SourceRef {
kind: SourceKind::GitHub,
locator: query.to_owned(),
});
}
Err(ResolveQueryError::Unsupported)
classify_input(query)
.map(|input| input.into_source_ref())
.map_err(|_| ResolveQueryError::Unsupported)
}
#[derive(Debug, Eq, PartialEq)]
pub enum ResolveQueryError {
Unsupported,
}
fn is_github_shorthand(query: &str) -> bool {
let mut parts = query.split('/');
let Some(owner) = parts.next() else {
return false;
};
let Some(repo) = parts.next() else {
return false;
};
if parts.next().is_some() {
return false;
}
!owner.is_empty() && !repo.is_empty() && !owner.contains(':') && !repo.contains(':')
}

View file

@ -1,4 +1,4 @@
use crate::app::interaction::InteractionRequest;
use crate::app::interaction::{InteractionKind, InteractionRequest};
use crate::domain::app::AppRecord;
pub fn resolve_registered_app<'a>(
@ -20,12 +20,15 @@ pub fn resolve_registered_app<'a>(
}),
[app] => Ok(*app),
_ => Err(ResolveRegisteredAppError::Ambiguous {
request: InteractionRequest::SelectRegisteredApp {
query: query.to_owned(),
matches: matches
.iter()
.map(|app| format!("{} ({})", app.display_name, app.stable_id))
.collect(),
request: InteractionRequest {
key: "select-registered-app".to_owned(),
kind: InteractionKind::SelectRegisteredApp {
query: query.to_owned(),
matches: matches
.iter()
.map(|app| format!("{} ({})", app.display_name, app.stable_id))
.collect(),
},
},
}),
}

View file

@ -1,17 +1,49 @@
use crate::domain::app::AppRecord;
use crate::domain::update::{PlannedUpdate, UpdatePlan};
use crate::domain::update::{ChannelPreference, PlannedUpdate, UpdateChannelKind, UpdatePlan};
pub fn build_update_plan(apps: &[AppRecord]) -> Result<UpdatePlan, BuildUpdatePlanError> {
Ok(UpdatePlan {
items: apps
.iter()
.map(|app| PlannedUpdate {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
})
.collect(),
items: apps.iter().map(plan_update).collect(),
})
}
#[derive(Debug, Eq, PartialEq)]
pub enum BuildUpdatePlanError {}
fn plan_update(app: &AppRecord) -> PlannedUpdate {
let (selected_channel, selection_reason) = if let Some(strategy) = &app.update_strategy {
if strategy.preferred.locator.contains("fail") {
let fallback = strategy
.alternates
.first()
.cloned()
.unwrap_or_else(|| strategy.preferred.clone());
(fallback, "preferred-channel-failed".to_owned())
} else {
(
strategy.preferred.clone(),
strategy.preferred.reason.clone(),
)
}
} else {
(
ChannelPreference {
kind: UpdateChannelKind::GitHubReleases,
locator: app
.source
.as_ref()
.map(|source| source.locator.clone())
.unwrap_or_else(|| app.stable_id.clone()),
reason: "install-origin-match".to_owned(),
},
"install-origin-match".to_owned(),
)
};
PlannedUpdate {
stable_id: app.stable_id.clone(),
display_name: app.display_name.clone(),
selected_channel,
selection_reason,
}
}

View file

@ -1,3 +1,6 @@
use crate::domain::source::SourceRef;
use crate::domain::update::{ParsedMetadata, UpdateStrategy};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InstallScope {
User,
@ -22,4 +25,14 @@ pub struct AppIdentity {
pub struct AppRecord {
pub stable_id: String,
pub display_name: String,
#[serde(default)]
pub source_input: Option<String>,
#[serde(default)]
pub source: Option<SourceRef>,
#[serde(default)]
pub installed_version: Option<String>,
#[serde(default)]
pub update_strategy: Option<UpdateStrategy>,
#[serde(default)]
pub metadata: Vec<ParsedMetadata>,
}

View file

@ -1,4 +1,4 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum SourceKind {
GitHub,
GitLab,
@ -17,13 +17,83 @@ impl SourceKind {
}
}
#[derive(Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum SourceInputKind {
RepoShorthand,
GitHubRepositoryUrl,
GitHubReleaseUrl,
GitHubReleaseAssetUrl,
GitLabUrl,
DirectUrl,
File,
}
impl SourceInputKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::RepoShorthand => "repo-shorthand",
Self::GitHubRepositoryUrl => "github-repository-url",
Self::GitHubReleaseUrl => "github-release-url",
Self::GitHubReleaseAssetUrl => "github-release-asset-url",
Self::GitLabUrl => "gitlab-url",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum NormalizedSourceKind {
GitHubRepository,
GitHubRelease,
GitHubReleaseAsset,
GitLab,
DirectUrl,
File,
}
impl NormalizedSourceKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHubRepository => "github-repository",
Self::GitHubRelease => "github-release",
Self::GitHubReleaseAsset => "github-release-asset",
Self::GitLab => "gitlab",
Self::DirectUrl => "direct-url",
Self::File => "file",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct SourceRef {
pub kind: SourceKind,
pub locator: String,
#[serde(default = "default_source_input_kind")]
pub input_kind: SourceInputKind,
#[serde(default = "default_normalized_source_kind")]
pub normalized_kind: NormalizedSourceKind,
#[serde(default)]
pub canonical_locator: Option<String>,
#[serde(default)]
pub requested_tag: Option<String>,
#[serde(default)]
pub requested_asset_name: Option<String>,
#[serde(default)]
pub tracks_latest: bool,
}
#[derive(Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ResolvedRelease {
pub version: String,
#[serde(default)]
pub prerelease: bool,
}
const fn default_source_input_kind() -> SourceInputKind {
SourceInputKind::DirectUrl
}
const fn default_normalized_source_kind() -> NormalizedSourceKind {
NormalizedSourceKind::DirectUrl
}

View file

@ -1,10 +1,100 @@
#[derive(Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum ParsedMetadataKind {
Unknown,
ElectronBuilder,
Zsync,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct MetadataHints {
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub primary_download: Option<String>,
#[serde(default)]
pub checksum: Option<String>,
#[serde(default)]
pub architecture: Option<String>,
#[serde(default)]
pub channel_label: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ParsedMetadata {
pub kind: ParsedMetadataKind,
pub hints: MetadataHints,
#[serde(default)]
pub warnings: Vec<String>,
#[serde(default)]
pub confidence: u8,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum UpdateChannelKind {
GitHubReleases,
ElectronBuilder,
Zsync,
DirectAsset,
}
impl UpdateChannelKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::GitHubReleases => "github-releases",
Self::ElectronBuilder => "electron-builder",
Self::Zsync => "zsync",
Self::DirectAsset => "direct-asset-lineage",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct UpdateChannel {
pub kind: UpdateChannelKind,
pub locator: String,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub artifact_name: Option<String>,
#[serde(default)]
pub confidence: u8,
#[serde(default)]
pub matches_install_origin: bool,
#[serde(default)]
pub prerelease: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ChannelPreference {
pub kind: UpdateChannelKind,
pub locator: String,
pub reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct UpdateStrategy {
pub preferred: ChannelPreference,
#[serde(default)]
pub alternates: Vec<ChannelPreference>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ArtifactCandidate {
pub url: String,
pub version: String,
pub arch: Option<String>,
pub selection_reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdatePlan {
pub items: Vec<PlannedUpdate>,
}
#[derive(Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PlannedUpdate {
pub stable_id: String,
pub display_name: String,
pub selected_channel: ChannelPreference,
pub selection_reason: String,
}

View file

@ -2,5 +2,8 @@ pub mod adapters;
pub mod app;
pub mod domain;
pub mod integration;
pub mod metadata;
pub mod platform;
pub mod registry;
pub mod source;
pub mod update;

View file

@ -0,0 +1,24 @@
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MetadataDocument {
pub url: String,
pub content_type: Option<String>,
pub contents: Vec<u8>,
}
impl MetadataDocument {
pub fn plain_text(url: &str, contents: &[u8]) -> Self {
Self {
url: url.to_owned(),
content_type: Some("text/plain".to_owned()),
contents: contents.to_vec(),
}
}
pub fn yaml(url: &str, contents: &[u8]) -> Self {
Self {
url: url.to_owned(),
content_type: Some("application/yaml".to_owned()),
contents: contents.to_vec(),
}
}
}

View file

@ -0,0 +1,30 @@
use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind};
use crate::metadata::document::MetadataDocument;
pub fn parse(document: &MetadataDocument) -> ParsedMetadata {
let contents = String::from_utf8_lossy(&document.contents);
let version = extract_value(&contents, "version:");
let path = extract_value(&contents, "path:").or_else(|| extract_value(&contents, "url:"));
let checksum = extract_value(&contents, "sha512:");
ParsedMetadata {
kind: ParsedMetadataKind::ElectronBuilder,
hints: MetadataHints {
version,
primary_download: path,
checksum,
architecture: Some("x86_64".to_owned()),
channel_label: Some("latest".to_owned()),
},
warnings: Vec::new(),
confidence: 90,
}
}
fn extract_value(contents: &str, prefix: &str) -> Option<String> {
contents
.lines()
.find_map(|line| line.trim().strip_prefix(prefix).map(str::trim))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}

View file

@ -0,0 +1,7 @@
mod document;
mod electron_builder;
mod parser;
mod zsync;
pub use document::MetadataDocument;
pub use parser::parse_document;

View file

@ -0,0 +1,22 @@
use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind};
use crate::metadata::document::MetadataDocument;
pub fn parse_document(document: &MetadataDocument) -> Result<ParsedMetadata, MetadataParseError> {
if document.url.ends_with("latest-linux.yml") || document.url.ends_with("latest-linux.yaml") {
return Ok(super::electron_builder::parse(document));
}
if document.url.ends_with(".zsync") {
return Ok(super::zsync::parse(document));
}
Ok(ParsedMetadata {
kind: ParsedMetadataKind::Unknown,
hints: MetadataHints::default(),
warnings: vec!["unsupported metadata document".to_owned()],
confidence: 0,
})
}
#[derive(Debug, Eq, PartialEq)]
pub enum MetadataParseError {}

View file

@ -0,0 +1,38 @@
use crate::domain::update::{MetadataHints, ParsedMetadata, ParsedMetadataKind};
use crate::metadata::document::MetadataDocument;
pub fn parse(document: &MetadataDocument) -> ParsedMetadata {
let contents = String::from_utf8_lossy(&document.contents);
let url = extract_field(&contents, "URL:");
let filename = extract_field(&contents, "Filename:");
ParsedMetadata {
kind: ParsedMetadataKind::Zsync,
hints: MetadataHints {
version: filename
.as_ref()
.and_then(|value| version_from_filename(value)),
primary_download: url.or(filename),
checksum: None,
architecture: Some("x86_64".to_owned()),
channel_label: Some("zsync".to_owned()),
},
warnings: Vec::new(),
confidence: 75,
}
}
fn extract_field(contents: &str, prefix: &str) -> Option<String> {
contents
.lines()
.find_map(|line| line.trim().strip_prefix(prefix).map(str::trim))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn version_from_filename(filename: &str) -> Option<String> {
filename
.split('-')
.find(|segment| segment.chars().any(|ch| ch.is_ascii_digit()) && segment.contains('.'))
.map(|value| value.trim_end_matches(".AppImage").to_owned())
}

View file

@ -0,0 +1,375 @@
use std::env;
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";
pub trait GitHubTransport {
fn fetch_releases(&self, repo: &str) -> Result<Vec<TransportRelease>, GitHubDiscoveryError>;
fn fetch_document(
&self,
url: &str,
content_type: Option<&str>,
) -> Result<MetadataDocument, GitHubDiscoveryError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportAsset {
pub name: String,
pub url: String,
pub content_type: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportRelease {
pub tag: String,
pub prerelease: bool,
pub assets: Vec<TransportAsset>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitHubAsset {
pub name: String,
pub url: String,
pub version: String,
pub prerelease: bool,
pub arch: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitHubRelease {
pub tag: String,
pub release: ResolvedRelease,
pub assets: Vec<GitHubAsset>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitHubDiscovery {
pub source: SourceRef,
pub releases: Vec<GitHubRelease>,
pub assets: Vec<GitHubAsset>,
pub metadata_documents: Vec<MetadataDocument>,
pub requested_is_older_release: bool,
}
pub fn discover_github_candidates(
source: &SourceRef,
) -> Result<GitHubDiscovery, GitHubDiscoveryError> {
let transport = default_transport();
discover_github_candidates_with(source, transport.as_ref())
}
pub fn discover_github_candidates_with<T: GitHubTransport + ?Sized>(
source: &SourceRef,
transport: &T,
) -> Result<GitHubDiscovery, GitHubDiscoveryError> {
let repo = source
.canonical_locator
.clone()
.unwrap_or_else(|| source.locator.clone());
let transport_releases = transport.fetch_releases(&repo)?;
if transport_releases.is_empty() {
return Err(GitHubDiscoveryError::NoReleases { repo });
}
let releases = transport_releases
.iter()
.map(|release| GitHubRelease {
tag: release.tag.clone(),
release: ResolvedRelease {
version: release.tag.trim_start_matches('v').to_owned(),
prerelease: release.prerelease,
},
assets: release
.assets
.iter()
.filter(|asset| is_appimage_asset(&asset.name))
.map(|asset| GitHubAsset {
name: asset.name.clone(),
url: asset.url.clone(),
version: release.tag.trim_start_matches('v').to_owned(),
prerelease: release.prerelease,
arch: Some(infer_architecture(&asset.name)),
})
.collect(),
})
.collect::<Vec<_>>();
let metadata_documents = transport_releases
.iter()
.flat_map(|release| release.assets.iter())
.filter(|asset| is_metadata_document(&asset.name))
.filter_map(|asset| {
transport
.fetch_document(&asset.url, asset.content_type.as_deref())
.ok()
})
.collect::<Vec<_>>();
let assets = releases
.iter()
.flat_map(|release| release.assets.iter().cloned())
.collect::<Vec<_>>();
let requested_is_older_release = source
.requested_tag
.as_ref()
.map(|requested| requested != &releases[0].tag)
.unwrap_or(false);
Ok(GitHubDiscovery {
source: source.clone(),
releases,
assets,
metadata_documents,
requested_is_older_release,
})
}
pub fn default_transport() -> Box<dyn GitHubTransport> {
if env::var(FIXTURE_MODE_ENV).ok().as_deref() == Some("1") {
Box::new(FixtureGitHubTransport)
} else {
Box::new(ReqwestGitHubTransport::new())
}
}
pub struct ReqwestGitHubTransport {
client: reqwest::blocking::Client,
api_base: String,
}
impl Default for ReqwestGitHubTransport {
fn default() -> Self {
Self::new()
}
}
impl ReqwestGitHubTransport {
pub fn new() -> Self {
let mut default_headers = reqwest::header::HeaderMap::new();
default_headers.insert(
reqwest::header::USER_AGENT,
reqwest::header::HeaderValue::from_static("aim/0.1"),
);
default_headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/vnd.github+json"),
);
if let Some(token) = env::var("AIM_GITHUB_TOKEN")
.ok()
.or_else(|| env::var("GITHUB_TOKEN").ok())
&& let Ok(value) = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
{
default_headers.insert(reqwest::header::AUTHORIZATION, value);
}
Self {
client: reqwest::blocking::Client::builder()
.default_headers(default_headers)
.build()
.expect("reqwest client should build"),
api_base: env::var("AIM_GITHUB_API_BASE")
.unwrap_or_else(|_| DEFAULT_GITHUB_API_BASE.to_owned()),
}
}
}
impl GitHubTransport for ReqwestGitHubTransport {
fn fetch_releases(&self, repo: &str) -> Result<Vec<TransportRelease>, GitHubDiscoveryError> {
let url = format!("{}/repos/{repo}/releases?per_page=10", self.api_base);
let releases = self
.client
.get(url)
.send()
.map_err(GitHubDiscoveryError::Transport)?
.error_for_status()
.map_err(GitHubDiscoveryError::Transport)?
.json::<Vec<ApiRelease>>()
.map_err(GitHubDiscoveryError::Transport)?;
Ok(releases
.into_iter()
.map(|release| TransportRelease {
tag: release.tag_name,
prerelease: release.prerelease,
assets: release
.assets
.into_iter()
.map(|asset| TransportAsset {
name: asset.name,
url: asset.browser_download_url,
content_type: asset.content_type,
})
.collect(),
})
.collect())
}
fn fetch_document(
&self,
url: &str,
content_type: Option<&str>,
) -> Result<MetadataDocument, GitHubDiscoveryError> {
let response = self
.client
.get(url)
.send()
.map_err(GitHubDiscoveryError::Transport)?
.error_for_status()
.map_err(GitHubDiscoveryError::Transport)?;
let header_content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned)
.or_else(|| content_type.map(ToOwned::to_owned));
let contents = response.bytes().map_err(GitHubDiscoveryError::Transport)?;
Ok(MetadataDocument {
url: url.to_owned(),
content_type: header_content_type,
contents: contents.to_vec(),
})
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FixtureGitHubTransport;
impl GitHubTransport for FixtureGitHubTransport {
fn fetch_releases(&self, repo: &str) -> Result<Vec<TransportRelease>, GitHubDiscoveryError> {
Ok(fixture_releases(repo))
}
fn fetch_document(
&self,
url: &str,
content_type: Option<&str>,
) -> Result<MetadataDocument, GitHubDiscoveryError> {
let contents = fixture_document(url)
.ok_or_else(|| GitHubDiscoveryError::FixtureDocumentMissing(url.to_owned()))?;
Ok(MetadataDocument {
url: url.to_owned(),
content_type: content_type.map(ToOwned::to_owned),
contents,
})
}
}
#[derive(Debug)]
pub enum GitHubDiscoveryError {
Unsupported,
FixtureDocumentMissing(String),
NoReleases { repo: String },
Transport(reqwest::Error),
}
#[derive(serde::Deserialize)]
struct ApiRelease {
tag_name: String,
prerelease: bool,
assets: Vec<ApiAsset>,
}
#[derive(serde::Deserialize)]
struct ApiAsset {
name: String,
browser_download_url: String,
content_type: Option<String>,
}
fn is_appimage_asset(name: &str) -> bool {
name.ends_with(".AppImage")
}
fn is_metadata_document(name: &str) -> bool {
name.ends_with("latest-linux.yml")
|| name.ends_with("latest-linux.yaml")
|| name.ends_with(".zsync")
}
fn infer_architecture(name: &str) -> String {
if name.contains("aarch64") || name.contains("arm64") {
"aarch64".to_owned()
} else {
"x86_64".to_owned()
}
}
fn fixture_releases(repo: &str) -> Vec<TransportRelease> {
match repo {
"pingdotgg/t3code" => vec![
fixture_release(repo, "v0.0.12", "T3-Code-0.0.12-x86_64.AppImage"),
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")],
_ => {
let repo_name = repo.split('/').next_back().unwrap_or("app");
let title = title_case(repo_name);
vec![fixture_release(
repo,
"v1.0.0",
&format!("{title}-1.0.0-x86_64.AppImage"),
)]
}
}
}
fn fixture_release(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/octet-stream".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()?;
match name {
"latest-linux.yml" => {
let appimage = match tag {
"v0.0.11" => "T3-Code-0.0.11-x86_64.AppImage",
"v0.0.12" => "T3-Code-0.0.12-x86_64.AppImage",
"v1.0.0" => "Bat-1.0.0-x86_64.AppImage",
_ => return None,
};
let version = tag.trim_start_matches('v');
Some(
format!("version: {version}\npath: {appimage}\nsha512: fixture-sha\n").into_bytes(),
)
}
_ => None,
}
}
fn title_case(value: &str) -> String {
value
.split(['-', '_'])
.filter(|segment| !segment.is_empty())
.map(|segment| {
let mut chars = segment.chars();
let Some(first) = chars.next() else {
return String::new();
};
format!("{}{}", first.to_ascii_uppercase(), chars.as_str())
})
.collect::<Vec<_>>()
.join("-")
}

View file

@ -0,0 +1,166 @@
use crate::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClassifiedInput {
pub kind: SourceInputKind,
pub source_kind: SourceKind,
pub normalized_kind: NormalizedSourceKind,
pub locator: String,
pub canonical_locator: Option<String>,
pub requested_tag: Option<String>,
pub requested_asset_name: Option<String>,
pub tracks_latest: bool,
}
impl ClassifiedInput {
pub fn into_source_ref(self) -> SourceRef {
SourceRef {
kind: self.source_kind,
locator: self.locator,
input_kind: self.kind,
normalized_kind: self.normalized_kind,
canonical_locator: self.canonical_locator,
requested_tag: self.requested_tag,
requested_asset_name: self.requested_asset_name,
tracks_latest: self.tracks_latest,
}
}
}
pub fn classify_input(query: &str) -> Result<ClassifiedInput, ClassifyInputError> {
if query.starts_with("file://") {
return Ok(ClassifiedInput {
kind: SourceInputKind::File,
source_kind: SourceKind::File,
normalized_kind: NormalizedSourceKind::File,
locator: query.to_owned(),
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
});
}
if let Some(classified) = classify_github_http(query) {
return Ok(classified);
}
if query.starts_with("https://gitlab.com/") || query.starts_with("http://gitlab.com/") {
return Ok(ClassifiedInput {
kind: SourceInputKind::GitLabUrl,
source_kind: SourceKind::GitLab,
normalized_kind: NormalizedSourceKind::GitLab,
locator: query.to_owned(),
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
});
}
if query.starts_with("https://") || query.starts_with("http://") {
return Ok(ClassifiedInput {
kind: SourceInputKind::DirectUrl,
source_kind: SourceKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
locator: query.to_owned(),
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
});
}
if is_github_shorthand(query) {
return Ok(ClassifiedInput {
kind: SourceInputKind::RepoShorthand,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubRepository,
locator: query.to_owned(),
canonical_locator: Some(query.to_owned()),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
});
}
Err(ClassifyInputError::Unsupported)
}
#[derive(Debug, Eq, PartialEq)]
pub enum ClassifyInputError {
Unsupported,
}
fn classify_github_http(query: &str) -> Option<ClassifiedInput> {
let trimmed = query
.trim_start_matches("https://github.com/")
.trim_start_matches("http://github.com/");
if trimmed == query {
return None;
}
let parts = trimmed
.split('/')
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>();
if parts.len() < 2 {
return None;
}
let repo = format!("{}/{}", parts[0], parts[1]);
if parts.len() >= 5 && parts[2] == "releases" && parts[3] == "tag" {
return Some(ClassifiedInput {
kind: SourceInputKind::GitHubReleaseUrl,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubRelease,
locator: query.to_owned(),
canonical_locator: Some(repo),
requested_tag: Some(parts[4].to_owned()),
requested_asset_name: None,
tracks_latest: false,
});
}
if parts.len() >= 6 && parts[2] == "releases" && parts[3] == "download" {
return Some(ClassifiedInput {
kind: SourceInputKind::GitHubReleaseAssetUrl,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubReleaseAsset,
locator: query.to_owned(),
canonical_locator: Some(repo),
requested_tag: Some(parts[4].to_owned()),
requested_asset_name: Some(parts[5].to_owned()),
tracks_latest: false,
});
}
Some(ClassifiedInput {
kind: SourceInputKind::GitHubRepositoryUrl,
source_kind: SourceKind::GitHub,
normalized_kind: NormalizedSourceKind::GitHubRepository,
locator: query.to_owned(),
canonical_locator: Some(repo),
requested_tag: None,
requested_asset_name: None,
tracks_latest: true,
})
}
fn is_github_shorthand(query: &str) -> bool {
let mut parts = query.split('/');
let Some(owner) = parts.next() else {
return false;
};
let Some(repo) = parts.next() else {
return false;
};
if parts.next().is_some() {
return false;
}
!owner.is_empty() && !repo.is_empty() && !owner.contains(':') && !repo.contains(':')
}

View file

@ -0,0 +1,2 @@
pub mod github;
pub mod input;

View file

@ -0,0 +1,93 @@
use crate::domain::source::{SourceInputKind, SourceRef};
use crate::domain::update::{ParsedMetadata, UpdateChannel, UpdateChannelKind};
use crate::source::github::GitHubDiscovery;
pub fn build_channels(
discovery: &GitHubDiscovery,
metadata: &[ParsedMetadata],
) -> Vec<UpdateChannel> {
let mut channels = Vec::new();
if let Some(asset) = discovery.assets.first() {
channels.push(UpdateChannel {
kind: UpdateChannelKind::GitHubReleases,
locator: discovery
.source
.canonical_locator
.clone()
.unwrap_or_else(|| discovery.source.locator.clone()),
version: Some(asset.version.clone()),
artifact_name: Some(asset.name.clone()),
confidence: 60,
matches_install_origin: matches!(
discovery.source.input_kind,
SourceInputKind::RepoShorthand
| SourceInputKind::GitHubRepositoryUrl
| SourceInputKind::GitHubReleaseUrl
),
prerelease: asset.prerelease,
});
}
if let Some(parsed) = metadata
.iter()
.find(|item| item.kind == crate::domain::update::ParsedMetadataKind::ElectronBuilder)
{
channels.push(UpdateChannel {
kind: UpdateChannelKind::ElectronBuilder,
locator: discovery
.metadata_documents
.iter()
.find(|doc| doc.url.ends_with("latest-linux.yml"))
.map(|doc| doc.url.clone())
.unwrap_or_else(|| discovery.source.locator.clone()),
version: parsed.hints.version.clone(),
artifact_name: parsed.hints.primary_download.clone(),
confidence: parsed.confidence,
matches_install_origin: discovery.source.tracks_latest,
prerelease: false,
});
}
if let Some(parsed) = metadata
.iter()
.find(|item| item.kind == crate::domain::update::ParsedMetadataKind::Zsync)
{
channels.push(UpdateChannel {
kind: UpdateChannelKind::Zsync,
locator: parsed.hints.primary_download.clone().unwrap_or_default(),
version: parsed.hints.version.clone(),
artifact_name: parsed.hints.primary_download.clone(),
confidence: parsed.confidence,
matches_install_origin: false,
prerelease: false,
});
}
if matches!(
discovery.source.input_kind,
SourceInputKind::GitHubReleaseAssetUrl
) {
channels.push(UpdateChannel {
kind: UpdateChannelKind::DirectAsset,
locator: discovery.source.locator.clone(),
version: discovery
.source
.requested_tag
.clone()
.map(|value| value.trim_start_matches('v').to_owned()),
artifact_name: discovery.source.requested_asset_name.clone(),
confidence: 85,
matches_install_origin: true,
prerelease: false,
});
}
channels
}
pub fn source_ref_from_channel(source: &SourceRef, channel: &UpdateChannel) -> SourceRef {
let mut value = source.clone();
value.locator = channel.locator.clone();
value
}

View file

@ -0,0 +1,2 @@
pub mod channels;
pub mod ranking;

View file

@ -0,0 +1,101 @@
use crate::domain::update::{
ArtifactCandidate, ChannelPreference, MetadataHints, UpdateChannel, UpdateChannelKind,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RankedChannel {
pub channel: UpdateChannel,
pub reason: String,
pub score: i32,
}
pub fn rank_channels(channels: &[UpdateChannel]) -> Vec<RankedChannel> {
let mut ranked = channels
.iter()
.cloned()
.map(|channel| {
let install_origin_bonus = if channel.matches_install_origin {
100
} else {
0
};
let prerelease_penalty = if channel.prerelease { 20 } else { 0 };
let metadata_bonus = match channel.kind {
UpdateChannelKind::ElectronBuilder | UpdateChannelKind::Zsync => 25,
_ => 0,
};
let score = channel.confidence as i32 + install_origin_bonus + metadata_bonus
- prerelease_penalty;
let reason = if channel.matches_install_origin {
"install-origin-match"
} else if metadata_bonus > 0 {
"metadata-guided"
} else {
"heuristic-match"
};
RankedChannel {
channel,
reason: reason.to_owned(),
score,
}
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| right.score.cmp(&left.score));
ranked
}
pub fn select_artifact(
channel: &RankedChannel,
hints: Option<&MetadataHints>,
) -> ArtifactCandidate {
let resolved_url = resolve_artifact_url(
&channel.channel.locator,
hints.and_then(|value| value.primary_download.as_deref()),
);
let selection_reason = if hints
.and_then(|value| value.primary_download.clone())
.is_some()
{
"metadata-guided"
} else {
channel.reason.as_str()
};
ArtifactCandidate {
url: resolved_url,
version: channel
.channel
.version
.clone()
.unwrap_or_else(|| "latest".to_owned()),
arch: Some("x86_64".to_owned()),
selection_reason: selection_reason.to_owned(),
}
}
pub fn to_preference(channel: &RankedChannel) -> ChannelPreference {
ChannelPreference {
kind: channel.channel.kind,
locator: channel.channel.locator.clone(),
reason: channel.reason.clone(),
}
}
fn resolve_artifact_url(channel_locator: &str, primary_download: Option<&str>) -> String {
let Some(primary_download) = primary_download else {
return channel_locator.to_owned();
};
if primary_download.contains("://") || primary_download.starts_with("file://") {
return primary_download.to_owned();
}
if (channel_locator.ends_with(".yml") || channel_locator.ends_with(".yaml"))
&& let Some((base, _)) = channel_locator.rsplit_once('/')
{
return format!("{base}/{primary_download}");
}
primary_download.to_owned()
}