Add self-updating feature (closes #2)

This commit is contained in:
James 2018-08-09 15:21:50 +10:00
parent b6fba61080
commit 351c4c7c1f
10 changed files with 344 additions and 22 deletions

View File

@ -60,6 +60,9 @@ impl BaseAttributes {
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config { pub struct Config {
pub installing_message: String, pub installing_message: String,
/// URL to a new updater, if required
#[serde(default)]
pub new_tool: Option<String>,
pub packages: Vec<PackageDescription>, pub packages: Vec<PackageDescription>,
} }

View File

@ -12,7 +12,7 @@ use reqwest::Client;
/// Builds a customised HTTP client. /// Builds a customised HTTP client.
pub fn build_client() -> Result<Client, String> { pub fn build_client() -> Result<Client, String> {
Client::builder() Client::builder()
.timeout(Duration::from_secs(5)) .timeout(Duration::from_secs(8))
.build() .build()
.map_err(|x| format!("Unable to build cient: {:?}", x)) .map_err(|x| format!("Unable to build cient: {:?}", x))
} }

View File

@ -5,7 +5,9 @@
use serde_json; use serde_json;
use std::fs::File; use std::fs::File;
use std::fs::OpenOptions;
use std::env;
use std::env::var; use std::env::var;
use std::path::Path; use std::path::Path;
@ -13,6 +15,12 @@ use std::path::PathBuf;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use std::io::copy;
use std::io::Cursor;
use std::process::exit;
use std::process::Command;
use config::BaseAttributes; use config::BaseAttributes;
use config::Config; use config::Config;
@ -20,6 +28,7 @@ use sources::types::Version;
use tasks::install::InstallTask; use tasks::install::InstallTask;
use tasks::uninstall::UninstallTask; use tasks::uninstall::UninstallTask;
use tasks::uninstall_global_shortcut::UninstallGlobalShortcutsTask;
use tasks::DependencyTree; use tasks::DependencyTree;
use logging::LoggingErrors; use logging::LoggingErrors;
@ -27,7 +36,10 @@ use logging::LoggingErrors;
use dirs::home_dir; use dirs::home_dir;
use std::fs::remove_file; use std::fs::remove_file;
use tasks::uninstall_global_shortcut::UninstallGlobalShortcutsTask;
use http;
use number_prefix::{decimal_prefix, Prefixed, Standalone};
/// A message thrown during the installation of packages. /// A message thrown during the installation of packages.
#[derive(Serialize)] #[derive(Serialize)]
@ -206,6 +218,122 @@ impl InstallerFramework {
Ok(()) Ok(())
} }
/// Verifies that the config has all requirements met (no need to update the
/// updater, for example). This will terminate if this is the case after applying
/// the correct actions.
pub fn update_updater(&mut self, messages: &Sender<InstallMessage>) -> Result<(), String> {
let tool = self
.config
.as_ref()
.log_expect("Config should exist by now")
.new_tool
.as_ref()
.log_expect("Frontend asked for updater update when one doesn't exist");
let mut downloaded = 0;
let mut data_storage: Vec<u8> = Vec::new();
http::stream_file(tool, |data, size| {
{
data_storage.extend_from_slice(&data);
}
downloaded += data.len();
let percentage = if size == 0 {
0.0
} else {
(downloaded as f64) / (size as f64)
};
// Pretty print data volumes
let pretty_current = match decimal_prefix(downloaded as f64) {
Standalone(bytes) => format!("{} bytes", bytes),
Prefixed(prefix, n) => format!("{:.0} {}B", n, prefix),
};
let pretty_total = match decimal_prefix(size as f64) {
Standalone(bytes) => format!("{} bytes", bytes),
Prefixed(prefix, n) => format!("{:.0} {}B", n, prefix),
};
if let Err(v) = messages.send(InstallMessage::Status(
format!(
"Downloading self-update ({} of {})...",
pretty_current, pretty_total
),
percentage as _,
)) {
error!("Failed to submit queue message: {:?}", v);
}
})?;
info!("Launching new updater...");
// Save to file in current dir
let current_exe = env::current_exe().log_expect("Current executable could not be found");
let path = current_exe
.parent()
.log_expect("Parent directory of executable could not be found");
let platform_extension = if cfg!(windows) {
"maintenancetool_new.exe"
} else {
"maintenancetool_new"
};
let new_app = path.join(platform_extension);
let mut file_metadata = OpenOptions::new();
file_metadata.write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
file_metadata.mode(0o770);
}
{
let mut new_app_file = match file_metadata.open(&new_app) {
Ok(v) => v,
Err(v) => return Err(format!("Unable to open installer binary: {:?}", v)),
};
if let Err(v) = copy(&mut Cursor::new(data_storage), &mut new_app_file) {
return Err(format!("Unable to copy installer binary: {:?}", v));
}
}
// Save current command line arguments
let args_file = path.join("args.json");
let args: Vec<String> = env::args_os()
.map(|x| {
x.to_str()
.log_expect("Unable to convert argument to String")
.to_string()
}).collect();
{
let new_app_file = match File::create(&args_file) {
Ok(v) => v,
Err(v) => return Err(format!("Unable to open args file: {:?}", v)),
};
serde_json::to_writer(new_app_file, &args).log_expect("Unable to write args");
}
let current_exe = env::current_exe().log_expect("Current executable could not be found");
// Launch this new process
Command::new(new_app)
.arg("--swap")
.arg(current_exe)
.spawn()
.log_expect("Unable to start child process");
exit(0);
}
/// Saves the applications database. /// Saves the applications database.
pub fn save_database(&self) -> Result<(), String> { pub fn save_database(&self) -> Result<(), String> {
// We have to have a install path for us to be able to do anything // We have to have a install path for us to be able to do anything

View File

@ -61,12 +61,21 @@ use nfd::Response;
use rest::WebServer; use rest::WebServer;
use std::net::TcpListener;
use std::net::ToSocketAddrs; use std::net::ToSocketAddrs;
use std::net::TcpListener;
use std::sync::Arc; use std::sync::Arc;
use std::sync::RwLock; use std::sync::RwLock;
use std::path::PathBuf;
use std::process::exit;
use std::process::Command;
use std::{thread, time};
use std::fs::remove_file;
use std::fs::File;
use logging::LoggingErrors; use logging::LoggingErrors;
use clap::App; use clap::App;
@ -91,16 +100,26 @@ fn main() {
let app_name = config.name.clone(); let app_name = config.name.clone();
let matches = App::new(format!("{} installer", app_name)) let app_about = format!("An interactive installer for {}", app_name);
let app = App::new(format!("{} installer", app_name))
.version(env!("CARGO_PKG_VERSION")) .version(env!("CARGO_PKG_VERSION"))
.about(format!("An interactive installer for {}", app_name).as_ref()) .about(app_about.as_ref())
.arg( .arg(
Arg::with_name("launcher") Arg::with_name("launcher")
.long("launcher") .long("launcher")
.value_name("TARGET") .value_name("TARGET")
.help("Launches the specified executable after checking for updates") .help("Launches the specified executable after checking for updates")
.takes_value(true), .takes_value(true),
).get_matches(); ).arg(
Arg::with_name("swap")
.long("swap")
.value_name("TARGET")
.help("Internal usage - swaps around a new installer executable")
.takes_value(true),
);
let reinterpret_app = app.clone(); // In case a reparse is needed
let mut matches = app.get_matches();
info!("{} installer", app_name); info!("{} installer", app_name);
@ -108,6 +127,66 @@ fn main() {
let current_path = current_exe let current_path = current_exe
.parent() .parent()
.log_expect("Parent directory of executable could not be found"); .log_expect("Parent directory of executable could not be found");
// Check to see if we are currently in a self-update
if let Some(to_path) = matches.value_of("swap") {
let to_path = PathBuf::from(to_path);
// Sleep a little bit to allow Windows to close the previous file handle
thread::sleep(time::Duration::from_millis(3000));
info!(
"Swapping installer from {} to {}",
current_exe.display(),
to_path.display()
);
if cfg!(windows) {
use std::fs::copy;
copy(&current_exe, &to_path).log_expect("Unable to copy new installer");
} else {
use std::fs::rename;
rename(&current_exe, &to_path).log_expect("Unable to move new installer");
}
Command::new(to_path)
.spawn()
.log_expect("Unable to start child process");
exit(0);
}
let args_file = current_path.join("args.json");
if args_file.exists() {
let database: Vec<String> = {
let metadata_file =
File::open(&args_file).log_expect("Unable to open args file handle");
serde_json::from_reader(metadata_file).log_expect("Unable to read metadata file")
};
matches = reinterpret_app.get_matches_from(database);
info!("Reparsed command line arguments from original instance");
remove_file(args_file).log_expect("Unable to clean up args file");
if cfg!(windows) {
let updater_executable = current_path.join("maintenancetool.exe");
// Sleep a little bit to allow Windows to close the previous file handle
thread::sleep(time::Duration::from_millis(3000));
if updater_executable.exists() {
remove_file(updater_executable)
.log_expect("Unable to clean up previous updater file");
}
}
}
// Load in metadata as to learn about the environment
let metadata_file = current_path.join("metadata.json"); let metadata_file = current_path.join("metadata.json");
let mut framework = if metadata_file.exists() { let mut framework = if metadata_file.exists() {
info!("Using pre-existing metadata file: {:?}", metadata_file); info!("Using pre-existing metadata file: {:?}", metadata_file);

View File

@ -11,7 +11,6 @@ mod natives {
use logging::LoggingErrors; use logging::LoggingErrors;
use std::env; use std::env;
use std::path::PathBuf;
use std::process::Command; use std::process::Command;
include!(concat!(env!("OUT_DIR"), "/interop.rs")); include!(concat!(env!("OUT_DIR"), "/interop.rs"));
@ -63,7 +62,12 @@ mod natives {
} }
/// Cleans up the installer /// Cleans up the installer
pub fn burn_on_exit(path: &PathBuf) { pub fn burn_on_exit() {
let current_exe = env::current_exe().log_expect("Current executable could not be found");
let path = current_exe
.parent()
.log_expect("Parent directory of executable could not be found");
// Need a cmd workaround here. // Need a cmd workaround here.
let tool = path.join("maintenancetool.exe"); let tool = path.join("maintenancetool.exe");
let tool = tool let tool = tool
@ -77,7 +81,7 @@ mod natives {
.log_expect("Unable to convert log path to string") .log_expect("Unable to convert log path to string")
.replace(" ", "\\ "); .replace(" ", "\\ ");
let target_arguments = format!("ping 127.0.0.1 -n 6 > nul && del {} {}", tool, log); let target_arguments = format!("ping 127.0.0.1 -n 3 > nul && del {} {}", tool, log);
info!("Launching cmd with {:?}", target_arguments); info!("Launching cmd with {:?}", target_arguments);
@ -108,7 +112,13 @@ mod natives {
} }
/// Cleans up the installer /// Cleans up the installer
pub fn burn_on_exit(path: &PathBuf) { pub fn burn_on_exit() {
let current_exe =
std::env::current_exe().log_expect("Current executable could not be found");
let path = current_exe
.parent()
.log_expect("Parent directory of executable could not be found");
// Thank god for *nix platforms // Thank god for *nix platforms
if let Err(e) = remove_file(path.join("/maintenancetool")) { if let Err(e) = remove_file(path.join("/maintenancetool")) {
// No regular logging now. // No regular logging now.

View File

@ -207,12 +207,7 @@ impl Service for WebService {
} }
if framework.burn_after_exit { if framework.burn_after_exit {
let path = framework native::burn_on_exit();
.install_path
.as_ref()
.log_expect("No install path when one should have existed?");
native::burn_on_exit(path);
} }
exit(0); exit(0);
@ -289,6 +284,61 @@ impl Service for WebService {
.with_body(rx) .with_body(rx)
})); }));
} }
// Updates the installer
(&Post, "/api/update-updater") => {
// We need to bit of pipelining to get this to work
let framework = self.framework.clone();
return Box::new(req.body().concat2().map(move |_b| {
let (sender, receiver) = channel();
let (tx, rx) = hyper::Body::pair();
// Startup a thread to do this operation for us
thread::spawn(move || {
let mut framework = framework
.write()
.log_expect("InstallerFramework has been dirtied");
if let Err(v) = framework.update_updater(&sender) {
error!("Self-update error occurred: {:?}", v);
if let Err(v) = sender.send(InstallMessage::Error(v)) {
error!("Failed to send self-update error: {:?}", v);
};
}
if let Err(v) = sender.send(InstallMessage::EOF) {
error!("Failed to send EOF to client: {:?}", v);
}
});
// Spawn a thread for transforming messages to chunk messages
thread::spawn(move || {
let mut tx = tx;
loop {
let response = receiver
.recv()
.log_expect("Failed to receive message from runner thread");
if let InstallMessage::EOF = response {
break;
}
let mut response = serde_json::to_string(&response)
.log_expect("Failed to render JSON logging response payload");
response.push('\n');
tx = tx
.send(Ok(response.into_bytes().into()))
.wait()
.log_expect("Failed to write JSON response payload to client");
}
});
Response::<hyper::Body>::new()
//.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::plaintext())
.with_body(rx)
}));
}
// Streams the installation of a particular set of packages // Streams the installation of a particular set of packages
(&Post, "/api/start-install") => { (&Post, "/api/start-install") => {
// We need to bit of pipelining to get this to work // We need to bit of pipelining to get this to work

View File

@ -22,6 +22,13 @@ body, div, span, h1, h2, h3, h4, h5, h6 {
cursor: default; cursor: default;
} }
pre {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
}
.tile.is-child > .box { .tile.is-child > .box {
height: 100%; height: 100%;
} }

View File

@ -19,6 +19,8 @@ function ajax(path, successCallback, failCallback, data) {
failCallback = defaultFailHandler; failCallback = defaultFailHandler;
} }
console.log("Making HTTP request to " + path);
var req = new XMLHttpRequest(); var req = new XMLHttpRequest();
req.addEventListener("load", function() { req.addEventListener("load", function() {
@ -69,6 +71,8 @@ function ajax(path, successCallback, failCallback, data) {
function stream_ajax(path, callback, successCallback, failCallback, data) { function stream_ajax(path, callback, successCallback, failCallback, data) {
var req = new XMLHttpRequest(); var req = new XMLHttpRequest();
console.log("Making streaming HTTP request to " + path);
req.addEventListener("load", function() { req.addEventListener("load", function() {
// The server can sometimes return a string error. Make sure we handle this. // The server can sometimes return a string error. Make sure we handle this.
if (this.status === 200) { if (this.status === 200) {

View File

@ -26,6 +26,17 @@ for (var i = 0; i < methods.length; i++) {
intercept(methods[i]); intercept(methods[i]);
} }
// Disable F5
function disable_shortcuts(e) {
switch (e.keyCode) {
case 116: // F5
e.preventDefault();
break;
}
}
window.addEventListener("keydown", disable_shortcuts);
document.getElementById("window-title").innerText = base_attributes.name + " Installer"; document.getElementById("window-title").innerText = base_attributes.name + " Installer";
function selectFileCallback(name) { function selectFileCallback(name) {

View File

@ -44,6 +44,12 @@ const DownloadConfig = {
}); });
}, },
choose_next_state: function() { choose_next_state: function() {
// Update the updater if needed
if (app.config.new_tool) {
router.push("/install/updater");
return;
}
if (app.metadata.preexisting_install) { if (app.metadata.preexisting_install) {
app.install_location = app.metadata.install_path; app.install_location = app.metadata.install_path;
@ -153,6 +159,7 @@ const InstallPackages = {
<div class="column has-padding"> <div class="column has-padding">
<h4 class="subtitle" v-if="$root.$data.metadata.is_launcher">Checking for updates...</h4> <h4 class="subtitle" v-if="$root.$data.metadata.is_launcher">Checking for updates...</h4>
<h4 class="subtitle" v-else-if="is_uninstall">Uninstalling...</h4> <h4 class="subtitle" v-else-if="is_uninstall">Uninstalling...</h4>
<h4 class="subtitle" v-else-if="is_updater_update">Downloading update for updater...</h4>
<h4 class="subtitle" v-else>Installing...</h4> <h4 class="subtitle" v-else>Installing...</h4>
<div v-html="$root.$data.config.installing_message"></div> <div v-html="$root.$data.config.installing_message"></div>
<br /> <br />
@ -168,11 +175,14 @@ const InstallPackages = {
progress: 0.0, progress: 0.0,
progress_message: "Please wait...", progress_message: "Please wait...",
is_uninstall: false, is_uninstall: false,
is_updater_update: false,
failed_with_error: false failed_with_error: false
} }
}, },
created: function() { created: function() {
this.is_uninstall = this.$route.params.kind === "uninstall"; this.is_uninstall = this.$route.params.kind === "uninstall";
this.is_updater_update = this.$route.params.kind === "updater";
console.log("Installer kind: " + this.$route.params.kind);
this.install(); this.install();
}, },
methods: { methods: {
@ -190,8 +200,15 @@ const InstallPackages = {
var that = this; // IE workaround var that = this; // IE workaround
stream_ajax(this.is_uninstall ? "/api/uninstall" : var targetUrl = "/api/start-install";
"/api/start-install", function(line) { if (this.is_uninstall) {
targetUrl = "/api/uninstall";
}
if (this.is_updater_update) {
targetUrl = "/api/update-updater";
}
stream_ajax(targetUrl, function(line) {
if (line.hasOwnProperty("Status")) { if (line.hasOwnProperty("Status")) {
that.progress_message = line.Status[0]; that.progress_message = line.Status[0];
that.progress = line.Status[1] * 100; that.progress = line.Status[1] * 100;
@ -206,10 +223,23 @@ const InstallPackages = {
} }
} }
}, function(e) { }, function(e) {
if (app.metadata.is_launcher) { if (that.is_updater_update) {
app.exit(); // Continue with what we were doing
} else if (!that.failed_with_error) { if (app.metadata.is_launcher) {
router.push("/complete"); router.replace("/install/regular");
} else {
if (app.metadata.preexisting_install) {
router.replace("/modify");
} else {
router.replace("/packages");
}
}
} else {
if (app.metadata.is_launcher) {
app.exit();
} else if (!that.failed_with_error) {
router.replace("/complete");
}
} }
}, undefined, results); }, undefined, results);
} }