From 351c4c7c1fa5c6ad4f3176e8e4ce0307e52c6324 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 9 Aug 2018 15:21:50 +1000 Subject: [PATCH] Add self-updating feature (closes #2) --- src/config.rs | 3 + src/http.rs | 2 +- src/installer.rs | 130 ++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 87 +++++++++++++++++++++++++++-- src/native/mod.rs | 18 ++++-- src/rest.rs | 62 +++++++++++++++++++-- static/css/main.css | 7 +++ static/js/helpers.js | 4 ++ static/js/main.js | 11 ++++ static/js/views.js | 42 ++++++++++++-- 10 files changed, 344 insertions(+), 22 deletions(-) diff --git a/src/config.rs b/src/config.rs index ecff603..361bdf4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,6 +60,9 @@ impl BaseAttributes { #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Config { pub installing_message: String, + /// URL to a new updater, if required + #[serde(default)] + pub new_tool: Option, pub packages: Vec, } diff --git a/src/http.rs b/src/http.rs index a8edb67..8b34145 100644 --- a/src/http.rs +++ b/src/http.rs @@ -12,7 +12,7 @@ use reqwest::Client; /// Builds a customised HTTP client. pub fn build_client() -> Result { Client::builder() - .timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(8)) .build() .map_err(|x| format!("Unable to build cient: {:?}", x)) } diff --git a/src/installer.rs b/src/installer.rs index c482e44..91f0f0d 100644 --- a/src/installer.rs +++ b/src/installer.rs @@ -5,7 +5,9 @@ use serde_json; use std::fs::File; +use std::fs::OpenOptions; +use std::env; use std::env::var; use std::path::Path; @@ -13,6 +15,12 @@ use std::path::PathBuf; 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::Config; @@ -20,6 +28,7 @@ use sources::types::Version; use tasks::install::InstallTask; use tasks::uninstall::UninstallTask; +use tasks::uninstall_global_shortcut::UninstallGlobalShortcutsTask; use tasks::DependencyTree; use logging::LoggingErrors; @@ -27,7 +36,10 @@ use logging::LoggingErrors; use dirs::home_dir; 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. #[derive(Serialize)] @@ -206,6 +218,122 @@ impl InstallerFramework { 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) -> 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 = 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 = 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. pub fn save_database(&self) -> Result<(), String> { // We have to have a install path for us to be able to do anything diff --git a/src/main.rs b/src/main.rs index b67fedc..d0e035b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,12 +61,21 @@ use nfd::Response; use rest::WebServer; +use std::net::TcpListener; use std::net::ToSocketAddrs; -use std::net::TcpListener; use std::sync::Arc; 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 clap::App; @@ -91,16 +100,26 @@ fn main() { 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")) - .about(format!("An interactive installer for {}", app_name).as_ref()) + .about(app_about.as_ref()) .arg( Arg::with_name("launcher") .long("launcher") .value_name("TARGET") .help("Launches the specified executable after checking for updates") .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); @@ -108,6 +127,66 @@ fn main() { let current_path = current_exe .parent() .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(¤t_exe, &to_path).log_expect("Unable to copy new installer"); + } else { + use std::fs::rename; + + rename(¤t_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 = { + 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 mut framework = if metadata_file.exists() { info!("Using pre-existing metadata file: {:?}", metadata_file); diff --git a/src/native/mod.rs b/src/native/mod.rs index 996fe22..a07af66 100644 --- a/src/native/mod.rs +++ b/src/native/mod.rs @@ -11,7 +11,6 @@ mod natives { use logging::LoggingErrors; use std::env; - use std::path::PathBuf; use std::process::Command; include!(concat!(env!("OUT_DIR"), "/interop.rs")); @@ -63,7 +62,12 @@ mod natives { } /// 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. let tool = path.join("maintenancetool.exe"); let tool = tool @@ -77,7 +81,7 @@ mod natives { .log_expect("Unable to convert log path to string") .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); @@ -108,7 +112,13 @@ mod natives { } /// 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 if let Err(e) = remove_file(path.join("/maintenancetool")) { // No regular logging now. diff --git a/src/rest.rs b/src/rest.rs index 475892a..5e30a45 100644 --- a/src/rest.rs +++ b/src/rest.rs @@ -207,12 +207,7 @@ impl Service for WebService { } if framework.burn_after_exit { - let path = framework - .install_path - .as_ref() - .log_expect("No install path when one should have existed?"); - - native::burn_on_exit(path); + native::burn_on_exit(); } exit(0); @@ -289,6 +284,61 @@ impl Service for WebService { .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::::new() + //.with_header(ContentLength(file.len() as u64)) + .with_header(ContentType::plaintext()) + .with_body(rx) + })); + } // Streams the installation of a particular set of packages (&Post, "/api/start-install") => { // We need to bit of pipelining to get this to work diff --git a/static/css/main.css b/static/css/main.css index b95523f..9568d7a 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -22,6 +22,13 @@ body, div, span, h1, h2, h3, h4, h5, h6 { cursor: default; } +pre { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; +} + .tile.is-child > .box { height: 100%; } diff --git a/static/js/helpers.js b/static/js/helpers.js index 3e1f5fb..f7cc42c 100644 --- a/static/js/helpers.js +++ b/static/js/helpers.js @@ -19,6 +19,8 @@ function ajax(path, successCallback, failCallback, data) { failCallback = defaultFailHandler; } + console.log("Making HTTP request to " + path); + var req = new XMLHttpRequest(); req.addEventListener("load", function() { @@ -69,6 +71,8 @@ function ajax(path, successCallback, failCallback, data) { function stream_ajax(path, callback, successCallback, failCallback, data) { var req = new XMLHttpRequest(); + console.log("Making streaming HTTP request to " + path); + req.addEventListener("load", function() { // The server can sometimes return a string error. Make sure we handle this. if (this.status === 200) { diff --git a/static/js/main.js b/static/js/main.js index b026980..8ec80ab 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -26,6 +26,17 @@ for (var i = 0; i < methods.length; 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"; function selectFileCallback(name) { diff --git a/static/js/views.js b/static/js/views.js index 6b54908..a24490a 100644 --- a/static/js/views.js +++ b/static/js/views.js @@ -44,6 +44,12 @@ const DownloadConfig = { }); }, choose_next_state: function() { + // Update the updater if needed + if (app.config.new_tool) { + router.push("/install/updater"); + return; + } + if (app.metadata.preexisting_install) { app.install_location = app.metadata.install_path; @@ -153,6 +159,7 @@ const InstallPackages = {

Checking for updates...

Uninstalling...

+

Downloading update for updater...

Installing...


@@ -168,11 +175,14 @@ const InstallPackages = { progress: 0.0, progress_message: "Please wait...", is_uninstall: false, + is_updater_update: false, failed_with_error: false } }, created: function() { 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(); }, methods: { @@ -190,8 +200,15 @@ const InstallPackages = { var that = this; // IE workaround - stream_ajax(this.is_uninstall ? "/api/uninstall" : - "/api/start-install", function(line) { + var targetUrl = "/api/start-install"; + 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")) { that.progress_message = line.Status[0]; that.progress = line.Status[1] * 100; @@ -206,10 +223,23 @@ const InstallPackages = { } } }, function(e) { - if (app.metadata.is_launcher) { - app.exit(); - } else if (!that.failed_with_error) { - router.push("/complete"); + if (that.is_updater_update) { + // Continue with what we were doing + if (app.metadata.is_launcher) { + 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); }