diff --git a/Cargo.lock b/Cargo.lock index 2f3b0e65..96a4c95a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -992,6 +992,7 @@ dependencies = [ "tracing", "tracing-subscriber", "ureq", + "url", "uuid", "walkdir", "windows-registry", diff --git a/bindings/packager/nodejs/schema.json b/bindings/packager/nodejs/schema.json index fba77239..2a49cb69 100644 --- a/bindings/packager/nodejs/schema.json +++ b/bindings/packager/nodejs/schema.json @@ -324,6 +324,14 @@ "type": "null" } ] + }, + "endpoint": { + "description": "When set, a summary `latest.json` build artefact will be generated which can be hosted alongside other build artefacts as an endpoint for the updater, including meta data about the version and URL's to point at each of the other build artefacts.\n\nSpecifically, this URL specifies where these build artefacts are hosted. For example, a using Github Releases: `https://github.com/org/repo/releases/download/v{{version}}/{{artefact}}`\n\nEach endpoint optionally could have `{{version}}` or `{{artefact}}` which will be detected and replaced with the appropriate value\n\n- `{{version}}`: The version of the app which is being packaged - `{{artefact}}`: The file name of the particular build artefact One URL is produced per build artefact.", + "type": [ + "string", + "null" + ], + "format": "uri" } }, "additionalProperties": false, diff --git a/crates/packager/Cargo.toml b/crates/packager/Cargo.toml index 66684311..2d99ef2e 100644 --- a/crates/packager/Cargo.toml +++ b/crates/packager/Cargo.toml @@ -85,6 +85,7 @@ time = { workspace = true, features = ["formatting"] } image = { version = "0.25", default-features = false, features = ["rayon", "bmp", "ico", "png", "jpeg"] } tempfile = "3" plist = "1" +url = { version = "2", features = ["serde"] } [target."cfg(target_os = \"windows\")".dependencies] windows-registry = "0.5" diff --git a/crates/packager/schema.json b/crates/packager/schema.json index fba77239..2a49cb69 100644 --- a/crates/packager/schema.json +++ b/crates/packager/schema.json @@ -324,6 +324,14 @@ "type": "null" } ] + }, + "endpoint": { + "description": "When set, a summary `latest.json` build artefact will be generated which can be hosted alongside other build artefacts as an endpoint for the updater, including meta data about the version and URL's to point at each of the other build artefacts.\n\nSpecifically, this URL specifies where these build artefacts are hosted. For example, a using Github Releases: `https://github.com/org/repo/releases/download/v{{version}}/{{artefact}}`\n\nEach endpoint optionally could have `{{version}}` or `{{artefact}}` which will be detected and replaced with the appropriate value\n\n- `{{version}}`: The version of the app which is being packaged - `{{artefact}}`: The file name of the particular build artefact One URL is produced per build artefact.", + "type": [ + "string", + "null" + ], + "format": "uri" } }, "additionalProperties": false, diff --git a/crates/packager/src/cli/mod.rs b/crates/packager/src/cli/mod.rs index 785630e1..a2ae4da4 100644 --- a/crates/packager/src/cli/mod.rs +++ b/crates/packager/src/cli/mod.rs @@ -10,7 +10,8 @@ use clap::{ArgAction, CommandFactory, FromArgMatches, Parser, Subcommand}; use crate::{ config::{LogLevel, PackageFormat}, - init_tracing_subscriber, package, parse_log_level, sign_outputs, util, SigningConfig, + init_tracing_subscriber, package, parse_log_level, sign_outputs, summarise_outputs, util, + SigningConfig, }; mod config; @@ -137,6 +138,7 @@ fn run_cli(cli: Cli) -> Result<()> { let mut outputs = Vec::new(); let mut signatures = Vec::new(); + let mut summaries = Vec::new(); for (config_dir, mut config) in configs { tracing::trace!(config = ?config); @@ -182,6 +184,9 @@ fn run_cli(cli: Cli) -> Result<()> { signatures.extend(s); } + // build summary + summaries.push(summarise_outputs(&config, &mut packages)?); + outputs.extend(packages); } @@ -189,6 +194,7 @@ fn run_cli(cli: Cli) -> Result<()> { let outputs = outputs .into_iter() .flat_map(|o| o.paths) + .chain(summaries.into_iter()) .collect::>(); // print information when finished diff --git a/crates/packager/src/cli/signer/sign.rs b/crates/packager/src/cli/signer/sign.rs index 9789fc58..f84045f4 100644 --- a/crates/packager/src/cli/signer/sign.rs +++ b/crates/packager/src/cli/signer/sign.rs @@ -42,7 +42,7 @@ pub fn command(options: Options) -> Result<()> { tracing::info!( "Signed the file successfully! find the signature at: {}", - signature_path.display() + signature_path.0.display() ); Ok(()) diff --git a/crates/packager/src/config/mod.rs b/crates/packager/src/config/mod.rs index 74285032..221c98f4 100644 --- a/crates/packager/src/config/mod.rs +++ b/crates/packager/src/config/mod.rs @@ -14,6 +14,7 @@ use std::{ use relative_path::PathExt; use serde::{Deserialize, Serialize}; +use url::Url; use crate::{util, Error}; @@ -1747,6 +1748,20 @@ pub struct Config { pub nsis: Option, /// Dmg configuration. pub dmg: Option, + /// When set, a summary `latest.json` build artefact will be generated which can be + /// hosted alongside other build artefacts as an endpoint for the updater, including + /// meta data about the version and URL's to point at each of the other build artefacts. + /// + /// Specifically, this URL specifies where these build artefacts are hosted. For example, + /// a using Github Releases: `https://github.com/org/repo/releases/download/v{{version}}/{{artefact}}` + /// + /// Each endpoint optionally could have `{{version}}` or `{{artefact}}` + /// which will be detected and replaced with the appropriate value + /// + /// - `{{version}}`: The version of the app which is being packaged + /// - `{{artefact}}`: The file name of the particular build artefact + /// One URL is produced per build artefact. + pub endpoint: Option, } impl Config { @@ -1807,6 +1822,20 @@ impl Config { }) } + /// Returns the operating system for the package to be built (e.g. "linux", "macos " or "windows"). + pub fn target_os(&self) -> Option<&str> { + let target = self.target_triple(); + if target.contains("windows") { + Some("windows") + } else if target.contains("apple-darwin") { + Some("macos") + } else if target.contains("linux") { + Some("linux") + } else { + None + } + } + /// Returns the architecture for the package to be built (e.g. "arm", "x86" or "x86_64"). pub fn target_arch(&self) -> crate::Result<&str> { let target = self.target_triple(); diff --git a/crates/packager/src/error.rs b/crates/packager/src/error.rs index 2fa6d828..bfde17e6 100644 --- a/crates/packager/src/error.rs +++ b/crates/packager/src/error.rs @@ -250,6 +250,9 @@ pub enum Error { /// Failed to enumerate registry keys. #[error("failed to enumerate registry keys")] FailedToEnumerateRegKeys, + /// Url parsing errors. + #[error(transparent)] + UrlParse(#[from] url::ParseError), } /// Convenient type alias of Result type for cargo-packager. diff --git a/crates/packager/src/lib.rs b/crates/packager/src/lib.rs index 47cf1439..fb2cc1d2 100644 --- a/crates/packager/src/lib.rs +++ b/crates/packager/src/lib.rs @@ -76,7 +76,7 @@ #![cfg_attr(doc_cfg, feature(doc_cfg))] #![deny(missing_docs)] -use std::{io::Write, path::PathBuf}; +use std::{collections::HashMap, fs::File, io::Write, path::PathBuf}; mod codesign; mod error; @@ -93,11 +93,14 @@ pub mod sign; pub use config::{Config, PackageFormat}; pub use error::{Error, Result}; use flate2::{write::GzEncoder, Compression}; +use serde::Serialize; pub use sign::SigningConfig; pub use package::{package, PackageOutput}; use util::PathExt; +use crate::package::PackageOutputSummary; + #[cfg(feature = "cli")] fn parse_log_level(verbose: u8) -> tracing::Level { match verbose { @@ -225,13 +228,71 @@ pub fn sign_outputs( } else { path }; - signatures.push(sign::sign_file(config, path)?); + + let (sig_file, sig) = sign::sign_file(config, path)?; + + // Add signature to package summary + if let Some(summary) = &mut package.summary { + summary.signature = Some(sig); + } + + signatures.push(sig_file); } } Ok(signatures) } +/// Create a `latest.json` output summarising the built packages +pub fn summarise_outputs( + config: &Config, + packages: &mut Vec, +) -> crate::Result { + #[derive(Debug, Clone, Serialize)] + struct InnerRemoteRelease { + version: String, + notes: Option, + pub_date: Option, + platforms: Option>, + } + + //Collect releases + let mut platforms = HashMap::with_capacity(packages.len()); + + for package in packages { + if let Some(summary) = package.summary.clone() { + // if summary.signature.is_some() { + platforms.insert(summary.platform.clone(), summary); + // } else { + // // The signer failed to update the signature field + // tracing::warn!("A package could not be summarized in latest.json because it could not be signed.") + // } + } + } + + // Write latest.json + let release = InnerRemoteRelease { + version: config.version.clone(), + notes: None, + pub_date: Some( + time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap(), + ), + platforms: Some(platforms), + }; + + //Write it to the file + let summary_path = config.out_dir().join("latest.json"); + let summary_file = File::create(summary_path.clone())?; + + serde_json::to_writer_pretty(summary_file, &release)?; + + tracing::info!("Finished summarising at:\n{}", summary_path.display()); + + Ok(summary_path) +} + /// Package an app using the specified config. /// Then signs the generated packages. /// diff --git a/crates/packager/src/package/mod.rs b/crates/packager/src/package/mod.rs index 2f70bbe0..478f11eb 100644 --- a/crates/packager/src/package/mod.rs +++ b/crates/packager/src/package/mod.rs @@ -4,6 +4,9 @@ use std::path::PathBuf; +use serde::Serialize; +use url::Url; + use crate::{config, shell::CommandExt, util, Config, PackageFormat}; use self::context::Context; @@ -49,6 +52,8 @@ pub struct PackageOutput { pub format: PackageFormat, /// All paths for this package. pub paths: Vec, + /// Package summary for `latest.json` + pub summary: Option, } impl PackageOutput { @@ -57,10 +62,28 @@ impl PackageOutput { /// This is only useful if you need to sign the packages in a different process, /// after packaging the app and storing its paths. pub fn new(format: PackageFormat, paths: Vec) -> Self { - Self { format, paths } + Self { + format, + paths, + summary: None, + } } } +/// Summary information for this package to be included in `latest.json` +#[derive(Debug, Clone, Serialize)] +pub struct PackageOutputSummary { + /// Download URL for the platform + pub url: Url, + /// Signature for the platform. If it is None then something has gone wrong + pub signature: Option, + /// Update format + pub format: PackageFormat, + /// Target triple for this package + #[serde(skip)] + pub platform: String, +} + /// Package an app using the specified config. #[tracing::instrument(level = "trace", skip(config))] pub fn package(config: &Config) -> crate::Result> { @@ -95,17 +118,21 @@ pub fn package(config: &Config) -> crate::Result> { tracing::trace!(ctx = ?ctx); let mut packages = Vec::new(); - for format in &formats { + for &format in &formats { run_before_each_packaging_command_hook( config, &formats_comma_separated, format.short_name(), )?; + let mut produce_summary: bool = true; + let paths = match format { PackageFormat::App => app::package(&ctx), #[cfg(target_os = "macos")] PackageFormat::Dmg => { + produce_summary = false; + // PackageFormat::App is required for the DMG bundle if !packages .iter() @@ -114,6 +141,7 @@ pub fn package(config: &Config) -> crate::Result> { let paths = app::package(&ctx)?; packages.push(PackageOutput { format: PackageFormat::App, + summary: None, paths, }); } @@ -153,8 +181,14 @@ pub fn package(config: &Config) -> crate::Result> { } }?; + let summary = produce_summary + .then(|| build_package_summary(&paths, format, config)) + .transpose()? + .flatten(); + packages.push(PackageOutput { - format: *format, + format, + summary, paths, }); } @@ -271,3 +305,70 @@ fn run_before_packaging_command_hook( Ok(()) } + +fn build_package_summary( + paths: &Vec, + format: PackageFormat, + config: &Config, +) -> crate::Result> { + Ok(if let Some(url) = &config.endpoint { + let paths = paths + .iter() + .cloned() + .filter_map(|path| path.file_name().and_then(|f| f.to_str().map(Into::into))) + .collect::>(); + + if paths.len() == 1 { + let artefact = paths.first().unwrap(); + + let url: Url = url + .to_string() + // url::Url automatically url-encodes the path components + .replace("%7B%7Bversion%7D%7D", &config.version) + .replace("%7B%7Bartefact%7D%7D", &artefact) + // but not query parameters + .replace("{{version}}", &config.version) + .replace("{{artefact}}", &artefact) + .parse()?; + + let target_triple = config.target_triple(); + // See the updater crate for which particular target strings are required. + let target_arch = if target_triple.starts_with("x86_64") { + Some("x86_64") + } else if target_triple.starts_with('i') { + Some("i686") + } else if target_triple.starts_with("arm") { + Some("armv7") + } else if target_triple.starts_with("aarch64") { + Some("aarch64") + } else { + None + }; + let target_os = config.target_os(); + match (target_arch, target_os) { + (Some(target_arch), Some(target_os)) => { + let platform = format!("{target_os}-{target_arch}"); + + Some(PackageOutputSummary { + url, + format, + platform, + // Signature will be set later + signature: None, + }) + } + _ => { + tracing::warn!(target_triple =?config.target_triple(), ?target_arch, ?target_os, "A package could not be summarized in latest.json because the platform string could not be determined from {target_triple}."); + None + } + } + } else { + // TODO: Implement logic to decide which path to publish in PackageOutputSummary when there are multiple to choose from + tracing::warn!("A package could not be summarized in latest.json because the package format {format:?} is not yet supported."); + None + } + } else { + // No endpoint has been configured, so no summary is outputted + None + }) +} diff --git a/crates/packager/src/sign.rs b/crates/packager/src/sign.rs index 84f6a4c5..c66c98cb 100644 --- a/crates/packager/src/sign.rs +++ b/crates/packager/src/sign.rs @@ -144,7 +144,7 @@ impl SigningConfig { pub fn sign_file + Debug>( config: &SigningConfig, path: P, -) -> crate::Result { +) -> crate::Result<(PathBuf, String)> { let secret_key = decode_private_key(&config.private_key, config.password.as_deref())?; sign_file_with_secret_key(&secret_key, path) } @@ -154,7 +154,7 @@ pub fn sign_file + Debug>( pub fn sign_file_with_secret_key + Debug>( secret_key: &minisign::SecretKey, path: P, -) -> crate::Result { +) -> crate::Result<(PathBuf, String)> { let path = path.as_ref(); let signature_path = path.with_additional_extension("sig"); let signature_path = dunce::simplified(&signature_path); @@ -188,5 +188,9 @@ pub fn sign_file_with_secret_key + Debug>( signature_box_writer.write_all(encoded_signature.as_bytes())?; signature_box_writer.flush()?; - dunce::canonicalize(signature_path).map_err(|e| crate::Error::IoWithPath(path.to_path_buf(), e)) + Ok(( + dunce::canonicalize(signature_path) + .map_err(|e| crate::Error::IoWithPath(path.to_path_buf(), e))?, + encoded_signature, + )) }