This commit is contained in:
Kieran 2025-02-08 23:31:47 +00:00
commit 03a529fc7b
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
8 changed files with 3926 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
.idea/

3580
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

28
Cargo.toml Normal file
View File

@ -0,0 +1,28 @@
[package]
name = "nap"
description = "Nostr App Publisher"
license = "MIT"
repository = "https://git.v0l.io/Kieran/nap"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "nap"
path = "src/main.rs"
[dependencies]
anyhow = "1.0.95"
clap = { version = "4.5.28", features = ["derive"] }
config = { version = "0.15.7", features = ["yaml"] }
log = "0.4.25"
nostr-sdk = "0.39.0"
reqwest = { version = "0.12.12", features = ["json"] }
tokio = { version = "1.43.0", features = ["fs", "rt", "macros", "rt-multi-thread"] }
serde = { version = "1.0.217", features = ["derive"] }
async-trait = "0.1.86"
apk = "0.4.0"
zip = "2.2.2"
semver = "1.0.25"
indicatif = "0.17.11"
dialoguer = "0.11.0"
env_logger = "0.11.6"

26
nap.yaml Normal file
View File

@ -0,0 +1,26 @@
# Unique app id
id: "io.nostrlabs.freeflow"
# Display name of the app
name: "Freeflow"
# Human-readable long description
description: "Live in the moment"
# Application icon
icon: "https://freeflow.app/icon.png"
# Banner / Preview of the app
images:
- "https://freeflow.app/banner.jpg"
# Public code repo or project website
repository: "https://github.com/nostrlabs-io/freeflow"
# SPDX code license
license: "MIT"
# Descriptive app tags
tags:
- "tiktok"
- "shorts"

59
src/main.rs Normal file
View File

@ -0,0 +1,59 @@
mod manifest;
mod repo;
use crate::manifest::Manifest;
use crate::repo::Repo;
use anyhow::{anyhow, Result};
use clap::Parser;
use config::{Config, File, FileSourceFile};
use log::info;
use std::path::PathBuf;
#[derive(clap::Parser)]
#[command(version, about)]
struct Args {
/// User specified config path
#[arg(long, short)]
pub config: Option<PathBuf>,
}
#[tokio::main]
async fn main() -> Result<()> {
env_logger::init();
// Set default log level to info
if std::env::var("RUST_LOG").is_err() {
std::env::set_var("RUST_LOG", "info");
}
let args = Args::parse();
let manifest: Manifest = Config::builder()
.add_source(File::from(args.config.unwrap_or(PathBuf::from("nap.yaml"))))
.build()
.map_err(|e| anyhow!("Failed to load config: {}", e))?
.try_deserialize()?;
let repo: Box<dyn Repo> = (&manifest).try_into()?;
let releases = repo.get_releases().await?;
info!("Found {} release(s)", releases.len());
if let Some(release) = releases.first() {
info!("Starting publish of release {}", release.version);
info!("Artifacts: ");
for a in &release.artifacts {
info!(" - {}", a.name);
}
if !dialoguer::Confirm::new()
.default(false)
.with_prompt(format!("Publish v{}?", release.version))
.interact()?
{
return Ok(());
}
}
Ok(())
}

52
src/manifest.rs Normal file
View File

@ -0,0 +1,52 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Manifest {
/// App ID, must be unique
pub id: String,
/// Application display name
pub name: String,
/// Long app description / release notes
pub description: Option<String>,
/// Repo URL
pub repository: Option<String>,
/// App icon
pub icon: Option<String>,
/// App preview images
pub images: Vec<String>,
/// Tags (category / purpose)
pub tags: Vec<String>,
}
#[derive(Deserialize)]
pub enum Platform {
Android {
arch: Architecture
},
IOS,
MacOS {
arch: Architecture
},
Windows {
arch: Architecture
},
Linux {
arch: Architecture
},
Web
}
#[derive(Deserialize)]
pub enum Architecture {
ARMv7,
ARMv8,
X86,
AMD64,
ARM64
}

128
src/repo/github.rs Normal file
View File

@ -0,0 +1,128 @@
use crate::repo::{Repo, RepoArtifact, RepoRelease, RepoResource};
use anyhow::{anyhow, Result};
use log::{info, warn};
use nostr_sdk::Url;
use reqwest::header::{HeaderMap, ACCEPT, USER_AGENT};
use reqwest::Client;
use semver::Version;
use serde::Deserialize;
pub struct GithubRepo {
client: Client,
owner: String,
repo: String,
}
impl GithubRepo {
pub fn new(owner: String, repo: String) -> GithubRepo {
let mut headers = HeaderMap::new();
headers.insert(ACCEPT, "application/vnd.github+json".parse().unwrap());
headers.insert(
USER_AGENT,
"nap/1.0 (https://github.com/v0l/nap)".parse().unwrap(),
);
let client = Client::builder().default_headers(headers).build().unwrap();
GithubRepo {
owner,
repo,
client,
}
}
pub fn from_url(url: &str) -> Result<GithubRepo> {
let u: Url = url.parse()?;
let mut segs = u.path_segments().ok_or(anyhow::anyhow!("Invalid URL"))?;
Ok(GithubRepo::new(
segs.next().ok_or(anyhow!("Invalid URL"))?.to_string(),
segs.next().ok_or(anyhow!("Invalid URL"))?.to_string(),
))
}
}
#[derive(Deserialize)]
struct GithubRelease {
pub tag_name: String,
pub name: String,
pub draft: bool,
#[serde(rename = "prerelease")]
pub pre_release: bool,
pub body: String,
pub assets: Vec<GithubReleaseArtifact>,
}
#[derive(Deserialize)]
struct GithubReleaseArtifact {
pub name: String,
pub size: u64,
pub content_type: String,
pub browser_download_url: String,
}
impl TryFrom<&GithubRelease> for RepoRelease {
type Error = anyhow::Error;
fn try_from(value: &GithubRelease) -> std::result::Result<Self, Self::Error> {
Ok(RepoRelease {
version: Version::parse(if value.tag_name.starts_with("v") {
&value.tag_name[1..]
} else {
&value.tag_name
})?,
artifacts: value
.assets
.iter()
.filter_map(|v| match RepoArtifact::try_from(v) {
Ok(art) => Some(art),
Err(e) => {
warn!("Failed to parse artifact {}: {}", &v.name, e);
None
}
})
.collect(),
})
}
}
impl TryFrom<&GithubReleaseArtifact> for RepoArtifact {
type Error = anyhow::Error;
fn try_from(value: &GithubReleaseArtifact) -> std::result::Result<Self, Self::Error> {
Ok(RepoArtifact {
name: value.name.clone(),
size: value.size,
content_type: value.content_type.clone(),
location: RepoResource::Remote(value.browser_download_url.clone()),
})
}
}
#[async_trait::async_trait]
impl Repo for GithubRepo {
async fn get_releases(&self) -> Result<Vec<RepoRelease>> {
info!(
"Fetching release from: github.com/{}/{}",
self.owner, self.repo
);
let req = self
.client
.get(format!(
"https://api.github.com/repos/{}/{}/releases",
self.owner, self.repo
))
.build()?;
let rsp: Vec<GithubRelease> = self.client.execute(req).await?.json().await?;
Ok(rsp
.into_iter()
.filter_map(|v| match RepoRelease::try_from(&v) {
Ok(r) => Some(r),
Err(e) => {
warn!("Failed to parse release: {} {}", v.tag_name, e);
None
}
})
.collect())
}
}

51
src/repo/mod.rs Normal file
View File

@ -0,0 +1,51 @@
use std::path::PathBuf;
use crate::manifest::Manifest;
use crate::repo::github::GithubRepo;
use anyhow::{anyhow, bail, Result};
use semver::Version;
mod github;
/// Since artifact binary / image
pub struct RepoArtifact {
pub name: String,
pub size: u64,
pub location: RepoResource,
pub content_type: String,
}
/// A local/remote location where the artifact is located
pub enum RepoResource {
Remote(String),
Local(PathBuf),
}
/// A single release with one or more artifacts
pub struct RepoRelease {
pub version: Version,
pub artifacts: Vec<RepoArtifact>,
}
/// Generic artifact repository
#[async_trait::async_trait]
pub trait Repo {
/// Get a list of release artifacts
async fn get_releases(&self) -> Result<Vec<RepoRelease>>;
}
impl TryInto<Box<dyn Repo>> for &Manifest {
type Error = anyhow::Error;
fn try_into(self) -> std::result::Result<Box<dyn Repo>, Self::Error> {
let repo = self
.repository
.as_ref()
.ok_or(anyhow!("repository not found"))?;
if !repo.starts_with("https://github.com/") {
bail!("Only github repos are supported");
}
Ok(Box::new(GithubRepo::from_url(repo)?))
}
}