diff --git a/src/Rust/Cargo.lock b/src/Rust/Cargo.lock index b6ecfbba..921419a8 100644 --- a/src/Rust/Cargo.lock +++ b/src/Rust/Cargo.lock @@ -1208,6 +1208,12 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" +[[package]] +name = "ryu" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" + [[package]] name = "same-file" version = "1.0.6" @@ -1275,6 +1281,17 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "serde_json" +version = "1.0.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1_smol" version = "1.0.0" @@ -1575,6 +1592,8 @@ dependencies = [ "regex", "remove_dir_all", "semver", + "serde", + "serde_json", "sha1_smol", "simple-stopwatch", "simplelog", diff --git a/src/Rust/Cargo.toml b/src/Rust/Cargo.toml index d910f8fb..fe639aa6 100644 --- a/src/Rust/Cargo.toml +++ b/src/Rust/Cargo.toml @@ -75,6 +75,8 @@ remove_dir_all = { git = "https://github.com/caesay/remove_dir_all.git", feature zstd = "0.13" sha1_smol = "1.0" url = "2.5" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [target.'cfg(unix)'.dependencies] native-dialog = "0.7" diff --git a/src/Rust/src/commands/check.rs b/src/Rust/src/commands/check.rs index 05ea9046..696abee9 100644 --- a/src/Rust/src/commands/check.rs +++ b/src/Rust/src/commands/check.rs @@ -1,38 +1,146 @@ -use crate::bundle::Manifest; +use crate::{bundle::Manifest, shared}; use anyhow::{bail, Result}; -use std::path::PathBuf; -use url::{ParseError, Url}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::{fs, path::PathBuf}; -pub fn check(root_path: &PathBuf, app: &Manifest, path: String, allow_downgrade: bool, channel: Option) -> Result<()> { - match Url::parse(&path) { - Ok(url) => check_url(root_path, app, url, allow_downgrade, channel), - _ => { - let buf = PathBuf::from(&path); - if !buf.exists() { - bail!("Path must be a valid HTTP Url or a path to an existing directory: {}", path); - } - check_dir(root_path, app, buf, allow_downgrade, channel) - } - } +#[allow(non_snake_case)] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct VelopackAssetFeed { + pub Assets: Vec, } -fn check_url(root_path: &PathBuf, app: &Manifest, path: Url, allow_downgrade: bool, channel: Option) -> Result<()> { - let channel = channel.unwrap_or(app.channel.clone()); +#[allow(non_snake_case)] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct VelopackAsset { + pub PackageId: String, + pub Version: String, + pub Type: String, + pub FileName: String, + pub SHA1: String, + pub Size: u64, + pub NotesMarkdown: String, + pub NotesHtml: String, +} + +#[allow(non_snake_case)] +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(default)] +pub struct UpdateInfo { + pub TargetFullRelease: VelopackAsset, + pub IsDowngrade: bool, +} + +pub fn check(app: &Manifest, path: &str, allow_downgrade: bool, channel: Option<&str>) -> Result> { + let result = if shared::is_http_url(&path) { + info!("Checking for updates from URL: {}", path); + check_url(app, path, allow_downgrade, channel) + } else { + let buf = PathBuf::from(&path); + info!("Checking for updates from Local Path: {}", buf.to_string_lossy()); + if !buf.exists() { + bail!("Path must be a valid HTTP Url or a path to an existing directory: {}", path); + } + check_dir(app, buf, allow_downgrade, channel) + }; + result +} + +fn get_default_channel() -> String { + #[cfg(target_os = "windows")] + return "win".to_owned(); + #[cfg(target_os = "linux")] + return "linux".to_owned(); + #[cfg(target_os = "macos")] + return "osx".to_owned(); +} + +fn check_url(app: &Manifest, path: &str, allow_downgrade: bool, channel: Option<&str>) -> Result> { + let mut channel = channel.unwrap_or(&app.channel).to_string(); + if channel.is_empty() { + channel = get_default_channel(); + } + + let non_default_channel = channel != app.channel; let releases_name = format!("releases.{}.json", channel); - todo!(); + let path = path.trim_end_matches('/').to_owned() + "/"; + let url = url::Url::parse(&path)?; + let mut releases_url = url.join(&releases_name)?; + releases_url.set_query(Some(format!("localVersion={}&id={}", app.version, app.id).as_str())); + + info!("Downloading releases for channel {} from: {}", channel, releases_url.to_string()); + + let json = shared::download::download_url_as_string(releases_url.as_str())?; + let feed: VelopackAssetFeed = serde_json::from_str(&json)?; + process_feed(app, feed, allow_downgrade, non_default_channel) } -fn check_dir(root_path: &PathBuf, app: &Manifest, path: PathBuf, allow_downgrade: bool, channel: Option) -> Result<()> { - let channel = channel.unwrap_or(app.channel.clone()); +fn check_dir(app: &Manifest, path: PathBuf, allow_downgrade: bool, channel: Option<&str>) -> Result> { + let mut channel = channel.unwrap_or(&app.channel).to_string(); + if channel.is_empty() { + channel = get_default_channel(); + } + + let non_default_channel = channel != app.channel; let releases_name = format!("releases.{}.json", channel); let releases_path = path.join(&releases_name); + + info!("Reading releases file for channel {} from: {}", channel, releases_path.to_string_lossy()); + if !releases_path.exists() { - bail!("Could not find releases file: {}", path.to_string_lossy()); + bail!("Could not find releases file: {}", path.to_string_lossy()); } - - - - todo!(); + let json = fs::read_to_string(&releases_path)?; + let feed: VelopackAssetFeed = serde_json::from_str(&json)?; + process_feed(app, feed, allow_downgrade, non_default_channel) +} + +fn process_feed(app: &Manifest, feed: VelopackAssetFeed, allow_downgrade: bool, is_non_default_channel: bool) -> Result> { + let assets = feed.Assets; + + if assets.is_empty() { + bail!("Zero assets found in releases feed."); + } + + let mut latest: Option = None; + let mut latest_version: Version = Version::parse("0.0.0")?; + for asset in assets { + if let Ok(sv) = Version::parse(&asset.Version) { + debug!("Found asset: {} ({}).", asset.FileName, sv.to_string()); + if latest.is_none() || (sv > latest_version && asset.Type.eq_ignore_ascii_case("Full")) { + latest = Some(asset); + latest_version = sv; + } + } + } + + if latest.is_none() { + bail!("No valid full releases found in feed."); + } + + let remote_version = latest_version; + let remote_asset = latest.unwrap(); + + debug!("Latest remote release: {} ({}).", remote_asset.FileName, remote_version.to_string()); + + let mut result: Option = None; + + if remote_version > app.version { + info!("Found newer remote release available ({} -> {}).", app.version, remote_version); + result = Some(UpdateInfo { TargetFullRelease: remote_asset, IsDowngrade: false }); + } else if remote_version < app.version && allow_downgrade { + info!("Found older remote release available and downgrade is enabled ({} -> {}).", app.version, remote_version); + result = Some(UpdateInfo { TargetFullRelease: remote_asset, IsDowngrade: true }); + } else if remote_version == app.version && allow_downgrade && is_non_default_channel { + info!("Latest remote release is the same version of a different channel, and downgrade is enabled ({} -> {}).", app.version, remote_version); + result = Some(UpdateInfo { TargetFullRelease: remote_asset, IsDowngrade: true }); + } else { + info!("No update available."); + } + + Ok(result) } diff --git a/src/Rust/src/commands/download.rs b/src/Rust/src/commands/download.rs new file mode 100644 index 00000000..9329a1bb --- /dev/null +++ b/src/Rust/src/commands/download.rs @@ -0,0 +1,64 @@ +use crate::{bundle::Manifest, shared}; +use anyhow::{bail, Result}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +pub fn download(root_path: &PathBuf, app: &Manifest, path: &str, clean: bool, name: &str, mut progress: A) -> Result +where + A: FnMut(i16), +{ + if !name.ends_with(".nupkg") { + bail!("Asset name must end with .nupkg"); + } + + let packages_dir_str = app.get_packages_path(root_path); + let packages_dir = Path::new(&packages_dir_str); + let target_file = packages_dir.join(name); + + let mut to_delete = Vec::new(); + + if clean { + let g = format!("{}/*.nupkg", packages_dir_str); + info!("Searching for packages to clean in: '{}'", g); + match glob::glob(&g) { + Ok(paths) => { + for path in paths { + if let Ok(path) = path { + to_delete.push(path); + } + } + } + Err(e) => { + error!("Error while searching for packages to clean: {}", e); + } + } + } + + if shared::is_http_url(path) { + info!("About to download from URL '{}' to file '{}'", path, target_file.to_string_lossy()); + shared::download::download_url_to_file(path, &target_file.to_string_lossy(), &mut progress)?; + } else { + let source_path = Path::new(path); + let source_file = source_path.join(name); + info!("About to copy local file from '{}' to '{}'", source_file.to_string_lossy(), target_file.to_string_lossy()); + + if !source_file.exists() { + bail!("Local file does not exist: {}", source_file.to_string_lossy()); + } + + fs::copy(&source_file, &target_file)?; + } + + info!("Successfully placed file: '{}'", target_file.to_string_lossy()); + + if clean { + for path in to_delete { + info!("Cleaning up old package: '{}'", path.to_string_lossy()); + fs::remove_file(&path)?; + } + } + + Ok(target_file) +} diff --git a/src/Rust/src/commands/mod.rs b/src/Rust/src/commands/mod.rs index ac9ebd82..e25ffdeb 100644 --- a/src/Rust/src/commands/mod.rs +++ b/src/Rust/src/commands/mod.rs @@ -7,6 +7,9 @@ pub use patch::*; mod check; pub use check::*; +mod download; +pub use download::*; + #[cfg(target_os = "linux")] mod apply_linux_impl; #[cfg(target_os = "macos")] diff --git a/src/Rust/src/windows/download.rs b/src/Rust/src/shared/download.rs similarity index 100% rename from src/Rust/src/windows/download.rs rename to src/Rust/src/shared/download.rs diff --git a/src/Rust/src/shared/mod.rs b/src/Rust/src/shared/mod.rs index d6c8fc8e..fc0a8a15 100644 --- a/src/Rust/src/shared/mod.rs +++ b/src/Rust/src/shared/mod.rs @@ -1,4 +1,5 @@ pub mod bundle; +pub mod download; mod dialogs_const; mod dialogs_common; diff --git a/src/Rust/src/shared/util_common.rs b/src/Rust/src/shared/util_common.rs index 39beb868..19a0cd78 100644 --- a/src/Rust/src/shared/util_common.rs +++ b/src/Rust/src/shared/util_common.rs @@ -3,6 +3,13 @@ use rand::distributions::{Alphanumeric, DistString}; use regex::Regex; use std::{path::Path, thread, time::Duration}; +pub fn is_http_url(url: &str) -> bool { + match url::Url::parse(url) { + Ok(url) => url.scheme().eq_ignore_ascii_case("http") || url.scheme().eq_ignore_ascii_case("https"), + _ => false, + } +} + pub fn retry_io(op: F) -> Result where F: Fn() -> Result, diff --git a/src/Rust/src/update.rs b/src/Rust/src/update.rs index 52511059..33da265a 100644 --- a/src/Rust/src/update.rs +++ b/src/Rust/src/update.rs @@ -29,14 +29,20 @@ fn root_command() -> Command { ) .subcommand(Command::new("check") .about("Checks for available updates") - .arg(arg!([PATH] "HTTP URL or path to local folder containing an update source.").required(true)) + .arg(arg!(--url "URL or local folder containing an update source").required(true)) .arg(arg!(--downgrade "Allow version downgrade")) .arg(arg!(--channel "Explicitly switch to a specific channel")) + .arg(arg!(--format "The format of the program output (json|text)").default_value("json")) ) .subcommand(Command::new("download") - .about("Download/copies an available remote file into the packages directory.") - .arg(arg!([PATH] "HTTP URL or path to local folder containing an update source.").required(true)) - .arg(arg!([NAME] "The remote package file to download.").required(true)) + .about("Download/copies an available remote file into the packages directory") + .arg(arg!(--url "URL or local folder containing an update source").required(true)) + .arg(arg!(--name "The name of the asset to download").required(true)) + .arg(arg!(--clean "Delete all other packages if download is successful")) + .arg(arg!(--format "The format of the program output (json|text)").default_value("json")) + ) + .subcommand(Command::new("get-version") + .about("Prints the current version of the application") ) .arg(arg!(--verbose "Print debug messages to console / log").global(true)) .arg(arg!(--nocolor "Disable colored output").hide(true).global(true)) @@ -82,17 +88,24 @@ fn main() -> Result<()> { #[cfg(unix)] let matches = root_command().get_matches(); + let (subcommand, subcommand_matches) = matches.subcommand().ok_or_else(|| anyhow!("No subcommand was used. Try `--help` for more information."))?; + let verbose = matches.get_flag("verbose"); - let silent = matches.get_flag("silent"); + let mut silent = matches.get_flag("silent"); let nocolor = matches.get_flag("nocolor"); let log_file = matches.get_one("log"); - dialogs::set_silent(silent); + // these commands output machine-readable data, so we don't want to show dialogs or logs to stdout + let no_console = subcommand.eq_ignore_ascii_case("check") || subcommand.eq_ignore_ascii_case("download") || subcommand.eq_ignore_ascii_case("get-version"); + if no_console { + silent = true; + } + dialogs::set_silent(silent); if let Some(log_file) = log_file { - logging::setup_logging(Some(&log_file), true, verbose, nocolor)?; + logging::setup_logging(Some(&log_file), !no_console, verbose, nocolor)?; } else { - default_logging(verbose, nocolor)?; + default_logging(verbose, nocolor, !no_console)?; } info!("Starting Velopack Updater ({})", env!("NGBV_VERSION")); @@ -106,14 +119,16 @@ fn main() -> Result<()> { containing_dir.pop(); env::set_current_dir(containing_dir)?; - let (subcommand, subcommand_matches) = matches.subcommand().ok_or_else(|| anyhow!("No subcommand was used. Try `--help` for more information."))?; let result = match subcommand { #[cfg(target_os = "windows")] "uninstall" => uninstall(subcommand_matches).map_err(|e| anyhow!("Uninstall error: {}", e)), #[cfg(target_os = "windows")] - "start" => start(&subcommand_matches).map_err(|e| anyhow!("Start error: {}", e)), + "start" => start(subcommand_matches).map_err(|e| anyhow!("Start error: {}", e)), "apply" => apply(subcommand_matches).map_err(|e| anyhow!("Apply error: {}", e)), "patch" => patch(subcommand_matches).map_err(|e| anyhow!("Patch error: {}", e)), + "check" => check(subcommand_matches).map_err(|e| anyhow!("Check error: {}", e)), + "download" => download(subcommand_matches).map_err(|e| anyhow!("Download error: {}", e)), + "get-version" => get_version(subcommand_matches).map_err(|e| anyhow!("Get-version error: {}", e)), _ => bail!("Unknown subcommand. Try `--help` for more information."), }; @@ -125,6 +140,94 @@ fn main() -> Result<()> { Ok(()) } +fn get_version(_matches: &ArgMatches) -> Result<()> { + let (_, app) = shared::detect_current_manifest()?; + println!("{}", app.version); + Ok(()) +} + +fn check(matches: &ArgMatches) -> Result<()> { + let url = matches.get_one::("url").unwrap(); + let format = matches.get_one::("format").unwrap(); + let allow_downgrade = matches.get_flag("downgrade"); + let channel = matches.get_one::("channel").map(|x| x.as_str()); + let is_json = format.eq_ignore_ascii_case("json"); + + info!("Command: Check"); + info!(" URL: {:?}", url); + info!(" Allow Downgrade: {:?}", allow_downgrade); + info!(" Channel: {:?}", channel); + info!(" Format: {:?}", format); + + // this is a machine readable command, so we write program output to stdout in the desired format + let (_, app) = shared::detect_current_manifest()?; + match commands::check(&app, url, allow_downgrade, channel) { + Ok(opt) => match opt { + Some(info) => { + if is_json { + println!("{}", serde_json::to_string(&info)?); + } else { + let asset = info.TargetFullRelease; + println!("{} {} {} {}", asset.Version, asset.SHA1, asset.FileName, asset.Size); + } + } + _ => println!("null"), + }, + Err(e) => { + if is_json { + println!("{{ \"error\": \"{}\" }}", e); + } else { + println!("err: {}", e); + } + return Err(e); + } + } + Ok(()) +} + +fn download(matches: &ArgMatches) -> Result<()> { + let url = matches.get_one::("url").unwrap(); + let name = matches.get_one::("name").unwrap(); + let format = matches.get_one::("format").unwrap(); + let clean = matches.get_flag("clean"); + let is_json = format.eq_ignore_ascii_case("json"); + + info!("Command: Download"); + info!(" URL: {:?}", url); + info!(" Asset Name: {:?}", name); + info!(" Format: {:?}", format); + info!(" Clean: {:?}", clean); + + // this is a machine readable command, so we write program output to stdout in the desired format + let (root_path, app) = shared::detect_current_manifest()?; + #[cfg(target_os = "windows")] + let _mutex = shared::retry_io(|| windows::create_global_mutex(&app))?; + match commands::download(&root_path, &app, url, clean, name, |p| { + if is_json { + println!("{{ \"progress\": {} }}", p); + } else { + println!("{}", p); + } + }) { + Ok(path) => { + if is_json { + println!("{{ \"complete\": true, \"progress\": 100, \"file\": \"{}\" }}", path.to_string_lossy()); + } else { + println!("complete: {}", path.to_string_lossy()); + } + } + Err(e) => { + if is_json { + println!("{{ \"error\": \"{}\" }}", e); + } else { + println!("err: {}", e); + } + return Err(e); + } + } + Ok(()) +} + fn patch(matches: &ArgMatches) -> Result<()> { let old_file = matches.get_one::("old").unwrap(); let patch_file = matches.get_one::("patch").unwrap(); @@ -184,7 +287,7 @@ fn uninstall(_matches: &ArgMatches) -> Result<()> { commands::uninstall(&root_path, &app, true) } -pub fn default_logging(verbose: bool, nocolor: bool) -> Result<()> { +pub fn default_logging(verbose: bool, nocolor: bool, console: bool) -> Result<()> { #[cfg(windows)] let default_log_file = { let mut my_dir = env::current_exe().unwrap(); @@ -195,7 +298,7 @@ pub fn default_logging(verbose: bool, nocolor: bool) -> Result<()> { #[cfg(unix)] let default_log_file = std::path::Path::new("/tmp/velopack.log").to_path_buf(); - logging::setup_logging(Some(&default_log_file), true, verbose, nocolor) + logging::setup_logging(Some(&default_log_file), console, verbose, nocolor) } #[cfg(target_os = "windows")] diff --git a/src/Rust/src/windows/mod.rs b/src/Rust/src/windows/mod.rs index 859f2808..8f3b5407 100644 --- a/src/Rust/src/windows/mod.rs +++ b/src/Rust/src/windows/mod.rs @@ -1,4 +1,3 @@ -pub mod download; pub mod prerequisite; pub mod runtimes; pub mod splash; diff --git a/src/Rust/src/windows/prerequisite.rs b/src/Rust/src/windows/prerequisite.rs index 242be11d..aa57183e 100644 --- a/src/Rust/src/windows/prerequisite.rs +++ b/src/Rust/src/windows/prerequisite.rs @@ -1,5 +1,5 @@ -use super::{download, runtimes, splash}; -use crate::shared::{bundle, dialogs}; +use super::{runtimes, splash}; +use crate::shared::{bundle, dialogs, download}; use anyhow::Result; use std::path::Path; diff --git a/src/Rust/src/windows/runtimes.rs b/src/Rust/src/windows/runtimes.rs index 2043f795..66b8ffc4 100644 --- a/src/Rust/src/windows/runtimes.rs +++ b/src/Rust/src/windows/runtimes.rs @@ -1,5 +1,5 @@ -use super::download; use crate::shared as util; +use crate::shared::download; use anyhow::{anyhow, bail, Result}; use regex::Regex; use std::process::Command as Process; diff --git a/src/Rust/tests/commands.rs b/src/Rust/tests/commands.rs index 192b22d3..324ad65d 100644 --- a/src/Rust/tests/commands.rs +++ b/src/Rust/tests/commands.rs @@ -9,6 +9,41 @@ use velopack::*; #[cfg(target_os = "windows")] use winsafe::{self as w, co}; +#[test] +pub fn test_check_updates() { + let fixtures = find_fixtures(); + let feedjson = fixtures.join("testfeed.json"); + + let tmp = tempdir().unwrap(); + + // verify that we can't check for updates without a manifest + let mut manifest = bundle::Manifest::default(); + manifest.version = semver::Version::parse("1.0.5").unwrap(); + let result = commands::check(&manifest, &tmp.path().to_string_lossy(), false, Some("stable")); + assert!(result.is_err()); + + // we should find a version greater than ours + fs::copy(feedjson, tmp.path().join("releases.stable.json")).unwrap(); + let result = commands::check(&manifest, &tmp.path().to_string_lossy(), false, Some("stable")).unwrap(); + assert!(result.is_some()); + assert!(semver::Version::parse(&result.unwrap().TargetFullRelease.Version).unwrap() == semver::Version::parse("1.0.11").unwrap()); + + // we should not find a version equal to ours + manifest.version = semver::Version::parse("1.0.11").unwrap(); + let result = commands::check(&manifest, &tmp.path().to_string_lossy(), false, Some("stable")).unwrap(); + assert!(result.is_none()); + + // we should not find a version less than ours + manifest.version = semver::Version::parse("1.0.20").unwrap(); + let result = commands::check(&manifest, &tmp.path().to_string_lossy(), false, Some("stable")).unwrap(); + assert!(result.is_none()); + + // if downgrade is allowed, we should find a version less than ours + let result = commands::check(&manifest, &tmp.path().to_string_lossy(), true, Some("stable")).unwrap(); + assert!(result.is_some()); + assert!(semver::Version::parse(&result.unwrap().TargetFullRelease.Version).unwrap() == semver::Version::parse("1.0.11").unwrap()); +} + #[cfg(target_os = "windows")] #[test] pub fn test_install_apply_uninstall() { diff --git a/test/fixtures/testfeed.json b/test/fixtures/testfeed.json index 1fa90fb1..efd6a87e 100644 --- a/test/fixtures/testfeed.json +++ b/test/fixtures/testfeed.json @@ -4,6 +4,7 @@ "PackageId": "AvaloniaCrossPlat", "Version": "1.0.11", "Type": "Full", + "UnknownProperty": "To test that serializers will ignore this", "FileName": "AvaloniaCrossPlat-1.0.11-full.nupkg", "SHA1": "D9F1CE7DE35D9544DF65AE6A5674D1A2D7EE5EAC", "Size": 14763516,