Add legacy migration code & tests

This commit is contained in:
Caelan Sayler
2024-01-04 13:49:41 +00:00
parent d8a969816a
commit acfb8c2ecc
9 changed files with 226 additions and 20 deletions

View File

@@ -12,6 +12,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SolutionLevel", "SolutionLe
.editorconfig = .editorconfig
.github\workflows\build.yml = .github\workflows\build.yml
src\Directory.Build.props = src\Directory.Build.props
test\Directory.Build.props = test\Directory.Build.props
README.md = README.md
Velopack.entitlements = Velopack.entitlements
version.json = version.json

View File

@@ -18,10 +18,12 @@ pub fn apply<'a>(
noelevate: bool,
) -> Result<()> {
if wait_for_parent {
let _ = shared::wait_for_parent_to_exit(60_000); // 1 minute
if let Err(e) = shared::wait_for_parent_to_exit(60_000) {
warn!("Failed to wait for parent process to exit ({}).", e);
}
}
if let Err(e) = apply_package(&root_path, &app, restart, package, exe_args.clone(), noelevate) {
if let Err(e) = apply_package_impl(&root_path, &app, restart, package, exe_args.clone(), noelevate) {
error!("Error applying package: {}", e);
if !restart {
return Err(e);
@@ -36,7 +38,7 @@ pub fn apply<'a>(
Ok(())
}
fn apply_package<'a>(
fn apply_package_impl<'a>(
root_path: &PathBuf,
app: &Manifest,
restart: bool,
@@ -84,7 +86,11 @@ fn apply_package<'a>(
let found_version = (&package_manifest.version).to_owned();
if found_version <= app.version {
if package.is_none() {
bail!("Latest package found is {}, which is not newer than current version {}.", found_version, app.version);
} else {
warn!("Provided package is {}, which is not newer than current version {}.", found_version, app.version);
}
}
#[cfg(target_os = "windows")]

View File

@@ -1,6 +1,14 @@
use crate::shared;
use anyhow::{bail, Result};
use std::path::Path;
use crate::{
dialogs,
shared::{self, bundle},
windows,
};
use anyhow::{anyhow, bail, Result};
use std::{
fs,
path::{Path, PathBuf},
};
use winsafe::{self as w, co};
pub fn start(wait_for_parent: bool, exe_name: Option<&String>, exe_args: Option<Vec<&str>>, legacy_args: Option<&String>) -> Result<()> {
if legacy_args.is_some() && exe_args.is_some() {
@@ -8,16 +16,47 @@ pub fn start(wait_for_parent: bool, exe_name: Option<&String>, exe_args: Option<
}
if wait_for_parent {
shared::wait_for_parent_to_exit(60_000)?; // 1 minute
if let Err(e) = shared::wait_for_parent_to_exit(60_000) {
warn!("Failed to wait for parent process to exit ({}).", e);
}
}
let (root_path, app) = shared::detect_current_manifest()?;
let (root_dir, app) = shared::detect_current_manifest()?;
let current = app.get_current_path(&root_path);
match shared::has_app_prefixed_folder(&root_dir) {
Ok(has_prefix) => {
if has_prefix {
info!("This is a legacy app. Will try and upgrade it now.");
// if started by legacy Squirrel, the working dir of Update.exe may be inside the app-* folder,
// meaning we can not clean up properly.
std::env::set_current_dir(&root_dir)?;
if let Err(e) = try_legacy_migration(&root_dir, &app) {
warn!("Failed to migrate legacy app ({}).", e);
dialogs::show_error(
&app.title,
Some("Unable to start app"),
"This app installation has been corrupted and cannot be started. Please reinstall the app.",
);
return Err(e);
}
// we can't run the normal start command, because legacy squirrel might provide an "exe name" to restart
// which no longer exists in the package
let (root_dir, app) = shared::detect_current_manifest()?;
shared::start_package(&app, &root_dir, exe_args, Some("VELOPACK_RESTART"))?;
return Ok(());
}
}
Err(e) => warn!("Failed legacy check ({}).", e),
}
let current = app.get_current_path(&root_dir);
let exe_to_execute = if let Some(exe) = exe_name {
Path::new(&current).join(exe)
} else {
let exe = app.get_main_exe_path(&root_path);
let exe = app.get_main_exe_path(&root_dir);
Path::new(&exe).to_path_buf()
};
@@ -39,3 +78,42 @@ pub fn start(wait_for_parent: bool, exe_name: Option<&String>, exe_args: Option<
Ok(())
}
fn try_legacy_migration(root_dir: &PathBuf, app: &bundle::Manifest) -> Result<()> {
let package = shared::find_latest_full_package(&root_dir).ok_or_else(|| anyhow!("Unable to find latest full package."))?;
let bundle = bundle::load_bundle_from_file(&package.file_path)?;
let _bundle_manifest = bundle.read_manifest()?; // this verifies it's a bundle we support
warn!("This application is installed in a folder prefixed with 'app-'. Attempting to migrate...");
let _ = shared::force_stop_package(&root_dir);
let current_dir = app.get_current_path(&root_dir);
let main_exe_path = app.get_main_exe_path(&root_dir);
if !Path::new(&current_dir).exists() {
info!("Renaming latest app-* folder to current.");
if let Some((latest_app_dir, _latest_ver)) = shared::get_latest_app_version_folder(&root_dir)? {
fs::rename(&latest_app_dir, &current_dir)?;
}
}
info!("Applying latest full package...");
let buf = Path::new(&package.file_path).to_path_buf();
super::apply(&root_dir, &app, false, false, Some(&buf), None, true)?;
info!("Removing old app-* folders...");
shared::delete_app_prefixed_folders(&root_dir)?;
let _ = remove_dir_all::remove_dir_all(root_dir.join("staging"));
info!("Removing old shortcuts...");
if let Err(e) = windows::remove_all_shortcuts_for_root_dir(&root_dir) {
warn!("Failed to remove shortcuts ({}).", e);
}
info!("Creating start menu shortcut...");
let startmenu = w::SHGetKnownFolderPath(&co::KNOWNFOLDERID::StartMenu, co::KF::DONT_UNEXPAND, None)?;
let lnk_path = Path::new(&startmenu).join("Programs").join(format!("{}.lnk", &app.title));
if let Err(e) = windows::create_lnk(&lnk_path.to_string_lossy(), &main_exe_path, &current_dir, None) {
warn!("Failed to create start menu shortcut: {}", e);
}
Ok(())
}

View File

@@ -78,8 +78,9 @@ pub struct BundleInfo<'a> {
file_path: Option<PathBuf>,
}
pub fn load_bundle_from_file<'a>(file_name: &PathBuf) -> Result<BundleInfo<'a>> {
debug!("Loading bundle from file '{}'...", file_name.display());
pub fn load_bundle_from_file<'a, P: AsRef<Path>>(file_name: P) -> Result<BundleInfo<'a>> {
let file_name = file_name.as_ref();
debug!("Loading bundle from file '{}'...", file_name.to_string_lossy());
let file = super::retry_io(|| File::open(&file_name))?;
let cursor: Box<dyn ReadSeek> = Box::new(file);
let zip = ZipArchive::new(cursor)?;

View File

@@ -1,12 +1,15 @@
use anyhow::{anyhow, bail, Result};
use regex::Regex;
use semver::Version;
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
process::Command as Process,
};
use winsafe::{self as w, co, prelude::*};
use super::bundle::{self, Manifest, EntryNameInfo};
use super::bundle::{self, EntryNameInfo, Manifest};
pub fn wait_for_parent_to_exit(ms_to_wait: u32) -> Result<()> {
info!("Reading parent process information.");
@@ -105,9 +108,7 @@ fn kill_pid(pid: u32) -> Result<()> {
pub fn force_stop_package<P: AsRef<Path>>(root_dir: P) -> Result<()> {
let root_dir = root_dir.as_ref();
super::retry_io(|| {
_force_stop_package(root_dir)
})?;
super::retry_io(|| _force_stop_package(root_dir))?;
Ok(())
}
@@ -170,6 +171,72 @@ pub fn detect_current_manifest() -> Result<(PathBuf, Manifest)> {
detect_manifest_from_update_path(&me)
}
pub fn get_app_prefixed_folders<P: AsRef<Path>>(parent_path: P) -> Result<Vec<PathBuf>> {
let parent_path = parent_path.as_ref();
let re = Regex::new(r"(?i)^app-")?;
let mut folders = Vec::new();
// Squirrel.Windows and Clowd.Squirrel V2
for entry in fs::read_dir(parent_path)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if re.is_match(name) {
folders.push(path);
}
}
}
}
// Clowd.Squirrel V3
let staging_dir = parent_path.join("staging");
if staging_dir.exists() {
for entry in fs::read_dir(&staging_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if re.is_match(name) {
folders.push(path);
}
}
}
}
}
Ok(folders)
}
pub fn get_latest_app_version_folder<P: AsRef<Path>>(parent_path: P) -> Result<Option<(PathBuf, Version)>> {
let mut latest_version: Option<Version> = None;
let mut latest_folder: Option<PathBuf> = None;
for entry in get_app_prefixed_folders(&parent_path)? {
if let Some(name) = entry.file_name().and_then(|n| n.to_str()) {
if let Some(version) = parse_version_from_folder_name(name) {
if latest_version.is_none() || version > latest_version.clone().unwrap() {
latest_version = Some(version);
latest_folder = Some(entry);
}
}
}
}
Ok(latest_folder.zip(latest_version))
}
pub fn has_app_prefixed_folder<P: AsRef<Path>>(parent_path: P) -> Result<bool> {
Ok(!get_app_prefixed_folders(parent_path)?.is_empty())
}
pub fn delete_app_prefixed_folders<P: AsRef<Path>>(parent_path: P) -> Result<()> {
let folders = get_app_prefixed_folders(parent_path)?;
for folder in folders {
super::retry_io(|| remove_dir_all::remove_dir_all(&folder))?;
}
Ok(())
}
fn parse_version_from_folder_name(folder_name: &str) -> Option<Version> {
folder_name.strip_prefix("app-").and_then(|v| Version::parse(v).ok())
}
fn find_manifest_from_root_dir(root_path: &PathBuf) -> Result<Manifest> {
// default to checking current/sq.version
let cm = find_current_manifest(root_path);
@@ -178,6 +245,7 @@ fn find_manifest_from_root_dir(root_path: &PathBuf) -> Result<Manifest> {
}
// if that fails, check for latest full package
warn!("Unable to find current manifest, checking for latest full package. (LEGACY MODE)");
let latest = find_latest_full_package(root_path);
if let Some(latest) = latest {
let mani = latest.load_manifest()?;
@@ -198,7 +266,7 @@ fn find_current_manifest(root_path: &PathBuf) -> Result<Manifest> {
bail!("Unable to read nuspec file in current directory.")
}
fn find_latest_full_package(root_path: &PathBuf) -> Option<EntryNameInfo> {
pub fn find_latest_full_package(root_path: &PathBuf) -> Option<EntryNameInfo> {
let packages = get_all_packages(root_path);
let mut latest: Option<EntryNameInfo> = None;
for pkg in packages {

View File

@@ -31,6 +31,7 @@ fn root_command() -> Command {
.arg(arg!(--nocolor "Disable colored output").hide(true).global(true))
.arg(arg!(-s --silent "Don't show any prompts / dialogs").global(true))
.arg(arg!(-l --log <PATH> "Override the default log file location").global(true).value_parser(value_parser!(PathBuf)))
.arg(arg!(--forceLatest "Legacy / not used").hide(true))
.disable_help_subcommand(true)
.flatten_help(true);

View File

@@ -166,9 +166,13 @@ namespace Velopack
VelopackHook defaultBlock = ((v) => { });
var fastExitlookup = new[] {
new { Key = "--veloapp-install", Value = _install ?? defaultBlock },
new { Key = "--squirrel-install", Value = _install ?? defaultBlock },
new { Key = "--veloapp-updated", Value = _update ?? defaultBlock },
new { Key = "--squirrel-updated", Value = _update ?? defaultBlock },
new { Key = "--veloapp-obsolete", Value = _obsolete ?? defaultBlock },
new { Key = "--squirrel-obsolete", Value = _obsolete ?? defaultBlock },
new { Key = "--veloapp-uninstall", Value = _uninstall ?? defaultBlock },
new { Key = "--squirrel-uninstall", Value = _uninstall ?? defaultBlock },
}.ToDictionary(k => k.Key, v => v.Value, StringComparer.OrdinalIgnoreCase);
if (args.Length >= 2 && fastExitlookup.ContainsKey(args[0])) {
try {

View File

@@ -16,7 +16,7 @@
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<IsTest>true</IsTest>
<NoWarn>$(NoWarn);CS1998,xUnit2015,xUnit2017,xUnit2005,xUnit2009,xUnit2013,xUnit2004;CA2007;CS8002</NoWarn>
<NoWarn>$(NoWarn);CS1998,xUnit2015,xUnit2017,xUnit2005,xUnit2009,xUnit2013,xUnit1013,xUnit2004;CA2007;CS8002</NoWarn>
<SignAssembly>True</SignAssembly>
<AssemblyOriginatorKeyFile>..\..\Velopack.snk</AssemblyOriginatorKeyFile>
<IsPackable>false</IsPackable>

View File

@@ -372,7 +372,7 @@ public class WindowsPackTests
RunCoveredDotnet(appPath, new string[] { "--autoupdate" }, installDir, logger, exitCode: null);
Thread.Sleep(2000);
Thread.Sleep(3000); // update.exe runs in separate process
var chk1version = RunCoveredDotnet(appPath, new string[] { "version" }, installDir, logger);
Assert.EndsWith(Environment.NewLine + "2.0.0", chk1version);
@@ -551,6 +551,53 @@ public class WindowsPackTests
logger.Info("TEST: uninstalled / complete");
}
[SkippableTheory]
[InlineData("LegacyTestApp-ClowdV2-Setup.exe", "app-1.0.0")]
[InlineData("LegacyTestApp-ClowdV3-Setup.exe", "current")]
[InlineData("LegacyTestApp-SquirrelWinV2-Setup.exe", "app-1.0.0")]
public void LegacyAppCanSuccessfullyMigrate(string fixture, string origDirName)
{
Skip.IfNot(VelopackRuntimeInfo.IsWindows);
using var logger = _output.BuildLoggerFor<WindowsPackTests>();
var rootDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LegacyTestApp");
if (Directory.Exists(rootDir))
Utility.DeleteFileOrDirectoryHard(rootDir);
var setup = PathHelper.GetFixture(fixture);
var p = Process.Start(setup);
p.WaitForExit();
var currentDir = Path.Combine(rootDir, origDirName);
var appExe = Path.Combine(currentDir, "LegacyTestApp.exe");
var updateExe = Path.Combine(rootDir, "Update.exe");
Assert.True(File.Exists(appExe));
Assert.True(File.Exists(updateExe));
using var _1 = Utility.GetTempDirectory(out var releaseDir);
PackTestApp("LegacyTestApp", "2.0.0", "hello!", releaseDir, logger);
RunNoCoverage(appExe, new string[] { "download", releaseDir }, currentDir, logger, exitCode: 0);
RunNoCoverage(appExe, new string[] { "apply", releaseDir }, currentDir, logger, exitCode: null);
Thread.Sleep(3000); // update.exe runs in a separate process here
if (origDirName != "current") {
Assert.True(!Directory.Exists(currentDir));
currentDir = Path.Combine(rootDir, "current");
}
Assert.True(Directory.Exists(currentDir));
appExe = Path.Combine(currentDir, "TestApp.exe");
Assert.True(File.Exists(appExe));
Assert.False(Directory.EnumerateDirectories(rootDir, "app-*").Any());
Assert.False(Directory.Exists(Path.Combine(rootDir, "staging")));
// this is the file written by TestApp when it's detected the squirrel restart. if this is here, everything went smoothly.
Assert.True(File.Exists(Path.Combine(rootDir, "restarted")));
}
//private string RunCoveredRust(string binName, string[] args, string workingDir, ILogger logger, int? exitCode = 0)
//{
// var outputfile = GetPath($"coverage.runrust.{RandomString(8)}.xml");