From 11e7f8f8f32569f51e71bcfa4bef875e6a962541 Mon Sep 17 00:00:00 2001 From: Serophots Date: Wed, 13 Aug 2025 22:34:15 +0100 Subject: [PATCH 1/4] Create `latest.json` build artefact for updater endpoint --- Cargo.lock | 1 + bindings/packager/nodejs/schema.json | 8 ++ crates/packager/Cargo.toml | 1 + crates/packager/schema.json | 8 ++ crates/packager/src/cli/mod.rs | 8 +- crates/packager/src/cli/signer/sign.rs | 2 +- crates/packager/src/config/mod.rs | 29 +++++++ crates/packager/src/error.rs | 3 + crates/packager/src/lib.rs | 65 +++++++++++++++- crates/packager/src/package/mod.rs | 100 ++++++++++++++++++++++++- crates/packager/src/sign.rs | 10 ++- 11 files changed, 225 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d7a898f2..c5e21ed7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1262,6 +1262,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 9df6865e..9390a2a6 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 7467775c..1cea8b22 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}; @@ -1759,6 +1760,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 { @@ -1819,6 +1834,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("macos") { + 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..2869081e 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..7781bf1f 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,7 +118,7 @@ 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, @@ -114,6 +137,7 @@ pub fn package(config: &Config) -> crate::Result> { let paths = app::package(&ctx)?; packages.push(PackageOutput { format: PackageFormat::App, + summary: None, paths, }); } @@ -153,8 +177,11 @@ pub fn package(config: &Config) -> crate::Result> { } }?; + let summary = build_package_summary(&paths, format, config)?; + packages.push(PackageOutput { - format: *format, + format, + summary, paths, }); } @@ -271,3 +298,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!("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, + )) } From 69eb62310af21baec5026eed6102316dd1f81a88 Mon Sep 17 00:00:00 2001 From: Serophots Date: Tue, 3 Feb 2026 21:33:45 +0000 Subject: [PATCH 2/4] fix macos target os detection --- crates/packager/src/config/mod.rs | 2 +- crates/packager/src/package/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/packager/src/config/mod.rs b/crates/packager/src/config/mod.rs index 983803c0..221c98f4 100644 --- a/crates/packager/src/config/mod.rs +++ b/crates/packager/src/config/mod.rs @@ -1827,7 +1827,7 @@ impl Config { let target = self.target_triple(); if target.contains("windows") { Some("windows") - } else if target.contains("macos") { + } else if target.contains("apple-darwin") { Some("macos") } else if target.contains("linux") { Some("linux") diff --git a/crates/packager/src/package/mod.rs b/crates/packager/src/package/mod.rs index 7781bf1f..4d4125b2 100644 --- a/crates/packager/src/package/mod.rs +++ b/crates/packager/src/package/mod.rs @@ -351,7 +351,7 @@ fn build_package_summary( }) } _ => { - tracing::warn!("A package could not be summarized in latest.json because the platform string could not be determined from {target_triple}."); + 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 } } From 07039aef322cf2d9bd1ab28d0f68d12cc5a3a45e Mon Sep 17 00:00:00 2001 From: Serophots Date: Fri, 6 Feb 2026 17:58:25 +0000 Subject: [PATCH 3/4] Don't let macOS dmg produce build summary --- crates/packager/src/lib.rs | 12 ++++++------ crates/packager/src/package/mod.rs | 9 ++++++++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/crates/packager/src/lib.rs b/crates/packager/src/lib.rs index 2869081e..fb2cc1d2 100644 --- a/crates/packager/src/lib.rs +++ b/crates/packager/src/lib.rs @@ -261,12 +261,12 @@ pub fn summarise_outputs( 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.") - } + // 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.") + // } } } diff --git a/crates/packager/src/package/mod.rs b/crates/packager/src/package/mod.rs index 4d4125b2..478f11eb 100644 --- a/crates/packager/src/package/mod.rs +++ b/crates/packager/src/package/mod.rs @@ -125,10 +125,14 @@ pub fn package(config: &Config) -> crate::Result> { 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() @@ -177,7 +181,10 @@ pub fn package(config: &Config) -> crate::Result> { } }?; - let summary = build_package_summary(&paths, format, config)?; + let summary = produce_summary + .then(|| build_package_summary(&paths, format, config)) + .transpose()? + .flatten(); packages.push(PackageOutput { format, From 088d6a4a1e1c26d246213b61b4b88a03b45e3eee Mon Sep 17 00:00:00 2001 From: Serophots Date: Mon, 6 Apr 2026 17:56:52 +0100 Subject: [PATCH 4/4] Mute compiler warning under some feature flags --- crates/packager/src/package/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/packager/src/package/mod.rs b/crates/packager/src/package/mod.rs index 478f11eb..596f35d2 100644 --- a/crates/packager/src/package/mod.rs +++ b/crates/packager/src/package/mod.rs @@ -125,6 +125,7 @@ pub fn package(config: &Config) -> crate::Result> { format.short_name(), )?; + #[allow(unused_mut)] let mut produce_summary: bool = true; let paths = match format {