Add native check/download commands

This commit is contained in:
Caelan Sayler
2024-02-04 00:07:18 +00:00
parent da9e244037
commit b1fbb5f656
14 changed files with 382 additions and 40 deletions

19
src/Rust/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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<String>) -> 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<VelopackAsset>,
}
fn check_url(root_path: &PathBuf, app: &Manifest, path: Url, allow_downgrade: bool, channel: Option<String>) -> 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<Option<UpdateInfo>> {
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<Option<UpdateInfo>> {
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<String>) -> Result<()> {
let channel = channel.unwrap_or(app.channel.clone());
fn check_dir(app: &Manifest, path: PathBuf, allow_downgrade: bool, channel: Option<&str>) -> Result<Option<UpdateInfo>> {
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<Option<UpdateInfo>> {
let assets = feed.Assets;
if assets.is_empty() {
bail!("Zero assets found in releases feed.");
}
let mut latest: Option<VelopackAsset> = 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<UpdateInfo> = 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)
}

View File

@@ -0,0 +1,64 @@
use crate::{bundle::Manifest, shared};
use anyhow::{bail, Result};
use std::{
fs,
path::{Path, PathBuf},
};
pub fn download<A>(root_path: &PathBuf, app: &Manifest, path: &str, clean: bool, name: &str, mut progress: A) -> Result<PathBuf>
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)
}

View File

@@ -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")]

View File

@@ -1,4 +1,5 @@
pub mod bundle;
pub mod download;
mod dialogs_const;
mod dialogs_common;

View File

@@ -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<F, T, E>(op: F) -> Result<T, E>
where
F: Fn() -> Result<T, E>,

View File

@@ -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> "URL or local folder containing an update source").required(true))
.arg(arg!(--downgrade "Allow version downgrade"))
.arg(arg!(--channel <NAME> "Explicitly switch to a specific channel"))
.arg(arg!(--format <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> "URL or local folder containing an update source").required(true))
.arg(arg!(--name <NAME> "The name of the asset to download").required(true))
.arg(arg!(--clean "Delete all other packages if download is successful"))
.arg(arg!(--format <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::<String>("url").unwrap();
let format = matches.get_one::<String>("format").unwrap();
let allow_downgrade = matches.get_flag("downgrade");
let channel = matches.get_one::<String>("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::<String>("url").unwrap();
let name = matches.get_one::<String>("name").unwrap();
let format = matches.get_one::<String>("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::<PathBuf>("old").unwrap();
let patch_file = matches.get_one::<PathBuf>("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")]

View File

@@ -1,4 +1,3 @@
pub mod download;
pub mod prerequisite;
pub mod runtimes;
pub mod splash;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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,