Update config files for v7 (#12)

* platform: fix build on Linux and update web-view

* deps: replace xz-decom with xz2 and update deps

* platform: fix regression...

... that prevents the build on Windows

* linux: implement platform-dependent functions

* travis: add macos and windows CI

* travis: use official Rust Docker image

* Update Cargo.lock for new version

* Break apart REST into separate services

This cleans up locking, ensures consistent futures for all endpoints
and enhances code re-use.

* Clean up codebase, fixing minor errors

* Update packages, use async client for downloading config

While this has a hell of a lot more boilerplate, this is quite
a bit cleaner.

* Add explicit 'dyn's as per Rust nightly requirements

* Migrate self updating functions to own module

* Migrate assets to server module

* Use patched web-view to fix dialogs, remove nfd

* Implement basic dark mode

* Revert window.close usage

* ui: split files and use Webpack

* frontend: ui: include prebuilt assets...

... and update rust side stuff

* build: integrate webpack building into build.rs

* Polish Vue UI split

* Add instructions for node + yarn

* native: fix uninstall self-destruction behavior...... by not showing the command prompt window and fork-spawning the cmd

* native: deal with Unicode issues in native APIs

* native: further improve Unicode support on Windows

* travis: add cache and fix issues

* ui: use Buefy components to...

... beautify the UI

* ui: makes error message selectable

* Make launcher mode behaviour more robust

* Fix error display on launcher pages

* Correctly handle exit on error

* Bump installer version
This commit is contained in:
James 2019-07-05 01:23:16 +00:00 committed by Flame Sage
parent 6aa5da8795
commit 68109894f1
89 changed files with 11951 additions and 2460 deletions

View file

@ -1,11 +1,27 @@
os: linux
dist: trusty
sudo: required
services:
- docker
matrix:
include:
- os: linux
language: cpp
sudo: required
dist: trusty
services: docker
install: docker pull rust:1
cache:
directories:
- $HOME/.cargo
- $TRAVIS_BUILD_DIR/ui/node_modules
script: docker run -v $HOME/.cargo:/root/.cargo -v $(pwd):/liftinstall rust:1 /bin/bash -ex /liftinstall/.travis/build.sh
install:
- docker pull ubuntu:18.04
- os: osx
language: rust
cache: cargo
osx_image: xcode10
script: brew install yarn && cargo build
script:
- docker run -v $(pwd):/liftinstall ubuntu:18.04 /bin/bash -ex /liftinstall/.travis/build.sh
- os: windows
language: rust
cache: cargo
script:
- choco install nodejs yarn
- export PATH="$PROGRAMFILES/nodejs/:$PROGRAMFILES (x86)/Yarn/bin/:$PATH"
- cargo build

View file

@ -1,10 +1,15 @@
#!/usr/bin/env bash
cd /liftinstall
cd /liftinstall || exit 1
apt update
apt install -y libwebkit2gtk-4.0-dev libssl-dev
# setup NodeJS
curl -sL https://deb.nodesource.com/setup_12.x | bash -
# setup Yarn
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list
curl https://sh.rustup.rs -sSf | sh -s -- -y
export PATH=~/.cargo/bin:$PATH
apt-get update
apt-get install -y libwebkit2gtk-4.0-dev libssl-dev nodejs yarn
yarn --cwd ui
cargo build

1721
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,47 +8,50 @@ description = "An adaptable installer for your application."
build = "build.rs"
[dependencies]
web-view = {git = "https://github.com/Boscop/web-view.git", rev = "555f422d09cbb94e82a728d47e9e07ca91963f6e"}
web-view = {git = "https://github.com/j-selby/web-view.git", rev = "752106e4637356cbdb39a0bf1113ea3ae8a14243"}
hyper = "0.11.27"
futures = "*"
mime_guess = "1.8.3"
url = "*"
futures = "0.1.25"
mime_guess = "1.8.6"
url = "1.7.2"
reqwest = "0.9.0"
number_prefix = "0.2.7"
reqwest = "0.9.12"
number_prefix = "0.3.0"
serde = "1.0.27"
serde_derive = "1.0.27"
serde_json = "1.0.9"
serde = "1.0.89"
serde_derive = "1.0.89"
serde_json = "1.0.39"
toml = "0.4"
toml = "0.5.0"
semver = {version = "0.9.0", features = ["serde"]}
regex = "0.2"
regex = "1.1.5"
dirs = "1.0"
zip = "0.4.2"
xz-decom = {git = "https://github.com/j-selby/xz-decom.git", rev = "9ebf3d00d9ff909c39eec1d2cf7e6e068ce214e5"}
dirs = "1.0.5"
zip = "0.5.1"
xz2 = "0.1.6"
tar = "0.4"
log = "0.4"
fern = "0.5"
chrono = "0.4.5"
chrono = "0.4.6"
clap = "2.32.0"
[build-dependencies]
walkdir = "2"
serde = "1.0.27"
serde_derive = "1.0.27"
toml = "0.4"
walkdir = "2.2.7"
serde = "1.0.89"
serde_derive = "1.0.89"
toml = "0.5.0"
which = "2.0.1"
[target.'cfg(windows)'.dependencies]
# NFD is needed on Windows, as web-view doesn't work correctly here
nfd = "0.0.4"
winapi = { version = "0.3", features = ["psapi", "winbase", "winioctl", "winnt"] }
widestring = "0.4.0"
[target.'cfg(not(windows))'.dependencies]
sysinfo = "0.8.2"
slug = "0.1.4"
[target.'cfg(windows)'.build-dependencies]
winres = "0.1"

View file

@ -22,6 +22,7 @@ For more detailed instructions, look at the usage documentation above.
There are are few system dependencies depending on your platform:
- For all platforms, `cargo` should be available on your PATH. [Rustup](https://rustup.rs/) is the
recommended way to achieve this. Stable or Nightly Rust works fine.
- Have node.js and Yarn available on your PATH (for building UI components, not needed at runtime).
- For Windows (MSVC), you need Visual Studio installed.
- For Windows (Mingw), you need `gcc`/`g++` available on the PATH.
- For Mac, you need Xcode installed, and Clang/etc available on the PATH.
@ -33,8 +34,8 @@ apt install -y build-essential libwebkit2gtk-4.0-dev libssl-dev
In order to build yourself an installer, as a bare minimum, you need to:
- Add your favicon to `static/favicon.ico`
- Add your logo to `static/logo.png`
- Add your favicon to `ui/public/favicon.ico`
- Add your logo to `ui/src/assets/logo.png`
- Modify the bootstrap configuration file as needed (`config.PLATFORM.toml`).
- Have the main configuration file somewhere useful, reachable over HTTP.
- Run:

3
bootstrap.macos.toml Normal file
View file

@ -0,0 +1,3 @@
# fake configuration for CI purpose only
name = "yuzu"
target_url = "https://raw.githubusercontent.com/j-selby/test-installer/master/config.linux.v2.toml"

View file

@ -1,2 +1,2 @@
name = "yuzu"
target_url = "https://raw.githubusercontent.com/yuzu-emu/liftinstall/master/config.windows.v6.toml"
target_url = "https://raw.githubusercontent.com/yuzu-emu/liftinstall/master/config.windows.v7.toml"

116
build.rs
View file

@ -1,5 +1,3 @@
extern crate walkdir;
#[cfg(windows)]
extern crate winres;
@ -11,24 +9,19 @@ extern crate serde;
extern crate serde_derive;
extern crate toml;
use walkdir::WalkDir;
extern crate which;
use std::env;
use std::path::PathBuf;
use std::fs::copy;
use std::fs::create_dir_all;
use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Read;
use std::io::Write;
use std::process::Command;
use std::env::consts::OS;
const FILES_TO_PREPROCESS: &'static [&'static str] = &["helpers.js", "views.js"];
/// Describes the application itself.
#[derive(Debug, Deserialize)]
pub struct BaseAttributes {
@ -39,7 +32,7 @@ pub struct BaseAttributes {
#[cfg(windows)]
fn handle_binary(config: &BaseAttributes) {
let mut res = winres::WindowsResource::new();
res.set_icon("static/favicon.ico");
res.set_icon("ui/public/favicon.ico");
res.set(
"FileDescription",
&format!("Interactive installer for {}", config.name),
@ -62,6 +55,8 @@ fn handle_binary(_config: &BaseAttributes) {}
fn main() {
let output_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let current_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
let ui_dir = current_dir.join("ui");
let os = OS.to_lowercase();
@ -92,80 +87,33 @@ fn main() {
// Copy for the main build
copy(&target_config, output_dir.join("bootstrap.toml")).expect("Unable to copy config file");
// Copy files from static/ to build dir
for entry in WalkDir::new("static") {
let entry = entry.expect("Unable to read output directory");
let yarn_binary = which::which("yarn")
.expect("Failed to find yarn - please go ahead and install it!");
let output_file = output_dir.join(entry.path());
if entry.path().is_dir() {
create_dir_all(output_file).expect("Unable to create dir");
} else {
let filename = entry
.path()
.file_name()
.expect("Unable to parse filename")
// Build and deploy frontend files
Command::new(&yarn_binary)
.arg("--version")
.spawn()
.expect("Yarn could not be launched");
Command::new(&yarn_binary)
.arg("--cwd")
.arg(ui_dir.to_str().expect("Unable to covert path"))
.spawn()
.unwrap()
.wait().expect("Unable to install Node.JS dependencies using Yarn");
Command::new(&yarn_binary)
.args(&[
"--cwd",
ui_dir.to_str().expect("Unable to covert path"),
"run",
"build",
"--dest",
output_dir
.join("static")
.to_str()
.expect("Unable to convert to string");
if FILES_TO_PREPROCESS.contains(&filename) {
// Do basic preprocessing - transcribe template string
let source = BufReader::new(File::open(entry.path()).expect("Unable to copy file"));
let mut target = File::create(output_file).expect("Unable to copy file");
let mut is_template_string = false;
for line in source.lines() {
let line = line.expect("Unable to read line from JS file");
let mut is_break = false;
let mut is_quote = false;
let mut output_line = String::new();
if is_template_string {
output_line += "\"";
}
for c in line.chars() {
if c == '\\' {
is_break = true;
output_line.push('\\');
continue;
}
if (c == '\"' || c == '\'') && !is_break && !is_template_string {
is_quote = !is_quote;
}
if c == '`' && !is_break && !is_quote {
output_line += "\"";
is_template_string = !is_template_string;
continue;
}
if c == '"' && !is_break && is_template_string {
output_line += "\\\"";
continue;
}
is_break = false;
output_line.push(c);
}
if is_template_string {
output_line += "\" +";
}
output_line.push('\n');
target
.write(output_line.as_bytes())
.expect("Unable to write line");
}
} else {
copy(entry.path(), output_file).expect("Unable to copy file");
}
}
}
.expect("Unable to convert path"),
])
.spawn()
.unwrap()
.wait().expect("Unable to build frontend assets using Webpack");
}

View file

@ -1,5 +1,6 @@
installing_message = "Reminder: yuzu is an <b>experimental</b> emulator. Stuff will break!"
hide_advanced = true
new_tool = "https://github.com/yuzu-emu/liftinstall/releases/download/1.5/yuzu_install.exe"
[[packages]]
name = "yuzu Nightly"

30
config.windows.v7.toml Normal file
View file

@ -0,0 +1,30 @@
installing_message = "Reminder: yuzu is an <b>experimental</b> emulator. Stuff will break!"
hide_advanced = true
[[packages]]
name = "yuzu Nightly"
description = "The nightly build of yuzu contains already reviewed and tested features."
[packages.source]
name = "github"
match = "^yuzu-windows-msvc-[0-9]*-[0-9a-f]*.zip$"
[packages.source.config]
repo = "yuzu-emu/yuzu-nightly"
[[packages.shortcuts]]
name = "yuzu Nightly"
relative_path = "nightly/yuzu.exe"
description = "Launch yuzu (Nightly version)"
[[packages]]
name = "yuzu Canary"
description = "The canary build of yuzu has additional features that are still waiting on review."
default = true
[packages.source]
name = "github"
match = "^yuzu-windows-msvc-[0-9]*-[0-9a-f]*.zip$"
[packages.source.config]
repo = "yuzu-emu/yuzu-canary"
[[packages.shortcuts]]
name = "yuzu Canary"
relative_path = "canary/yuzu.exe"
description = "Launch yuzu (Canary version)"

View file

@ -10,13 +10,13 @@ use std::io::Read;
use std::iter::Iterator;
use std::path::PathBuf;
use xz_decom;
use xz2::read::XzDecoder;
pub trait Archive<'a> {
/// func: iterator value, max size, file name, file contents
fn for_each(
&mut self,
func: &mut FnMut(usize, Option<usize>, PathBuf, &mut Read) -> Result<(), String>,
func: &mut dyn FnMut(usize, Option<usize>, PathBuf, &mut dyn Read) -> Result<(), String>,
) -> Result<(), String>;
}
@ -27,7 +27,7 @@ struct ZipArchive<'a> {
impl<'a> Archive<'a> for ZipArchive<'a> {
fn for_each(
&mut self,
func: &mut FnMut(usize, Option<usize>, PathBuf, &mut Read) -> Result<(), String>,
func: &mut dyn FnMut(usize, Option<usize>, PathBuf, &mut dyn Read) -> Result<(), String>,
) -> Result<(), String> {
let max = self.archive.len();
@ -49,13 +49,13 @@ impl<'a> Archive<'a> for ZipArchive<'a> {
}
struct TarArchive<'a> {
archive: UpstreamTarArchive<Box<Read + 'a>>,
archive: UpstreamTarArchive<Box<dyn Read + 'a>>,
}
impl<'a> Archive<'a> for TarArchive<'a> {
fn for_each(
&mut self,
func: &mut FnMut(usize, Option<usize>, PathBuf, &mut Read) -> Result<(), String>,
func: &mut dyn FnMut(usize, Option<usize>, PathBuf, &mut dyn Read) -> Result<(), String>,
) -> Result<(), String> {
let entries = self
.archive
@ -83,7 +83,7 @@ impl<'a> Archive<'a> for TarArchive<'a> {
}
/// Reads the named archive with an archive implementation.
pub fn read_archive<'a>(name: &str, data: &'a [u8]) -> Result<Box<Archive<'a> + 'a>, String> {
pub fn read_archive<'a>(name: &str, data: &'a [u8]) -> Result<Box<dyn Archive<'a> + 'a>, String> {
if name.ends_with(".zip") {
// Decompress a .zip file
let archive = UpstreamZipArchive::new(Cursor::new(data))
@ -92,10 +92,13 @@ pub fn read_archive<'a>(name: &str, data: &'a [u8]) -> Result<Box<Archive<'a> +
Ok(Box::new(ZipArchive { archive }))
} else if name.ends_with(".tar.xz") {
// Decompress a .tar.xz file
let decompressed_data = xz_decom::decompress(data)
.map_err(|x| format!("Failed to build decompressor: {:?}", x))?;
let mut decompresser = XzDecoder::new(data);
let mut decompressed_data = Vec::new();
decompresser
.read_to_end(&mut decompressed_data)
.map_err(|x| format!("Failed to decompress data: {:?}", x))?;
let decompressed_contents: Box<Read> = Box::new(Cursor::new(decompressed_data));
let decompressed_contents: Box<dyn Read> = Box::new(Cursor::new(decompressed_data));
let tar = UpstreamTarArchive::new(decompressed_contents);

29
src/frontend/mod.rs Normal file
View file

@ -0,0 +1,29 @@
//! frontend/mod.rs
//!
//! Provides the frontend interface, including HTTP server.
use std::sync::{Arc, RwLock};
use installer::InstallerFramework;
use logging::LoggingErrors;
pub mod rest;
mod ui;
/// Launches the main web server + UI. Returns when the framework has been consumed + web UI closed.
pub fn launch(app_name: &str, is_launcher: bool, framework: InstallerFramework) {
let framework = Arc::new(RwLock::new(framework));
let (servers, address) = rest::server::spawn_servers(framework.clone());
ui::start_ui(app_name, &address, is_launcher);
// Explicitly hint that we want the servers instance until here.
drop(servers);
framework
.write()
.log_expect("Failed to write to framework to finalize")
.shutdown()
.log_expect("Failed to finalize framework");
}

View file

@ -2,7 +2,7 @@
extern crate mime_guess;
use assets::mime_guess::{get_mime_type, octet_stream};
use self::mime_guess::{get_mime_type, octet_stream};
macro_rules! include_files_as_assets {
( $target_match:expr, $( $file_name:expr ),* ) => {
@ -34,18 +34,15 @@ pub fn file_from_string(file_path: &str) -> Option<(String, &'static [u8])> {
file_path,
"/index.html",
"/favicon.ico",
"/logo.png",
"/how-to-open.png",
"/css/bulma.min.css",
"/css/main.css",
"/img/logo.png",
"/img/how-to-open.png",
"/css/app.css",
"/css/chunk-vendors.css",
"/fonts/roboto-v18-latin-regular.eot",
"/fonts/roboto-v18-latin-regular.woff",
"/fonts/roboto-v18-latin-regular.woff2",
"/js/vue.min.js",
"/js/vue-router.min.js",
"/js/helpers.js",
"/js/views.js",
"/js/main.js"
"/js/chunk-vendors.js",
"/js/app.js"
)?;
Some((string_mime, contents))

7
src/frontend/rest/mod.rs Normal file
View file

@ -0,0 +1,7 @@
//! frontend/rest/mod.rs
//!
//! Contains the main web server used within the application.
mod assets;
pub mod server;
pub mod services;

View file

@ -0,0 +1,86 @@
//! frontend/rest/server.rs
//!
//! Contains the over-arching server object + methods to manipulate it.
use frontend::rest::services::WebService;
use installer::InstallerFramework;
use logging::LoggingErrors;
use hyper::server::Http;
use std::sync::{Arc, RwLock};
use std::net::{SocketAddr, TcpListener, ToSocketAddrs};
use std::thread;
use std::thread::JoinHandle;
/// Acts as a communication mechanism between the Hyper WebService and the rest of the
/// application.
pub struct WebServer {
_handle: JoinHandle<()>,
}
impl WebServer {
/// Creates a new web server with the specified address.
pub fn with_addr(
framework: Arc<RwLock<InstallerFramework>>,
addr: SocketAddr,
) -> Result<Self, hyper::Error> {
let handle = thread::spawn(move || {
let server = Http::new()
.bind(&addr, move || Ok(WebService::new(framework.clone())))
.log_expect("Failed to bind to port");
server.run().log_expect("Failed to run HTTP server");
});
Ok(WebServer { _handle: handle })
}
}
/// Spawns a server instance on all local interfaces.
///
/// Returns server instances + http address of service running.
pub fn spawn_servers(framework: Arc<RwLock<InstallerFramework>>) -> (Vec<WebServer>, String) {
// Firstly, allocate us an epidermal port
let target_port = {
let listener = TcpListener::bind("127.0.0.1:0")
.log_expect("At least one local address should be free");
listener
.local_addr()
.log_expect("Should be able to pull address from listener")
.port()
};
// Now, iterate over all ports
let addresses = "localhost:0"
.to_socket_addrs()
.log_expect("No localhost address found");
let mut instances = Vec::with_capacity(addresses.len());
let mut http_address = None;
// Startup HTTP server for handling the web view
for mut address in addresses {
address.set_port(target_port);
let server = WebServer::with_addr(framework.clone(), address)
.log_expect("Failed to bind to address");
info!("Spawning server instance @ {:?}", address);
http_address = Some(address);
instances.push(server);
}
let http_address = http_address.log_expect("No HTTP address found");
(
instances,
format!("http://localhost:{}", http_address.port()),
)
}

View file

@ -0,0 +1,33 @@
//! frontend/rest/services/attributes.rs
//!
//! The /api/attr call returns an executable script containing session variables.
use frontend::rest::services::default_future;
use frontend::rest::services::encapsulate_json;
use frontend::rest::services::Future;
use frontend::rest::services::Request;
use frontend::rest::services::Response;
use frontend::rest::services::WebService;
use hyper::header::{ContentLength, ContentType};
use logging::LoggingErrors;
pub fn handle(service: &WebService, _req: Request) -> Future {
let framework = service.get_framework_read();
let file = encapsulate_json(
"base_attributes",
&framework
.base_attributes
.to_json_str()
.log_expect("Failed to render JSON representation of config"),
);
default_future(
Response::new()
.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::json())
.with_body(file),
)
}

View file

@ -0,0 +1,84 @@
//! frontend/rest/services/config.rs
//!
//! The /api/config call returns the current installer framework configuration.
//!
//! This endpoint should be usable directly from a <script> tag during loading.
use frontend::rest::services::Future;
use frontend::rest::services::Request;
use frontend::rest::services::Response;
use frontend::rest::services::WebService;
use hyper::header::{ContentLength, ContentType};
use logging::LoggingErrors;
use config::Config;
use http::build_async_client;
use futures::stream::Stream;
use futures::Future as _;
pub fn handle(service: &WebService, _req: Request) -> Future {
let framework_url = {
service
.get_framework_read()
.base_attributes
.target_url
.clone()
};
info!("Downloading configuration from {:?}...", framework_url);
let framework = service.framework.clone();
// Hyper doesn't allow for clients to do sync network operations in a async future.
// This smallish pipeline joins the two together.
Box::new(
build_async_client()
.log_expect("Failed to build async client")
.get(&framework_url)
.send()
.map_err(|x| {
error!("HTTP error while downloading configuration file: {:?}", x);
hyper::Error::Incomplete
})
.and_then(|x| {
x.into_body().concat2().map_err(|x| {
error!("HTTP error while parsing configuration file: {:?}", x);
hyper::Error::Incomplete
})
})
.and_then(move |x| {
let x = String::from_utf8(x.to_vec()).map_err(|x| {
error!("UTF-8 error while parsing configuration file: {:?}", x);
hyper::Error::Incomplete
})?;
let config = Config::from_toml_str(&x).map_err(|x| {
error!("Serde error while parsing configuration file: {:?}", x);
hyper::Error::Incomplete
})?;
let mut framework = framework
.write()
.log_expect("Failed to get write lock for framework");
framework.config = Some(config);
info!("Configuration file downloaded successfully.");
let file = framework
.get_config()
.log_expect("Config should be loaded by now")
.to_json_str()
.log_expect("Failed to render JSON representation of config");
Ok(Response::new()
.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::json())
.with_body(file))
}),
)
}

View file

@ -0,0 +1,27 @@
//! frontend/rest/services/dark_mode.rs
//!
//! This call returns if dark mode is enabled on the system currently.
use frontend::rest::services::default_future;
use frontend::rest::services::Future;
use frontend::rest::services::Request;
use frontend::rest::services::Response;
use frontend::rest::services::WebService;
use hyper::header::{ContentLength, ContentType};
use logging::LoggingErrors;
use native::is_dark_mode_active;
pub fn handle(_service: &WebService, _req: Request) -> Future {
let file = serde_json::to_string(&is_dark_mode_active())
.log_expect("Failed to render JSON payload of installation status object");
default_future(
Response::new()
.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::json())
.with_body(file),
)
}

View file

@ -0,0 +1,35 @@
//! frontend/rest/services/default_path.rs
//!
//! The /api/default-path returns the default path for the application to install into.
use frontend::rest::services::default_future;
use frontend::rest::services::Future;
use frontend::rest::services::Request;
use frontend::rest::services::Response;
use frontend::rest::services::WebService;
use hyper::header::{ContentLength, ContentType};
use logging::LoggingErrors;
/// Struct used by serde to send a JSON payload to the client containing an optional value.
#[derive(Serialize)]
struct FileSelection {
path: Option<String>,
}
pub fn handle(service: &WebService, _req: Request) -> Future {
let path = { service.get_framework_read().get_default_path() };
let response = FileSelection { path };
let file = serde_json::to_string(&response)
.log_expect("Failed to render JSON payload of default path object");
default_future(
Response::new()
.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::json())
.with_body(file),
)
}

View file

@ -0,0 +1,32 @@
//! frontend/rest/services/exit.rs
//!
//! The /api/exit closes down the application.
use frontend::rest::services::default_future;
use frontend::rest::services::Future;
use frontend::rest::services::Request;
use frontend::rest::services::Response;
use frontend::rest::services::WebService;
use hyper::header::ContentType;
use hyper::StatusCode;
use std::process::exit;
pub fn handle(service: &WebService, _req: Request) -> Future {
match service.get_framework_write().shutdown() {
Ok(_) => {
exit(0);
}
Err(e) => {
error!("Failed to complete framework shutdown: {:?}", e);
default_future(
Response::new()
.with_status(StatusCode::InternalServerError)
.with_header(ContentType::plaintext())
.with_body(format!("Failed to complete framework shutdown - {}", e)),
)
}
}
}

View file

@ -0,0 +1,70 @@
//! frontend/rest/services/install.rs
//!
//! The /api/install call installs a set of packages dictated by a POST request.
use frontend::rest::services::stream_progress;
use frontend::rest::services::Future;
use frontend::rest::services::Request;
use frontend::rest::services::WebService;
use logging::LoggingErrors;
use installer::InstallMessage;
use futures::future::Future as _;
use futures::stream::Stream;
use url::form_urlencoded;
use std::collections::HashMap;
pub fn handle(service: &WebService, req: Request) -> Future {
let framework = service.framework.clone();
Box::new(req.body().concat2().map(move |b| {
let results = form_urlencoded::parse(b.as_ref())
.into_owned()
.collect::<HashMap<String, String>>();
let mut to_install = Vec::new();
let mut path: Option<String> = None;
// Transform results into just an array of stuff to install
for (key, value) in &results {
if key == "path" {
path = Some(value.to_owned());
continue;
}
if value == "true" {
to_install.push(key.to_owned());
}
}
// The frontend always provides this
let path =
path.log_expect("No path specified by frontend when one should have already existed");
stream_progress(move |sender| {
let mut framework = framework
.write()
.log_expect("InstallerFramework has been dirtied");
let new_install = !framework.preexisting_install;
if new_install {
framework.set_install_dir(&path);
}
if let Err(v) = framework.install(to_install, &sender, new_install) {
error!("Install error occurred: {:?}", v);
if let Err(v) = sender.send(InstallMessage::Error(v)) {
error!("Failed to send install error: {:?}", v);
}
}
if let Err(v) = sender.send(InstallMessage::EOF) {
error!("Failed to send EOF to client: {:?}", v);
}
})
}))
}

View file

@ -0,0 +1,32 @@
//! frontend/rest/services/installation_status.rs
//!
//! The /api/installation-status call returns metadata relating to the current status of
//! the installation.
//!
//! e.g. if the application is in maintenance mode
use frontend::rest::services::default_future;
use frontend::rest::services::Future;
use frontend::rest::services::Request;
use frontend::rest::services::Response;
use frontend::rest::services::WebService;
use hyper::header::{ContentLength, ContentType};
use logging::LoggingErrors;
pub fn handle(service: &WebService, _req: Request) -> Future {
let framework = service.get_framework_read();
let response = framework.get_installation_status();
let file = serde_json::to_string(&response)
.log_expect("Failed to render JSON payload of installation status object");
default_future(
Response::new()
.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::json())
.with_body(file),
)
}

View file

@ -0,0 +1,150 @@
//! frontend/rest/services/mod.rs
//!
//! Provides all services used by the REST server.
use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard};
use installer::{InstallMessage, InstallerFramework};
use hyper::server::Service;
use hyper::{Method, StatusCode};
use logging::LoggingErrors;
use std::sync::mpsc::{channel, Sender};
use std::thread;
use hyper::header::ContentType;
use futures::future::Future as _;
use futures::sink::Sink;
mod attributes;
mod config;
mod default_path;
mod dark_mode;
mod exit;
mod install;
mod installation_status;
mod packages;
mod static_files;
mod uninstall;
mod update_updater;
/// Expected incoming Request format from Hyper.
pub type Request = hyper::server::Request;
/// Completed response type returned by the server.
pub type Response = hyper::server::Response;
/// Error type returned by the server.
pub type Error = hyper::Error;
/// The return type used by function calls to the web server.
pub type Future = Box<dyn futures::Future<Item = Response, Error = Error>>;
/// If advanced functionality is not needed, return a default instant future.
pub fn default_future(response: Response) -> Future {
Box::new(futures::future::ok(response))
}
/// Encapsulates JSON as a injectable Javascript script.
pub fn encapsulate_json(field_name: &str, json: &str) -> String {
format!("var {} = {};", field_name, json)
}
/// Streams messages from a specified task to the client in a thread.
pub fn stream_progress<F: 'static>(function: F) -> Response
where
F: FnOnce(Sender<InstallMessage>) -> () + Send,
{
let (sender, receiver) = channel();
let (tx, rx) = hyper::Body::pair();
// Startup a thread to do this operation for us
thread::spawn(move || function(sender));
// 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(ContentType::plaintext())
.with_body(rx)
}
/// Holds internal state for a single Hyper instance. Multiple will exist.
pub struct WebService {
framework: Arc<RwLock<InstallerFramework>>,
}
impl WebService {
/// Returns an immutable reference to the framework. May block.
pub fn get_framework_read(&self) -> RwLockReadGuard<InstallerFramework> {
self.framework
.read()
.log_expect("InstallerFramework has been dirtied")
}
/// Returns an immutable reference to the framework. May block.
pub fn get_framework_write(&self) -> RwLockWriteGuard<InstallerFramework> {
self.framework
.write()
.log_expect("InstallerFramework has been dirtied")
}
/// Creates a new WebService instance. Multiple are likely going to exist at once,
/// so create a lock to hold this.
pub fn new(framework: Arc<RwLock<InstallerFramework>>) -> WebService {
WebService { framework }
}
}
impl Service for WebService {
type Request = Request;
type Response = Response;
type Error = Error;
type Future = Future;
fn call(&self, req: Self::Request) -> Self::Future {
let method = req.method().clone();
let path = req.path().to_string();
match (method, path.as_str()) {
(Method::Get, "/api/attrs") => attributes::handle(self, req),
(Method::Get, "/api/config") => config::handle(self, req),
(Method::Get, "/api/dark-mode") => dark_mode::handle(self, req),
(Method::Get, "/api/default-path") => default_path::handle(self, req),
(Method::Get, "/api/exit") => exit::handle(self, req),
(Method::Get, "/api/packages") => packages::handle(self, req),
(Method::Get, "/api/installation-status") => installation_status::handle(self, req),
(Method::Post, "/api/start-install") => install::handle(self, req),
(Method::Post, "/api/uninstall") => uninstall::handle(self, req),
(Method::Post, "/api/update-updater") => update_updater::handle(self, req),
(Method::Get, _) => static_files::handle(self, req),
e => {
info!("Returned 404 for {:?}", e);
default_future(Response::new().with_status(StatusCode::NotFound))
}
}
}
}

View file

@ -0,0 +1,31 @@
//! frontend/rest/services/packages.rs
//!
//! The /api/packages call returns all the currently installed packages.
use frontend::rest::services::default_future;
use frontend::rest::services::encapsulate_json;
use frontend::rest::services::Future;
use frontend::rest::services::Request;
use frontend::rest::services::Response;
use frontend::rest::services::WebService;
use hyper::header::{ContentLength, ContentType};
use logging::LoggingErrors;
pub fn handle(service: &WebService, _req: Request) -> Future {
let framework = service.get_framework_read();
let file = encapsulate_json(
"packages",
&serde_json::to_string(&framework.database)
.log_expect("Failed to render JSON representation of database"),
);
default_future(
Response::new()
.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::json())
.with_body(file),
)
}

View file

@ -0,0 +1,42 @@
//! frontend/rest/services/static_files.rs
//!
//! The static files call returns static files embedded within the executable.
//!
//! e.g. index.html, main.js, ...
use frontend::rest::assets;
use frontend::rest::services::default_future;
use frontend::rest::services::Future;
use frontend::rest::services::Request;
use frontend::rest::services::Response;
use frontend::rest::services::WebService;
use hyper::header::{ContentLength, ContentType};
use hyper::StatusCode;
use logging::LoggingErrors;
pub fn handle(_service: &WebService, req: Request) -> Future {
// At this point, we have a web browser client. Search for a index page
// if needed
let mut path: String = req.path().to_owned();
if path.ends_with('/') {
path += "index.html";
}
default_future(match assets::file_from_string(&path) {
Some((content_type, file)) => {
let content_type = ContentType(
content_type
.parse()
.log_expect("Failed to parse content type into correct representation"),
);
Response::new()
.with_header(ContentLength(file.len() as u64))
.with_header(content_type)
.with_body(file)
}
None => Response::new().with_status(StatusCode::NotFound),
})
}

View file

@ -0,0 +1,34 @@
//! frontend/rest/services/uninstall.rs
//!
//! The /api/uninstall call uninstalls all packages.
use frontend::rest::services::default_future;
use frontend::rest::services::stream_progress;
use frontend::rest::services::Future;
use frontend::rest::services::Request;
use frontend::rest::services::WebService;
use logging::LoggingErrors;
use installer::InstallMessage;
pub fn handle(service: &WebService, _req: Request) -> Future {
let framework = service.framework.clone();
default_future(stream_progress(move |sender| {
let mut framework = framework
.write()
.log_expect("InstallerFramework has been dirtied");
if let Err(v) = framework.uninstall(&sender) {
error!("Uninstall error occurred: {:?}", v);
if let Err(v) = sender.send(InstallMessage::Error(v)) {
error!("Failed to send uninstall error: {:?}", v);
};
}
if let Err(v) = sender.send(InstallMessage::EOF) {
error!("Failed to send EOF to client: {:?}", v);
}
}))
}

View file

@ -0,0 +1,34 @@
//! frontend/rest/services/update_updater.rs
//!
//! The /api/update-updater call attempts to update the currently running updater.
use frontend::rest::services::default_future;
use frontend::rest::services::stream_progress;
use frontend::rest::services::Future;
use frontend::rest::services::Request;
use frontend::rest::services::WebService;
use logging::LoggingErrors;
use installer::InstallMessage;
pub fn handle(service: &WebService, _req: Request) -> Future {
let framework = service.framework.clone();
default_future(stream_progress(move |sender| {
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);
}
}))
}

71
src/frontend/ui/mod.rs Normal file
View file

@ -0,0 +1,71 @@
//! frontend/ui/mod.rs
//!
//! Provides a web-view UI.
use web_view::Content;
use logging::LoggingErrors;
use log::Level;
#[derive(Deserialize, Debug)]
enum CallbackType {
SelectInstallDir { callback_name: String },
Log { msg: String, kind: String },
Test {}
}
/// Starts the main web UI. Will return when UI is closed.
pub fn start_ui(app_name: &str, http_address: &str, is_launcher: bool) {
let size = if is_launcher { (600, 300) } else { (1024, 500) };
info!("Spawning web view instance");
web_view::builder()
.title(&format!("{} Installer", app_name))
.content(Content::Url(http_address))
.size(size.0, size.1)
.resizable(false)
.debug(false)
.user_data(())
.invoke_handler(|wv, msg| {
let mut cb_result = Ok(());
let command: CallbackType =
serde_json::from_str(msg).log_expect(&format!("Unable to parse string: {:?}", msg));
debug!("Incoming payload: {:?}", command);
match command {
CallbackType::SelectInstallDir { callback_name } => {
let result = wv
.dialog()
.choose_directory("Select a install directory...", "");
if let Ok(Some(new_path)) = result {
if new_path.to_string_lossy().len() > 0 {
let result = serde_json::to_string(&new_path)
.log_expect("Unable to serialize response");
let command = format!("{}({});", callback_name, result);
debug!("Injecting response: {}", command);
cb_result = wv.eval(&command);
}
}
}
CallbackType::Log { msg, kind } => {
let kind = match kind.as_ref() {
"info" | "log" => Level::Info,
"warn" => Level::Warn,
"error" => Level::Error,
_ => Level::Error,
};
log!(target: "liftinstall::frontend::js", kind, "{}", msg);
}
CallbackType::Test {} => {}
}
cb_result
})
.run()
.log_expect("Unable to launch Web UI!");
}

View file

@ -7,6 +7,7 @@ use reqwest::header::CONTENT_LENGTH;
use std::io::Read;
use std::time::Duration;
use reqwest::async::Client as AsyncClient;
use reqwest::Client;
/// Asserts that a URL is valid HTTPS, else returns an error.
@ -14,7 +15,7 @@ pub fn assert_ssl(url: &str) -> Result<(), String> {
if url.starts_with("https://") {
Ok(())
} else {
Err(format!("Specified URL was not https"))
Err("Specified URL was not https".to_string())
}
}
@ -26,18 +27,12 @@ pub fn build_client() -> Result<Client, String> {
.map_err(|x| format!("Unable to build client: {:?}", x))
}
/// Downloads a text file from the specified URL.
pub fn download_text(url: &str) -> Result<String, String> {
assert_ssl(url)?;
let mut client = build_client()?
.get(url)
.send()
.map_err(|x| format!("Failed to GET resource: {:?}", x))?;
client
.text()
.map_err(|v| format!("Failed to get text from resource: {:?}", v))
/// Builds a customised async HTTP client.
pub fn build_async_client() -> Result<AsyncClient, String> {
AsyncClient::builder()
.timeout(Duration::from_secs(8))
.build()
.map_err(|x| format!("Unable to build client: {:?}", x))
}
/// Streams a file from a HTTP server.

View file

@ -18,8 +18,8 @@ use std::sync::mpsc::Sender;
use std::io::copy;
use std::io::Cursor;
use std::process::exit;
use std::process::Command;
use std::process::{exit, Stdio};
use config::BaseAttributes;
use config::Config;
@ -40,7 +40,9 @@ use std::fs::remove_file;
use http;
use number_prefix::{decimal_prefix, Prefixed, Standalone};
use number_prefix::{NumberPrefix, Prefixed, Standalone};
use native;
/// A message thrown during the installation of packages.
#[derive(Serialize)]
@ -105,14 +107,14 @@ pub struct LocalInstallation {
macro_rules! declare_messenger_callback {
($target:expr) => {
&|msg: &TaskMessage| match msg {
&TaskMessage::DisplayMessage(msg, progress) => {
&|msg: &TaskMessage| match *msg {
TaskMessage::DisplayMessage(msg, progress) => {
if let Err(v) = $target.send(InstallMessage::Status(msg.to_string(), progress as _))
{
error!("Failed to submit queue message: {:?}", v);
}
}
&TaskMessage::PackageInstalled => {
TaskMessage::PackageInstalled => {
if let Err(v) = $target.send(InstallMessage::PackageInstalled) {
error!("Failed to submit queue message: {:?}", v);
}
@ -264,11 +266,11 @@ impl InstallerFramework {
};
// Pretty print data volumes
let pretty_current = match decimal_prefix(downloaded as f64) {
let pretty_current = match NumberPrefix::decimal(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) {
let pretty_total = match NumberPrefix::decimal(size as f64) {
Standalone(bytes) => format!("{} bytes", bytes),
Prefixed(prefix, n) => format!("{:.0} {}B", n, prefix),
};
@ -328,7 +330,8 @@ impl InstallerFramework {
x.to_str()
.log_expect("Unable to convert argument to String")
.to_string()
}).collect();
})
.collect();
{
let new_app_file = match File::create(&args_file) {
@ -393,6 +396,29 @@ impl InstallerFramework {
}
}
/// Shuts down the installer instance.
pub fn shutdown(&mut self) -> Result<(), String> {
info!("Shutting down installer framework...");
if let Some(ref v) = self.launcher_path.take() {
info!("Launching {:?}", v);
Command::new(v)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|x| format!("Unable to start application: {:?}", x))?;
}
if self.burn_after_exit {
info!("Requesting that self be deleted after exit.");
native::burn_on_exit(&self.base_attributes.name);
self.burn_after_exit = false;
}
Ok(())
}
/// Creates a new instance of the Installer Framework with a specified Config.
pub fn new(attrs: BaseAttributes) -> Self {
InstallerFramework {

View file

@ -17,7 +17,8 @@ pub fn setup_logger(file_name: String) -> Result<(), fern::InitError> {
record.level(),
message
))
}).level(log::LevelFilter::Info)
})
.level(log::LevelFilter::Info)
.chain(io::stdout())
.chain(fern::log_file(file_name)?)
.apply()?;

View file

@ -7,9 +7,6 @@
#![deny(unsafe_code)]
#![deny(missing_docs)]
#[cfg(windows)]
extern crate nfd;
extern crate web_view;
extern crate futures;
@ -30,7 +27,7 @@ extern crate semver;
extern crate dirs;
extern crate tar;
extern crate xz_decom;
extern crate xz2;
extern crate zip;
extern crate fern;
@ -40,66 +37,45 @@ extern crate log;
extern crate chrono;
extern crate clap;
#[cfg(windows)]
extern crate winapi;
#[cfg(windows)]
extern crate widestring;
#[cfg(not(windows))]
extern crate slug;
#[cfg(not(windows))]
extern crate sysinfo;
mod archives;
mod assets;
mod config;
mod frontend;
mod http;
mod installer;
mod logging;
mod native;
mod rest;
mod self_update;
mod sources;
mod tasks;
use web_view::*;
use installer::InstallerFramework;
#[cfg(windows)]
use nfd::Response;
use rest::WebServer;
use std::net::TcpListener;
use std::net::ToSocketAddrs;
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;
use clap::Arg;
use log::Level;
use config::BaseAttributes;
static RAW_CONFIG: &'static str = include_str!(concat!(env!("OUT_DIR"), "/bootstrap.toml"));
#[derive(Deserialize, Debug)]
enum CallbackType {
SelectInstallDir { callback_name: String },
Log { msg: String, kind: String },
}
fn main() {
let config = BaseAttributes::from_toml_str(RAW_CONFIG).expect("Config file could not be read");
logging::setup_logger(format!("{}_installer.log", config.name))
.expect("Unable to setup logging!");
// Parse CLI arguments
let app_name = config.name.clone();
let app_about = format!("An interactive installer for {}", app_name);
@ -112,7 +88,8 @@ fn main() {
.value_name("TARGET")
.help("Launches the specified executable after checking for updates")
.takes_value(true),
).arg(
)
.arg(
Arg::with_name("swap")
.long("swap")
.value_name("TARGET")
@ -125,100 +102,19 @@ fn main() {
info!("{} installer", app_name);
// Handle self-updating if needed
let current_exe = std::env::current_exe().log_expect("Current executable could not be found");
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()
);
// Attempt it a few times because Windows can hold a lock
for i in 1..=5 {
let swap_result = if cfg!(windows) {
use std::fs::copy;
copy(&current_exe, &to_path).map(|_x| ())
} else {
use std::fs::rename;
rename(&current_exe, &to_path)
};
match swap_result {
Ok(_) => break,
Err(e) => {
if i < 5 {
info!("Copy attempt failed: {:?}, retrying in 3 seconds.", e);
thread::sleep(time::Duration::from_millis(3000));
} else {
let _: () = Err(e).log_expect("Copying new binary failed");
}
}
}
self_update::perform_swap(&current_exe, matches.value_of("swap"));
if let Some(new_matches) = self_update::check_args(reinterpret_app, current_path) {
matches = new_matches;
}
self_update::cleanup(current_path);
Command::new(to_path)
.spawn()
.log_expect("Unable to start child process");
exit(0);
}
// If we just finished a update, we need to inject our previous command line arguments
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!("Parsed command line arguments from original instance");
remove_file(args_file).log_expect("Unable to clean up args file");
}
// Cleanup any remaining new maintenance tool instances if they exist
if cfg!(windows) {
let updater_executable = current_path.join("maintenancetool_new.exe");
if updater_executable.exists() {
// Sleep a little bit to allow Windows to close the previous file handle
thread::sleep(time::Duration::from_millis(3000));
// Attempt it a few times because Windows can hold a lock
for i in 1..=5 {
let swap_result = remove_file(&updater_executable);
match swap_result {
Ok(_) => break,
Err(e) => {
if i < 5 {
info!("Cleanup attempt failed: {:?}, retrying in 3 seconds.", e);
thread::sleep(time::Duration::from_millis(3000));
} else {
warn!("Deleting temp binary failed after 5 attempts: {:?}", e);
}
}
}
}
}
}
// Load in metadata as to learn about the environment
// Load in metadata + setup the installer framework
let metadata_file = current_path.join("metadata.json");
let mut framework = if metadata_file.exists() {
info!("Using pre-existing metadata file: {:?}", metadata_file);
@ -236,97 +132,6 @@ fn main() {
false
};
// Firstly, allocate us an epidermal port
let target_port = {
let listener = TcpListener::bind("127.0.0.1:0")
.log_expect("At least one local address should be free");
listener
.local_addr()
.log_expect("Should be able to pull address from listener")
.port()
};
// Now, iterate over all ports
let addresses = "localhost:0"
.to_socket_addrs()
.log_expect("No localhost address found");
let mut servers = Vec::new();
let mut http_address = None;
let framework = Arc::new(RwLock::new(framework));
// Startup HTTP server for handling the web view
for mut address in addresses {
address.set_port(target_port);
let server = WebServer::with_addr(framework.clone(), address)
.log_expect("Failed to bind to address");
info!("Server: {:?}", address);
http_address = Some(address);
servers.push(server);
}
let http_address = http_address.log_expect("No HTTP address found");
let http_address = format!("http://localhost:{}", http_address.port());
// Init the web view
let size = if is_launcher { (600, 300) } else { (1024, 500) };
let resizable = false;
let debug = true;
run(
&format!("{} Installer", app_name),
Content::Url(http_address),
Some(size),
resizable,
debug,
|_| {},
|wv, msg, _| {
let command: CallbackType =
serde_json::from_str(msg).log_expect(&format!("Unable to parse string: {:?}", msg));
debug!("Incoming payload: {:?}", command);
match command {
CallbackType::SelectInstallDir { callback_name } => {
#[cfg(windows)]
let result = match nfd::open_pick_folder(None)
.log_expect("Unable to open folder dialog")
{
Response::Okay(v) => v,
_ => return,
};
#[cfg(not(windows))]
let result =
wv.dialog(Dialog::ChooseDirectory, "Select a install directory...", "");
if !result.is_empty() {
let result = serde_json::to_string(&result)
.log_expect("Unable to serialize response");
let command = format!("{}({});", callback_name, result);
debug!("Injecting response: {}", command);
wv.eval(&command);
}
}
CallbackType::Log { msg, kind } => {
let kind = match kind.as_ref() {
"info" | "log" => Level::Info,
"warn" => Level::Warn,
"error" => Level::Error,
_ => Level::Error,
};
log!(target: "liftinstall::frontend-js", kind, "{}", msg);
}
}
},
(),
);
// Start up the UI
frontend::launch(&app_name, is_launcher, framework);
}

View file

@ -2,72 +2,95 @@
* Misc interop helpers.
**/
// Explicitly use the Unicode version of the APIs
#ifndef UNICODE
#define UNICODE
#endif
#ifndef _UNICODE
#define _UNICODE
#endif
#include "windows.h"
#include "winnls.h"
#include "shobjidl.h"
#include "objbase.h"
#include "objidl.h"
#include "shlguid.h"
#include "shlobj.h"
// https://stackoverflow.com/questions/52101827/windows-10-getsyscolor-does-not-get-dark-ui-color-theme
extern "C" int isDarkThemeActive()
{
DWORD type;
DWORD value;
DWORD count = 4;
LSTATUS st = RegGetValue(
HKEY_CURRENT_USER,
TEXT("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"),
TEXT("AppsUseLightTheme"),
RRF_RT_REG_DWORD,
&type,
&value,
&count);
if (st == ERROR_SUCCESS && type == REG_DWORD)
return value == 0;
return false;
}
extern "C" int saveShortcut(
const char *shortcutPath,
const char *description,
const char *path,
const char *args,
const char *workingDir) {
char* errStr = NULL;
const wchar_t *shortcutPath,
const wchar_t *description,
const wchar_t *path,
const wchar_t *args,
const wchar_t *workingDir)
{
char *errStr = NULL;
HRESULT h;
IShellLink* shellLink = NULL;
IPersistFile* persistFile = NULL;
#ifdef _WIN64
wchar_t wName[MAX_PATH+1];
#else
WORD wName[MAX_PATH+1];
#endif
int id;
IShellLink *shellLink = NULL;
IPersistFile *persistFile = NULL;
// Initialize the COM library
h = CoInitialize(NULL);
if (FAILED(h)) {
if (FAILED(h))
{
errStr = "Failed to initialize COM library";
goto err;
}
h = CoCreateInstance( CLSID_ShellLink, NULL, CLSCTX_INPROC_SERVER,
IID_IShellLink, (PVOID*)&shellLink );
if (FAILED(h)) {
h = CoCreateInstance(CLSID_ShellLink, NULL, CLSCTX_INPROC_SERVER,
IID_IShellLink, (PVOID *)&shellLink);
if (FAILED(h))
{
errStr = "Failed to create IShellLink";
goto err;
}
h = shellLink->QueryInterface(IID_IPersistFile, (PVOID*)&persistFile);
if (FAILED(h)) {
h = shellLink->QueryInterface(IID_IPersistFile, (PVOID *)&persistFile);
if (FAILED(h))
{
errStr = "Failed to get IPersistFile";
goto err;
}
//Append the shortcut name to the folder
MultiByteToWideChar(CP_UTF8,0,shortcutPath,-1,wName,MAX_PATH);
// Load the file if it exists, to get the values for anything
// that we do not set. Ignore errors, such as if it does not exist.
h = persistFile->Load(wName, 0);
h = persistFile->Load(shortcutPath, 0);
// Set the fields for which the application has set a value
if (description!=NULL)
if (description != NULL)
shellLink->SetDescription(description);
if (path!=NULL)
if (path != NULL)
shellLink->SetPath(path);
if (args!=NULL)
if (args != NULL)
shellLink->SetArguments(args);
if (workingDir!=NULL)
if (workingDir != NULL)
shellLink->SetWorkingDirectory(workingDir);
//Save the shortcut to disk
h = persistFile->Save(wName, TRUE);
if (FAILED(h)) {
h = persistFile->Save(shortcutPath, TRUE);
if (FAILED(h))
{
errStr = "Failed to save shortcut";
goto err;
}
@ -87,3 +110,53 @@ err:
return h;
}
extern "C" int spawnDetached(const wchar_t *app, const wchar_t *cmdline)
{
STARTUPINFOW si;
PROCESS_INFORMATION pi;
// make non-constant copy of the parameters
// this is allowed per https://docs.microsoft.com/en-us/windows/desktop/api/processthreadsapi/nf-processthreadsapi-createprocessw#security-remarks
wchar_t *app_copy = _wcsdup(app);
wchar_t *cmdline_copy = _wcsdup(cmdline);
if (app_copy == NULL || cmdline_copy == NULL)
{
return GetLastError();
}
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
ZeroMemory(&pi, sizeof(pi));
if (!CreateProcessW(app, // module name
(LPWSTR)cmdline, // Command line, unicode is allowed
NULL, // Process handle not inheritable
NULL, // Thread handle not inheritable
FALSE, // Set handle inheritance to FALSE
CREATE_NO_WINDOW, // Create without window
NULL, // Use parent's environment block
NULL, // Use parent's starting directory
&si, // Pointer to STARTUPINFO structure
&pi) // Pointer to PROCESS_INFORMATION structure
)
{
return GetLastError();
}
// Close process and thread handles.
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return 0;
}
extern "C" HRESULT getSystemFolder(wchar_t *out_path)
{
PWSTR path = NULL;
HRESULT result = SHGetKnownFolderPath(FOLDERID_System, 0, NULL, &path);
if (result == S_OK)
{
wcscpy_s(out_path, MAX_PATH + 1, path);
CoTaskMemFree(path);
}
return result;
}

View file

@ -3,8 +3,8 @@
/// Basic definition of some running process.
#[derive(Debug)]
pub struct Process {
pub pid : usize,
pub name : String
pub pid: usize,
pub name: String,
}
#[cfg(windows)]
@ -15,31 +15,40 @@ mod natives {
const PROCESS_LEN: usize = 10192;
use std::ffi::CString;
use logging::LoggingErrors;
use std::env;
use std::process::Command;
use winapi::shared::minwindef::{DWORD, FALSE, MAX_PATH};
use winapi::shared::winerror::HRESULT;
use winapi::um::processthreadsapi::OpenProcess;
use winapi::um::psapi::{
EnumProcessModulesEx, GetModuleFileNameExW, K32EnumProcesses, LIST_MODULES_ALL,
};
use winapi::um::winnt::{
HANDLE, PROCESS_QUERY_INFORMATION, PROCESS_TERMINATE, PROCESS_VM_READ,
};
use winapi::um::processthreadsapi::{OpenProcess};
use winapi::um::psapi::{
K32EnumProcesses,
EnumProcessModulesEx, GetModuleFileNameExW, LIST_MODULES_ALL,
};
use widestring::U16CString;
extern "C" {
pub fn saveShortcut(
shortcutPath: *const ::std::os::raw::c_char,
description: *const ::std::os::raw::c_char,
path: *const ::std::os::raw::c_char,
args: *const ::std::os::raw::c_char,
workingDir: *const ::std::os::raw::c_char,
shortcutPath: *const winapi::ctypes::wchar_t,
description: *const winapi::ctypes::wchar_t,
path: *const winapi::ctypes::wchar_t,
args: *const winapi::ctypes::wchar_t,
workingDir: *const winapi::ctypes::wchar_t,
) -> ::std::os::raw::c_int;
pub fn isDarkThemeActive() -> ::std::os::raw::c_uint;
pub fn spawnDetached(
app: *const winapi::ctypes::wchar_t,
cmdline: *const winapi::ctypes::wchar_t,
) -> ::std::os::raw::c_int;
pub fn getSystemFolder(out_path: *mut ::std::os::raw::c_ushort) -> HRESULT;
}
// Needed here for Windows interop
@ -59,15 +68,16 @@ mod natives {
info!("Generating shortcut @ {:?}", source_file);
let native_target_dir = CString::new(source_file.clone())
.log_expect("Error while converting to C-style string");
let native_target_dir = U16CString::from_str(source_file.clone())
.log_expect("Error while converting to wchar_t");
let native_description =
CString::new(description).log_expect("Error while converting to C-style string");
U16CString::from_str(description).log_expect("Error while converting to wchar_t");
let native_target =
CString::new(target).log_expect("Error while converting to C-style string");
let native_args = CString::new(args).log_expect("Error while converting to C-style string");
U16CString::from_str(target).log_expect("Error while converting to wchar_t");
let native_args =
U16CString::from_str(args).log_expect("Error while converting to wchar_t");
let native_working_dir =
CString::new(working_dir).log_expect("Error while converting to C-style string");
U16CString::from_str(working_dir).log_expect("Error while converting to wchar_t");
let shortcutResult = unsafe {
saveShortcut(
@ -108,15 +118,41 @@ mod natives {
.log_expect("Unable to convert log path to string")
.replace(" ", "\\ ");
let target_arguments = format!("ping 127.0.0.1 -n 3 > nul && del {} {}", tool, log);
let target_arguments = format!("/C choice /C Y /N /D Y /T 2 & del {} {}", tool, log);
info!("Launching cmd with {:?}", target_arguments);
Command::new("C:\\Windows\\system32\\cmd.exe")
.arg("/C")
.arg(&target_arguments)
.spawn()
.log_expect("Unable to start child process");
// Needs to use `spawnDetached` which is an unsafe C/C++ function from interop.cpp
#[allow(unsafe_code)]
let spawn_result: i32 = unsafe {
let mut cmd_path = [0u16; MAX_PATH + 1];
let result = getSystemFolder(cmd_path.as_mut_ptr());
let mut pos = 0;
for x in cmd_path.iter() {
if *x == 0 {
break;
}
pos += 1;
}
if result != winapi::shared::winerror::S_OK {
return;
}
spawnDetached(
U16CString::from_str(
format!("{}\\cmd.exe", String::from_utf16_lossy(&cmd_path[..pos])).as_str(),
)
.log_expect("Unable to convert string to wchar_t")
.as_ptr(),
U16CString::from_str(target_arguments.as_str())
.log_expect("Unable to convert string to wchar_t")
.as_ptr(),
)
};
if spawn_result != 0 {
warn!("Unable to start child process");
}
}
#[allow(unsafe_code)]
@ -149,9 +185,7 @@ mod natives {
let size = ::std::mem::size_of::<DWORD>() * process_ids.len();
unsafe {
if K32EnumProcesses(process_ids.as_mut_ptr(),
size as DWORD,
&mut cb_needed) == 0 {
if K32EnumProcesses(process_ids.as_mut_ptr(), size as DWORD, &mut cb_needed) == 0 {
return vec![];
}
}
@ -160,7 +194,7 @@ mod natives {
let mut processes = Vec::new();
for i in 0 .. nb_processes {
for i in 0..nb_processes {
let pid = process_ids[i as usize];
unsafe {
@ -169,20 +203,25 @@ mod natives {
let mut process_name = [0u16; MAX_PATH + 1];
let mut cb_needed = 0;
if EnumProcessModulesEx(process_handler,
if EnumProcessModulesEx(
process_handler,
&mut h_mod,
::std::mem::size_of::<DWORD>() as DWORD,
&mut cb_needed,
LIST_MODULES_ALL) != 0 {
GetModuleFileNameExW(process_handler,
LIST_MODULES_ALL,
) != 0
{
GetModuleFileNameExW(
process_handler,
h_mod,
process_name.as_mut_ptr(),
MAX_PATH as DWORD + 1);
MAX_PATH as DWORD + 1,
);
let mut pos = 0;
for x in process_name.iter() {
if *x == 0 {
break
break;
}
pos += 1;
}
@ -199,6 +238,12 @@ mod natives {
processes
}
// Needed here for Windows interop
#[allow(unsafe_code)]
pub fn is_dark_mode_active() -> bool {
unsafe { isDarkThemeActive() == 1 }
}
}
#[cfg(not(windows))]
@ -209,6 +254,15 @@ mod natives {
use logging::LoggingErrors;
use sysinfo::{ProcessExt, SystemExt};
use dirs;
use slug::slugify;
use std::fs::{create_dir_all, File};
use std::io::Write;
#[cfg(target_os = "linux")]
pub fn create_shortcut(
name: &str,
description: &str,
@ -216,9 +270,51 @@ mod natives {
args: &str,
working_dir: &str,
) -> Result<String, String> {
// TODO: no-op
warn!("create_shortcut is stubbed!");
// FIXME: no icon will be shown since no icon is provided
let data_local_dir = dirs::data_local_dir();
match data_local_dir {
Some(x) => {
let mut path = x;
path.push("applications");
match create_dir_all(path.to_path_buf()) {
Ok(_) => (()),
Err(e) => {
return Err(format!(
"Local data directory does not exist and cannot be created: {}",
e
));
}
};
path.push(format!("{}.desktop", slugify(name))); // file name
let desktop_file = format!(
"[Desktop Entry]\nName={}\nExec=\"{}\" {}\nComment={}\nPath={}\n",
name, target, args, description, working_dir
);
let desktop_f = File::create(path);
let mut desktop_f = match desktop_f {
Ok(file) => file,
Err(e) => return Err(format!("Unable to create desktop file: {}", e)),
};
let mut desktop_f = desktop_f.write_all(desktop_file.as_bytes());
match desktop_f {
Ok(_) => Ok("".to_string()),
Err(e) => Err(format!("Unable to write desktop file: {}", e)),
}
}
// return error when failed to acquire local data directory
None => Err("Unable to determine local data directory".to_string()),
}
}
#[cfg(target_os = "macos")]
pub fn create_shortcut(
name: &str,
description: &str,
target: &str,
args: &str,
working_dir: &str,
) -> Result<String, String> {
warn!("STUB! Creating shortcut is not implemented on macOS");
Ok("".to_string())
}
@ -242,8 +338,23 @@ mod natives {
/// Returns a list of running processes
pub fn get_process_names() -> Vec<super::Process> {
// TODO: no-op
vec![]
// a platform-independent implementation using sysinfo crate
let mut processes: Vec<super::Process> = Vec::new();
let mut system = sysinfo::System::new();
system.refresh_all();
for (pid, procs) in system.get_process_list() {
processes.push(super::Process {
pid: *pid as usize,
name: procs.name().to_string(),
});
}
processes // return running processes
}
/// Returns if dark mode is active on this system.
pub fn is_dark_mode_active() -> bool {
// No-op
false
}
}

View file

@ -1,473 +0,0 @@
//! rest.rs
//!
//! Provides a HTTP/REST server for both frontend<->backend communication, as well
//! as talking to external applications.
use serde_json;
use futures::future;
use futures::Future;
use futures::Sink;
use futures::Stream;
use hyper::header::{ContentLength, ContentType};
use hyper::server::{Http, Request, Response, Service};
use hyper::{self, Error as HyperError, Get, Post, StatusCode};
use url::form_urlencoded;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::process::exit;
use std::process::Command;
use std::process::Stdio;
use std::sync::mpsc::channel;
use std::sync::Arc;
use std::sync::RwLock;
use std::thread::{self, JoinHandle};
use assets;
use installer::InstallMessage;
use installer::InstallerFramework;
use logging::LoggingErrors;
use http;
use config::Config;
use native;
#[derive(Serialize)]
struct FileSelection {
path: Option<String>,
}
/// Acts as a communication mechanism between the Hyper WebService and the rest of the
/// application.
pub struct WebServer {
_handle: JoinHandle<()>,
}
impl WebServer {
/// Creates a new web server with the specified address.
pub fn with_addr(
framework: Arc<RwLock<InstallerFramework>>,
addr: SocketAddr,
) -> Result<Self, HyperError> {
let handle = thread::spawn(move || {
let server = Http::new()
.bind(&addr, move || {
Ok(WebService {
framework: framework.clone(),
})
}).log_expect("Failed to bind to port");
server.run().log_expect("Failed to run HTTP server");
});
Ok(WebServer { _handle: handle })
}
}
/// Holds internal state for Hyper
struct WebService {
framework: Arc<RwLock<InstallerFramework>>,
}
impl Service for WebService {
type Request = Request;
type Response = Response;
type Error = hyper::Error;
type Future = Box<Future<Item = Self::Response, Error = Self::Error>>;
/// HTTP request handler
fn call(&self, req: Self::Request) -> Self::Future {
Box::new(future::ok(match (req.method(), req.path()) {
// This endpoint should be usable directly from a <script> tag during loading.
(&Get, "/api/attrs") => {
let framework = self
.framework
.read()
.log_expect("InstallerFramework has been dirtied");
let file = encapsulate_json(
"base_attributes",
&framework
.base_attributes
.to_json_str()
.log_expect("Failed to render JSON representation of config"),
);
Response::<hyper::Body>::new()
.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::json())
.with_body(file)
}
// Returns the web config loaded
(&Get, "/api/config") => {
let mut framework = self
.framework
.write()
.log_expect("InstallerFramework has been dirtied");
info!(
"Downloading configuration from {:?}...",
framework.base_attributes.target_url
);
match http::download_text(&framework.base_attributes.target_url)
.map(|x| Config::from_toml_str(&x))
{
Ok(Ok(config)) => {
framework.config = Some(config.clone());
info!("Configuration file downloaded successfully.");
let file = framework
.get_config()
.log_expect("Config should be loaded by now")
.to_json_str()
.log_expect("Failed to render JSON representation of config");
Response::<hyper::Body>::new()
.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::json())
.with_body(file)
}
Ok(Err(v)) => {
error!("Bad configuration file: {:?}", v);
Response::<hyper::Body>::new()
.with_status(StatusCode::ServiceUnavailable)
.with_header(ContentType::plaintext())
.with_body("Bad HTTP response")
}
Err(v) => {
error!(
"General connectivity error while downloading config: {:?}",
v
);
Response::<hyper::Body>::new()
.with_status(StatusCode::ServiceUnavailable)
.with_header(ContentLength(v.len() as u64))
.with_header(ContentType::plaintext())
.with_body(v)
}
}
}
// This endpoint should be usable directly from a <script> tag during loading.
(&Get, "/api/packages") => {
let framework = self
.framework
.read()
.log_expect("InstallerFramework has been dirtied");
let file = encapsulate_json(
"packages",
&serde_json::to_string(&framework.database)
.log_expect("Failed to render JSON representation of database"),
);
Response::<hyper::Body>::new()
.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::json())
.with_body(file)
}
// Returns the default path for a installation
(&Get, "/api/default-path") => {
let framework = self
.framework
.read()
.log_expect("InstallerFramework has been dirtied");
let path = framework.get_default_path();
let response = FileSelection { path };
let file = serde_json::to_string(&response)
.log_expect("Failed to render JSON payload of default path object");
Response::<hyper::Body>::new()
.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::json())
.with_body(file)
}
// Immediately exits the application
(&Get, "/api/exit") => {
let framework = self
.framework
.read()
.log_expect("InstallerFramework has been dirtied");
if let Some(ref v) = framework.launcher_path {
Command::new(v)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.log_expect("Unable to start child process");
}
if framework.burn_after_exit {
native::burn_on_exit(&framework.base_attributes.name);
}
exit(0);
}
// Gets properties such as if the application is in maintenance mode
(&Get, "/api/installation-status") => {
let framework = self
.framework
.read()
.log_expect("InstallerFramework has been dirtied");
let response = framework.get_installation_status();
let file = serde_json::to_string(&response)
.log_expect("Failed to render JSON payload of installation status object");
Response::<hyper::Body>::new()
.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::json())
.with_body(file)
}
// Streams the installation of a particular set of packages
(&Post, "/api/uninstall") => {
// 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.uninstall(&sender) {
error!("Uninstall error occurred: {:?}", v);
if let Err(v) = sender.send(InstallMessage::Error(v)) {
error!("Failed to send uninstall 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)
}));
}
// 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
(&Post, "/api/start-install") => {
// 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 results = form_urlencoded::parse(b.as_ref())
.into_owned()
.collect::<HashMap<String, String>>();
let mut to_install = Vec::new();
let mut path: Option<String> = None;
// Transform results into just an array of stuff to install
for (key, value) in &results {
if key == "path" {
path = Some(value.to_owned());
continue;
}
if value == "true" {
to_install.push(key.to_owned());
}
}
// The frontend always provides this
let path = path.log_expect(
"No path specified by frontend when one should have already existed",
);
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");
let new_install = !framework.preexisting_install;
if new_install {
framework.set_install_dir(&path);
}
if let Err(v) = framework.install(to_install, &sender, new_install) {
error!("Install error occurred: {:?}", v);
if let Err(v) = sender.send(InstallMessage::Error(v)) {
error!("Failed to send install 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 mut panic_after_finish = false;
let response = match receiver
.recv() {
Ok(v) => v,
Err(v) => {
error!("Queue message failed: {:?}", v);
panic_after_finish = true;
InstallMessage::Error("Internal error".to_string())
}
};
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");
if panic_after_finish {
panic!("Failed to read from queue (flushed error message successfully)");
}
}
});
Response::<hyper::Body>::new()
//.with_header(ContentLength(file.len() as u64))
.with_header(ContentType::plaintext())
.with_body(rx)
}));
}
// Static file handler
(&Get, _) => {
// At this point, we have a web browser client. Search for a index page
// if needed
let mut path: String = req.path().to_owned();
if path.ends_with('/') {
path += "index.html";
}
match assets::file_from_string(&path) {
Some((content_type, file)) => {
let content_type = ContentType(content_type.parse().log_expect(
"Failed to parse content type into correct representation",
));
Response::<hyper::Body>::new()
.with_header(ContentLength(file.len() as u64))
.with_header(content_type)
.with_body(file)
}
None => Response::new().with_status(StatusCode::NotFound),
}
}
// Fallthrough for POST/PUT/CONNECT/...
_ => Response::new().with_status(StatusCode::NotFound),
}))
}
}
/// Encapsulates JSON as a injectable Javascript script.
fn encapsulate_json(field_name: &str, json: &str) -> String {
format!("var {} = {};", field_name, json)
}

111
src/self_update.rs Normal file
View file

@ -0,0 +1,111 @@
//! self_update.rs
//!
//! Handles different components of self-updating.
use std::fs::{remove_file, File};
use std::path::{Path, PathBuf};
use std::process::{exit, Command};
use std::{thread, time};
use clap::{App, ArgMatches};
use logging::LoggingErrors;
/// Swaps around the main executable if needed.
pub fn perform_swap(current_exe: &PathBuf, to_path: Option<&str>) {
// Check to see if we are currently in a self-update
if let Some(to_path) = to_path {
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()
);
// Attempt it a few times because Windows can hold a lock
for i in 1..=5 {
let swap_result = if cfg!(windows) {
use std::fs::copy;
copy(&current_exe, &to_path).map(|_x| ())
} else {
use std::fs::rename;
rename(&current_exe, &to_path)
};
match swap_result {
Ok(_) => break,
Err(e) => {
if i < 5 {
info!("Copy attempt failed: {:?}, retrying in 3 seconds.", e);
thread::sleep(time::Duration::from_millis(3000));
} else {
Err::<(), _>(e).log_expect("Copying new binary failed");
}
}
}
}
Command::new(to_path)
.spawn()
.log_expect("Unable to start child process");
exit(0);
}
}
pub fn check_args<'a>(app: App<'a, '_>, current_path: &Path) -> Option<ArgMatches<'a>> {
// If we just finished a update, we need to inject our previous command line arguments
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")
};
let matches = app.get_matches_from(database);
info!("Parsed command line arguments from original instance");
remove_file(args_file).log_expect("Unable to clean up args file");
Some(matches)
} else {
None
}
}
pub fn cleanup(current_path: &Path) {
// Cleanup any remaining new maintenance tool instances if they exist
if cfg!(windows) {
let updater_executable = current_path.join("maintenancetool_new.exe");
if updater_executable.exists() {
// Sleep a little bit to allow Windows to close the previous file handle
thread::sleep(time::Duration::from_millis(3000));
// Attempt it a few times because Windows can hold a lock
for i in 1..=5 {
let swap_result = remove_file(&updater_executable);
match swap_result {
Ok(_) => break,
Err(e) => {
if i < 5 {
info!("Cleanup attempt failed: {:?}, retrying in 3 seconds.", e);
thread::sleep(time::Duration::from_millis(3000));
} else {
warn!("Deleting temp binary failed after 5 attempts: {:?}", e);
}
}
}
}
}
}
}

View file

@ -41,17 +41,19 @@ impl ReleaseSource for GithubReleases {
.get(&format!(
"https://api.github.com/repos/{}/releases",
config.repo
)).header(USER_AGENT, "liftinstall (j-selby)")
))
.header(USER_AGENT, "liftinstall (j-selby)")
.send()
.map_err(|x| format!("Error while sending HTTP request: {:?}", x))?;
match response.status() {
StatusCode::OK => {}
StatusCode::FORBIDDEN => {
return Err(format!(
return Err(
"GitHub is rate limiting you. Try moving to a internet connection \
that isn't shared, and/or disabling VPNs."
));
.to_string(),
);
}
_ => {
return Err(format!("Bad status code: {:?}.", response.status()));
@ -87,14 +89,18 @@ impl ReleaseSource for GithubReleases {
let string = match asset["name"].as_str() {
Some(v) => v,
None => {
return Err("JSON payload missing information about release name".to_string())
return Err(
"JSON payload missing information about release name".to_string()
);
}
};
let url = match asset["browser_download_url"].as_str() {
Some(v) => v,
None => {
return Err("JSON payload missing information about release URL".to_string())
return Err(
"JSON payload missing information about release URL".to_string()
);
}
};

View file

@ -9,7 +9,7 @@ pub mod github;
use self::types::ReleaseSource;
/// Returns a ReleaseSource by a name, if possible
pub fn get_by_name(name: &str) -> Option<Box<ReleaseSource>> {
pub fn get_by_name(name: &str) -> Option<Box<dyn ReleaseSource>> {
match name {
"github" => Some(Box::new(github::GithubReleases::new())),
_ => None,

View file

@ -12,7 +12,7 @@ use tasks::resolver::ResolvePackageTask;
use http::stream_file;
use number_prefix::{decimal_prefix, Prefixed, Standalone};
use number_prefix::{NumberPrefix, Prefixed, Standalone};
use logging::LoggingErrors;
@ -25,7 +25,7 @@ impl Task for DownloadPackageTask {
&mut self,
mut input: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
assert_eq!(input.len(), 1);
@ -68,11 +68,11 @@ impl Task for DownloadPackageTask {
};
// Pretty print data volumes
let pretty_current = match decimal_prefix(downloaded as f64) {
let pretty_current = match NumberPrefix::decimal(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) {
let pretty_total = match NumberPrefix::decimal(size as f64) {
Standalone(bytes) => format!("{} bytes", bytes),
Prefixed(prefix, n) => format!("{:.0} {}B", n, prefix),
};

View file

@ -7,8 +7,8 @@ use tasks::TaskDependency;
use tasks::TaskMessage;
use tasks::TaskParamType;
use native::Process;
use native::get_process_names;
use native::Process;
use std::process;
@ -19,7 +19,7 @@ impl Task for EnsureOnlyInstanceTask {
&mut self,
input: Vec<TaskParamType>,
context: &mut InstallerFramework,
_messenger: &Fn(&TaskMessage),
_messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
assert_eq!(input.len(), 0);
@ -32,13 +32,13 @@ impl Task for EnsureOnlyInstanceTask {
let exe = name;
if exe.ends_with("maintenancetool.exe") || exe.ends_with("maintenancetool") {
return Err(format!("Maintenance tool is already running!"));
return Err("Maintenance tool is already running!".to_string());
}
for package in &context.database.packages {
for file in &package.files {
if exe.ends_with(file) {
return Err(format!("The installed application is currently running!"));
return Err("The installed application is currently running!".to_string());
}
}
}
@ -52,6 +52,6 @@ impl Task for EnsureOnlyInstanceTask {
}
fn name(&self) -> String {
format!("EnsureOnlyInstanceTask")
"EnsureOnlyInstanceTask".to_string()
}
}

View file

@ -26,7 +26,7 @@ impl Task for InstallTask {
&mut self,
_: Vec<TaskParamType>,
_: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
messenger(&TaskMessage::DisplayMessage("Wrapping up...", 0.0));
Ok(TaskParamType::None)

View file

@ -21,7 +21,7 @@ impl Task for VerifyInstallDirTask {
&mut self,
input: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
assert_eq!(input.len(), 0);
messenger(&TaskMessage::DisplayMessage(

View file

@ -20,7 +20,7 @@ impl Task for InstallGlobalShortcutsTask {
&mut self,
_: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
messenger(&TaskMessage::DisplayMessage(
"Generating global shortcut...",

View file

@ -34,7 +34,7 @@ impl Task for InstallPackageTask {
&mut self,
mut input: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
messenger(&TaskMessage::DisplayMessage(
&format!("Installing package {:?}...", self.name),

View file

@ -22,7 +22,7 @@ impl Task for InstallShortcutsTask {
&mut self,
_: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
messenger(&TaskMessage::DisplayMessage(
&format!("Generating shortcuts for package {:?}...", self.name),

View file

@ -49,12 +49,12 @@ pub enum TaskOrdering {
/// A dependency of a task with various properties.
pub struct TaskDependency {
ordering: TaskOrdering,
task: Box<Task>,
task: Box<dyn Task>,
}
impl TaskDependency {
/// Builds a new dependency from the specified task.
pub fn build(ordering: TaskOrdering, task: Box<Task>) -> TaskDependency {
pub fn build(ordering: TaskOrdering, task: Box<dyn Task>) -> TaskDependency {
TaskDependency { ordering, task }
}
}
@ -74,7 +74,7 @@ pub trait Task {
&mut self,
input: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String>;
/// Returns a vector containing all dependencies that need to be executed
@ -87,7 +87,7 @@ pub trait Task {
/// The dependency tree allows for smart iteration on a Task struct.
pub struct DependencyTree {
task: Box<Task>,
task: Box<dyn Task>,
dependencies: Vec<(TaskOrdering, DependencyTree)>,
}
@ -120,7 +120,7 @@ impl DependencyTree {
pub fn execute(
&mut self,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
let total_tasks = (self.dependencies.len() + 1) as f64;
@ -133,8 +133,8 @@ impl DependencyTree {
continue;
}
let result = i.execute(context, &|msg: &TaskMessage| match msg {
&TaskMessage::DisplayMessage(msg, progress) => {
let result = i.execute(context, &|msg: &TaskMessage| match *msg {
TaskMessage::DisplayMessage(msg, progress) => {
messenger(&TaskMessage::DisplayMessage(
msg,
progress / total_tasks + (1.0 / total_tasks) * f64::from(count),
@ -159,8 +159,8 @@ impl DependencyTree {
let task_result = self
.task
.execute(inputs, context, &|msg: &TaskMessage| match msg {
&TaskMessage::DisplayMessage(msg, progress) => {
.execute(inputs, context, &|msg: &TaskMessage| match *msg {
TaskMessage::DisplayMessage(msg, progress) => {
messenger(&TaskMessage::DisplayMessage(
msg,
progress / total_tasks + (1.0 / total_tasks) * f64::from(count),
@ -179,8 +179,8 @@ impl DependencyTree {
continue;
}
let result = i.execute(context, &|msg: &TaskMessage| match msg {
&TaskMessage::DisplayMessage(msg, progress) => {
let result = i.execute(context, &|msg: &TaskMessage| match *msg {
TaskMessage::DisplayMessage(msg, progress) => {
messenger(&TaskMessage::DisplayMessage(
msg,
progress / total_tasks + (1.0 / total_tasks) * f64::from(count),
@ -206,7 +206,7 @@ impl DependencyTree {
}
/// Builds a new pipeline from the specified task, iterating on dependencies.
pub fn build(task: Box<Task>) -> DependencyTree {
pub fn build(task: Box<dyn Task>) -> DependencyTree {
let dependencies = task
.dependencies()
.into_iter()

View file

@ -24,7 +24,7 @@ impl Task for ResolvePackageTask {
&mut self,
input: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
assert_eq!(input.len(), 0);
let mut metadata: Option<PackageDescription> = None;

View file

@ -14,7 +14,7 @@ impl Task for SaveDatabaseTask {
&mut self,
input: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
assert_eq!(input.len(), 0);
messenger(&TaskMessage::DisplayMessage(

View file

@ -23,7 +23,7 @@ impl Task for SaveExecutableTask {
&mut self,
input: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
assert_eq!(input.len(), 0);
messenger(&TaskMessage::DisplayMessage(

View file

@ -19,7 +19,7 @@ impl Task for UninstallTask {
&mut self,
_: Vec<TaskParamType>,
_: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
messenger(&TaskMessage::DisplayMessage("Wrapping up...", 0.0));
Ok(TaskParamType::None)

View file

@ -18,7 +18,7 @@ impl Task for UninstallGlobalShortcutsTask {
&mut self,
input: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
assert_eq!(input.len(), 0);

View file

@ -27,7 +27,7 @@ impl Task for UninstallPackageTask {
&mut self,
input: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
assert_eq!(input.len(), 1);

View file

@ -24,7 +24,7 @@ impl Task for UninstallShortcutsTask {
&mut self,
input: Vec<TaskParamType>,
context: &mut InstallerFramework,
messenger: &Fn(&TaskMessage),
messenger: &dyn Fn(&TaskMessage),
) -> Result<TaskParamType, String> {
assert_eq!(input.len(), 0);

File diff suppressed because one or more lines are too long

View file

@ -1,88 +0,0 @@
/* roboto-regular - latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src: url('../fonts/roboto-v18-latin-regular.eot'); /* IE9 Compat Modes */
src: local('Roboto'), local('Roboto-Regular'),
url('../fonts/roboto-v18-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
url('../fonts/roboto-v18-latin-regular.woff') format('woff');
}
html, body {
overflow: hidden;
height: 100%;
}
body, div, span, h1, h2, h3, h4, h5, h6 {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
cursor: default;
}
pre {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
cursor: text;
}
.tile.is-child > .box {
height: 100%;
}
.has-padding {
padding: 2rem;
position: relative;
}
.clickable-box {
cursor: pointer;
}
.clickable-box label {
pointer-events: none;
}
.is-max-height {
height: 100%;
}
.is-bottom-floating {
position: absolute;
bottom: 0;
}
.is-right-floating {
position: absolute;
right: 0;
}
.has-padding .is-right-floating {
right: 1rem;
}
.is-left-floating {
position: absolute;
left: 0;
}
.has-padding .is-left-floating {
left: 1rem;
}
.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 9999;
padding: 20px;
background: #fff;
}

View file

@ -1,67 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title id="window-title">... Installer</title>
<link rel="icon" href="/favicon.ico" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="/css/bulma.min.css" type="text/css">
<link rel="stylesheet" href="/css/main.css" type="text/css">
</head>
<body class="is-max-height">
<div class="fullscreen" id="ie-blackout" style="display: none">
<div class="title">Your computer is out of date.</div>
<div class="subtitle">
Make sure that your computer is up to date, and that you have Internet Explorer 11 installed.
</div>
<div class="subtitle">
Please note we do not support pirated or unsupported versions of Windows.
</div>
</div>
<script type="text/javascript">
if (!document.__proto__) {
document.getElementById("ie-blackout").style.display = "block";
}
</script>
<div id="app" class="is-max-height">
<section class="section is-max-height">
<div class="container is-max-height">
<div class="columns is-max-height">
<div class="column is-one-third has-padding" v-if="!metadata.is_launcher">
<img src="/logo.png" width="60%" />
<br />
<br />
<h2 class="subtitle" v-if="!metadata.preexisting_install">
Welcome to the {{ attrs.name }} installer!
</h2>
<h2 class="subtitle" v-if="!metadata.preexisting_install">
We will have you up and running in just a few moments.
</h2>
<h2 class="subtitle" v-if="metadata.preexisting_install">
Welcome to the {{ attrs.name }} Maintenance Tool.
</h2>
</div>
<router-view></router-view>
</div>
</div>
</section>
</div>
<script src="/js/vue.min.js" type="text/javascript"></script>
<script src="/js/vue-router.min.js" type="text/javascript"></script>
<script src="/api/attrs" type="text/javascript"></script>
<script src="/js/helpers.js" type="text/javascript"></script>
<script src="/js/views.js" type="text/javascript"></script>
<script src="/js/main.js" type="text/javascript"></script>
</body>
</html>

View file

@ -1,144 +0,0 @@
/**
* helpers.js
*
* Additional state-less helper methods.
*/
var request_id = 0;
/**
* Makes a AJAX request.
*
* @param path The path to connect to.
* @param successCallback A callback with a JSON payload.
* @param failCallback A fail callback. Optional.
* @param data POST data. Optional.
*/
function ajax(path, successCallback, failCallback, data) {
if (failCallback === undefined) {
failCallback = defaultFailHandler;
}
console.log("Making HTTP request to " + path);
var req = new XMLHttpRequest();
req.addEventListener("load", function() {
// The server can sometimes return a string error. Make sure we handle this.
if (this.status === 200 && this.getResponseHeader('Content-Type').indexOf("application/json") !== -1) {
successCallback(JSON.parse(this.responseText));
} else {
failCallback(this.responseText);
}
});
req.addEventListener("error", failCallback);
req.open(data == null ? "GET" : "POST", path + "?nocache=" + request_id++, true);
// Rocket only currently supports URL encoded forms.
req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
if (data != null) {
var form = "";
for (var key in data) {
if (!data.hasOwnProperty(key)) {
continue;
}
if (form !== "") {
form += "&";
}
form += encodeURIComponent(key) + "=" + encodeURIComponent(data[key]);
}
req.send(form);
} else {
req.send();
}
}
/**
* Makes a AJAX request, streaming each line as it arrives. Type should be text/plain,
* each line will be interpreted as JSON separately.
*
* @param path The path to connect to.
* @param callback A callback with a JSON payload. Called for every line as it comes.
* @param successCallback A callback with a raw text payload.
* @param failCallback A fail callback. Optional.
* @param data POST data. Optional.
*/
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) {
successCallback(this.responseText);
} else {
failCallback(this.responseText);
}
});
var buffer = "";
var seenBytes = 0;
req.onreadystatechange = function() {
if(req.readyState > 2) {
buffer += req.responseText.substr(seenBytes);
var pointer;
while ((pointer = buffer.indexOf("\n")) >= 0) {
var line = buffer.substring(0, pointer).trim();
buffer = buffer.substring(pointer + 1);
if (line.length === 0) {
continue;
}
var contents = JSON.parse(line);
callback(contents);
}
seenBytes = req.responseText.length;
}
};
req.addEventListener("error", failCallback);
req.open(data == null ? "GET" : "POST", path + "?nocache=" + request_id++, true);
// Rocket only currently supports URL encoded forms.
req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
if (data != null) {
var form = "";
for (var key in data) {
if (!data.hasOwnProperty(key)) {
continue;
}
if (form !== "") {
form += "&";
}
form += encodeURIComponent(key) + "=" + encodeURIComponent(data[key]);
}
req.send(form);
} else {
req.send();
}
}
/**
* The default handler if a AJAX request fails. Not to be used directly.
*
* @param e The XMLHttpRequest that failed.
*/
function defaultFailHandler(e) {
console.error("A AJAX request failed, and was not caught:");
console.error(e);
}

View file

@ -1,68 +0,0 @@
// Overwrite loggers with the logging backend
if (window.external !== undefined && window.external.invoke !== undefined) {
window.onerror = function(msg, url, line) {
old_onerror(msg, url, line);
window.external.invoke(JSON.stringify({
Log: {
kind: "error",
msg: msg + " @ " + url + ":" + line
}
}));
};
// Borrowed from http://tobyho.com/2012/07/27/taking-over-console-log/
function intercept(method){
console[method] = function(){
var message = Array.prototype.slice.apply(arguments).join(' ');
window.external.invoke(JSON.stringify({
Log: {
kind: method,
msg: message
}
}));
}
}
var methods = ['log', 'warn', 'error'];
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) {
app.install_location = name;
}
var app = new Vue({
router: router,
data: {
attrs: base_attributes,
config : {},
install_location : "",
// If the option to pick an install location should be provided
show_install_location : true,
metadata : {
database : [],
install_path : "",
preexisting_install : false
}
},
methods: {
"exit": function() {
ajax("/api/exit", function() {});
}
}
}).$mount("#app");

View file

@ -1,460 +0,0 @@
const DownloadConfig = {
template: `
<div class="column has-padding">
<h4 class="subtitle">Downloading config...</h4>
<br />
<progress class="progress is-info is-medium" value="0" max="100">
0%
</progress>
</div>
`,
created: function() {
this.download_install_status();
},
methods: {
download_install_status: function() {
var that = this; // IE workaround
ajax("/api/installation-status", function(e) {
app.metadata = e;
that.download_config();
});
},
download_config: function() {
var that = this; // IE workaround
ajax("/api/config", function(e) {
app.config = e;
that.choose_next_state();
}, function(e) {
console.error("Got error while downloading config: "
+ e);
if (app.metadata.is_launcher) {
// Just launch the target application
app.exit();
} else {
router.replace({name: 'showerr', params: {msg: "Got error while downloading config: "
+ e}});
}
});
},
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;
// Copy over installed packages
for (var x = 0; x < app.config.packages.length; x++) {
app.config.packages[x].default = false;
app.config.packages[x].installed = false;
}
for (var i = 0; i < app.metadata.database.packages.length; i++) {
// Find this config package
for (var x = 0; x < app.config.packages.length; x++) {
if (app.config.packages[x].name === app.metadata.database.packages[i].name) {
app.config.packages[x].default = true;
app.config.packages[x].installed = true;
}
}
}
if (app.metadata.is_launcher) {
router.replace("/install/regular");
} else {
router.replace("/modify");
}
} else {
for (var x = 0; x < app.config.packages.length; x++) {
app.config.packages[x].installed = false;
}
// Need to do a bit more digging to get at the
// install location.
ajax("/api/default-path", function(e) {
if (e.path != null) {
app.install_location = e.path;
}
});
router.replace("/packages");
}
}
}
};
const SelectPackages = {
template: `
<div class="column has-padding">
<h4 class="subtitle">Select which packages you want to install:</h4>
<!-- Build options -->
<div class="tile is-ancestor">
<div class="tile is-parent" v-for="package in $root.$data.config.packages" :index="package.name">
<div class="tile is-child">
<div class="box clickable-box" v-on:click.capture.stop="package.default = !package.default">
<label class="checkbox">
<input type="checkbox" v-model="package.default" />
{{ package.name }}
<span v-if="package.installed"><i>(installed)</i></span>
</label>
<p>
{{ package.description }}
</p>
</div>
</div>
</div>
</div>
<div class="subtitle is-6" v-if="!$root.$data.metadata.preexisting_install && advanced">Install Location</div>
<div class="field has-addons" v-if="!$root.$data.metadata.preexisting_install && advanced">
<div class="control is-expanded">
<input class="input" type="text" v-model="$root.$data.install_location"
placeholder="Enter a install path here">
</div>
<div class="control">
<a class="button is-dark" v-on:click="select_file">
Select
</a>
</div>
</div>
<div class="is-right-floating is-bottom-floating">
<div class="field is-grouped">
<p class="control">
<a class="button is-medium" v-if="!$root.$data.config.hide_advanced && !$root.$data.metadata.preexisting_install && !advanced"
v-on:click="advanced = true">Advanced...</a>
</p>
<p class="control">
<a class="button is-dark is-medium" v-if="!$root.$data.metadata.preexisting_install"
v-on:click="install">Install</a>
</p>
<p class="control">
<a class="button is-dark is-medium" v-if="$root.$data.metadata.preexisting_install"
v-on:click="install">Modify</a>
</p>
</div>
</div>
<div class="field is-grouped is-left-floating is-bottom-floating">
<p class="control">
<a class="button is-medium" v-if="$root.$data.metadata.preexisting_install"
v-on:click="go_back">Back</a>
</p>
</div>
</div>
`,
data: function() {
return {
advanced: false
}
},
methods: {
select_file: function() {
window.external.invoke(JSON.stringify({
SelectInstallDir: {
callback_name: "selectFileCallback"
}
}));
},
install: function() {
router.push("/install/regular");
},
go_back: function() {
router.go(-1);
}
}
};
const InstallPackages = {
template: `
<div class="column has-padding">
<h4 class="subtitle" v-if="$root.$data.metadata.is_launcher || is_update">Checking for updates...</h4>
<h4 class="subtitle" v-else-if="is_uninstall">Uninstalling...</h4>
<h4 class="subtitle" v-else-if="is_updater_update">Downloading self-update...</h4>
<h4 class="subtitle" v-else>Installing...</h4>
<div v-html="$root.$data.config.installing_message"></div>
<br />
<div v-html="progress_message"></div>
<progress class="progress is-info is-medium" v-bind:value="progress" max="100">
{{ progress }}%
</progress>
</div>
`,
data: function() {
return {
progress: 0.0,
progress_message: "Please wait...",
is_uninstall: false,
is_updater_update: false,
is_update: false,
failed_with_error: false,
packages_installed: 0
}
},
created: function() {
this.is_uninstall = this.$route.params.kind === "uninstall";
this.is_updater_update = this.$route.params.kind === "updater";
this.is_update = this.$route.params.kind === "update";
console.log("Installer kind: " + this.$route.params.kind);
this.install();
},
methods: {
install: function() {
var results = {};
for (var package_index = 0; package_index < app.config.packages.length; package_index++) {
var current_package = app.config.packages[package_index];
if (current_package.default != null) {
results[current_package.name] = current_package.default;
}
}
results["path"] = app.install_location;
var that = this; // IE workaround
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;
}
if (line.hasOwnProperty("PackageInstalled")) {
that.packages_installed += 1;
}
if (line.hasOwnProperty("Error")) {
if (app.metadata.is_launcher) {
app.exit();
} else {
that.failed_with_error = true;
router.replace({name: 'showerr', params: {msg: line.Error}});
}
}
}, function(e) {
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) {
if (that.is_uninstall) {
router.replace({name: 'complete', params: {
uninstall: true,
update: that.is_update,
installed: that.packages_installed
}});
} else {
router.replace({name: 'complete', params: {
uninstall: false,
update: that.is_update,
installed: that.packages_installed
}});
}
}
}
}, undefined, results);
}
}
};
const ErrorView = {
template: `
<div class="column has-padding">
<h4 class="subtitle">An error occurred:</h4>
<pre>{{ msg }}</pre>
<div class="field is-grouped is-right-floating is-bottom-floating">
<p class="control">
<a class="button is-primary is-medium" v-if="remaining" v-on:click="go_back">Back</a>
</p>
</div>
</div>
`,
data: function() {
return {
msg: this.$route.params.msg,
remaining: window.history.length > 1
}
},
methods: {
go_back: function() {
router.go(-1);
}
}
};
const CompleteView = {
template: `
<div class="column has-padding">
<div v-if="was_update">
<div v-if="has_installed">
<h4 class="subtitle">{{ $root.$data.attrs.name }} has been updated.</h4>
<p>You can find your installed applications in your start menu.</p>
</div>
<div v-else>
<h4 class="subtitle">{{ $root.$data.attrs.name }} is already up to date!</h4>
<p>You can find your installed applications in your start menu.</p>
</div>
</div>
<div v-else-if="was_install">
<h4 class="subtitle">Thanks for installing {{ $root.$data.attrs.name }}!</h4>
<p>You can find your installed applications in your start menu.</p>
<img src="/how-to-open.png" />
</div>
<div v-else>
<h4 class="subtitle">{{ $root.$data.attrs.name }} has been uninstalled.</h4>
</div>
<div class="field is-grouped is-right-floating is-bottom-floating">
<p class="control">
<a class="button is-dark is-medium" v-on:click="exit">Exit</a>
</p>
</div>
</div>
`,
data: function() {
return {
was_install: !this.$route.params.uninstall,
was_update: this.$route.params.update,
has_installed: this.$route.params.packages_installed > 0
}
},
methods: {
exit: function() {
app.exit();
}
}
};
const ModifyView = {
template: `
<div class="column has-padding">
<h4 class="subtitle">Choose an option:</h4>
<a class="button is-dark is-medium" v-on:click="update">
Update
</a>
<br />
<br />
<a class="button is-dark is-medium" v-on:click="modify_packages">
Modify
</a>
<br />
<br />
<a class="button is-dark is-medium" v-on:click="prepare_uninstall">
Uninstall
</a>
<div class="modal is-active" v-if="show_uninstall">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Are you sure you want to uninstall {{ $root.$data.attrs.name }}?</p>
</header>
<footer class="modal-card-foot">
<button class="button is-danger" v-on:click="uninstall">Yes</button>
<button class="button" v-on:click="cancel_uninstall">No</button>
</footer>
</div>
</div>
</div>
`,
data: function() {
return {
show_uninstall: false
}
},
methods: {
update: function() {
router.push("/install/update");
},
modify_packages: function() {
router.push("/packages");
},
prepare_uninstall: function() {
this.show_uninstall = true;
},
cancel_uninstall: function() {
this.show_uninstall = false;
},
uninstall: function() {
router.push("/install/uninstall");
},
}
};
const router = new VueRouter({
routes: [
{
path: '/config',
name: 'config',
component: DownloadConfig
},
{
path: '/packages',
name: 'packages',
component: SelectPackages
},
{
path: '/install/:kind',
name: 'install',
component: InstallPackages
},
{
path: '/showerr',
name: 'showerr',
component: ErrorView
},
{
path: '/complete/:uninstall/:update/:packages_installed',
name: 'complete',
component: CompleteView
},
{
path: '/modify',
name: 'modify',
component: ModifyView
},
{
path: '/',
redirect: '/config'
}
]
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

3
ui/.browserslistrc Normal file
View file

@ -0,0 +1,3 @@
> 1%
last 2 versions
not ie <= 11

5
ui/.editorconfig Normal file
View file

@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

17
ui/.eslintrc.js Normal file
View file

@ -0,0 +1,17 @@
module.exports = {
root: true,
env: {
node: true
},
'extends': [
'plugin:vue/essential',
'@vue/standard'
],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
},
parserOptions: {
parser: 'babel-eslint'
}
}

21
ui/.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*

29
ui/README.md Normal file
View file

@ -0,0 +1,29 @@
# ui
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn run serve
```
### Compiles and minifies for production
```
yarn run build
```
### Run your tests
```
yarn run test
```
### Lints and fixes files
```
yarn run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
ui/babel.config.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/app'
]
}

95
ui/mock-server.js Normal file
View file

@ -0,0 +1,95 @@
'use strict'
const express = require('express')
const app = express()
const port = 3000
function progressSimulation (res) {
var progress = 0.0
var timer = setInterval(() => {
var resp = JSON.stringify({ Status: ['Processing...', progress] }) + '\n'
progress += 0.1
res.write(resp)
if (progress >= 1) {
res.status(200).end()
clearInterval(timer)
}
}, 1500)
}
function returnConfig (res) {
res.json({
installing_message:
'Test Banner <strong>Bold</strong>&nbsp;<pre>Code block</pre>&nbsp;<i>Italic</i>&nbsp;<del>Strike</del>',
new_tool: null,
packages: [
{
name: 'Test 1',
description: 'LiftInstall GUI Test 1',
default: true,
source: {
name: 'github',
match: '^test$',
config: { repo: 'j-selby/liftinstall' }
},
shortcuts: []
},
{
name: 'Test 2',
description:
'Different Banner <strong>Bold</strong>&nbsp;<pre>Code block</pre>&nbsp;<i>Italic</i>&nbsp;<del>Strike</del>',
default: null,
source: {
name: 'github',
match: '^test2$',
config: { repo: 'j-selby/liftinstall' }
},
shortcuts: []
}
],
hide_advanced: false
})
}
app.get('/api/attrs', (req, res) => {
res.send(
`var base_attributes = {"name":"yuzu","target_url":"https://raw.githubusercontent.com/j-selby/test-installer/master/config.linux.v2.toml"};`
)
})
app.get('/api/dark-mode', (req, res) => {
res.json(false)
})
app.get('/api/installation-status', (req, res) => {
res.json({
database: { packages: [], shortcuts: [] },
install_path: null,
preexisting_install: false,
is_launcher: false,
launcher_path: null
})
})
app.get('/api/default-path', (req, res) => {
res.json({ path: '/tmp/test/' })
})
app.get('/api/config', (req, res) => {
setTimeout(() => {
returnConfig(res)
}, 3000)
})
app.post('/api/start-install', (req, res) => {
console.log(`-- Install: ${req.body}`)
progressSimulation(res)
})
app.get('/api/exit', (req, res) => {
console.log('-- Exit')
res.status(204)
})
console.log(`Listening on ${port}...`)
app.listen(port)

27
ui/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "ui",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"buefy": "^0.7.7",
"vue": "^2.6.6",
"vue-router": "^3.0.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.5.0",
"@vue/cli-plugin-eslint": "^3.5.0",
"@vue/cli-service": "^3.5.0",
"@vue/eslint-config-standard": "^4.0.0",
"babel-eslint": "^10.0.1",
"eslint": "^5.8.0",
"eslint-plugin-vue": "^5.0.0",
"express": "^4.17.1",
"http-proxy-middleware": "^0.19.1",
"vue-template-compiler": "^2.5.21"
}
}

5
ui/postcss.config.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {}
}
}

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

35
ui/public/index.html Normal file
View file

@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=11">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<script src="/api/attrs" type="text/javascript"></script>
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title id="window-title">... Installer</title>
</head>
<body>
<div class="fullscreen" id="ie-blackout" style="display: none">
<div class="title">Your computer is out of date.</div>
<div class="subtitle">
Make sure that your computer is up to date, and that you have Internet Explorer 11 installed.
</div>
<div class="subtitle">
Please note we do not support pirated or unsupported versions of Windows.
</div>
</div>
<script type="text/javascript">
if (!document.__proto__) {
document.getElementById("ie-blackout").style.display = "block";
}
</script>
<noscript>
<strong>You need JavaScript enabled in your Windows Internet Options to install this application.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

129
ui/src/App.vue Normal file
View file

@ -0,0 +1,129 @@
<template>
<div id="app" class="is-max-height">
<section class="section is-max-height">
<div class="container is-max-height">
<div class="columns is-max-height">
<div class="column is-one-third has-padding" v-if="!$root.$data.metadata.is_launcher">
<img src="./assets/logo.png" width="60%" alt="Application icon" />
<br />
<br />
<h2 class="subtitle" v-if="!$root.$data.metadata.preexisting_install">
Welcome to the {{ $root.$data.attrs.name }} installer!
</h2>
<h2 class="subtitle" v-if="!$root.$data.metadata.preexisting_install">
We will have you up and running in just a few moments.
</h2>
<h2 class="subtitle" v-if="$root.$data.metadata.preexisting_install">
Welcome to the {{ $root.$data.attrs.name }} Maintenance Tool.
</h2>
</div>
<router-view />
</div>
</div>
</section>
</div>
</template>
<style>
/* roboto-regular - latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src: url('./assets/fonts/roboto-v18-latin-regular.eot'); /* IE9 Compat Modes */
src: local('Roboto'), local('Roboto-Regular'),
url('./assets/fonts/roboto-v18-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
url('./assets/fonts/roboto-v18-latin-regular.woff') format('woff');
}
html, body {
overflow: hidden;
height: 100%;
}
body, div, span, h1, h2, h3, h4, h5, h6 {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
cursor: default;
}
pre {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
cursor: text;
}
.tile.is-child > .box {
height: 100%;
}
.has-padding {
padding: 2rem;
position: relative;
}
.clickable-box {
cursor: pointer;
}
.clickable-box label {
pointer-events: none;
}
.is-max-height {
height: 100%;
}
.is-bottom-floating {
position: absolute;
bottom: 0;
}
.is-top-floating {
position: absolute;
top: 0;
}
.is-right-floating {
position: absolute;
right: 0;
}
.has-padding .is-right-floating {
right: 1rem;
}
.is-left-floating {
position: absolute;
left: 0;
}
.has-padding .is-left-floating {
left: 1rem;
}
.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 9999;
padding: 20px;
background: #fff;
}
/* Dark mode */
body.has-background-black-ter .subtitle, body.has-background-black-ter .column > div {
color: hsl(0, 0%, 96%);
}
</style>

View file

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View file

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

144
ui/src/helpers.js Normal file
View file

@ -0,0 +1,144 @@
/**
* helpers.js
*
* Additional state-less helper methods.
*/
var request_id = 0
/**
* Makes a AJAX request.
*
* @param path The path to connect to.
* @param successCallback A callback with a JSON payload.
* @param failCallback A fail callback. Optional.
* @param data POST data. Optional.
*/
export function ajax (path, successCallback, failCallback, data) {
if (failCallback === undefined) {
failCallback = defaultFailHandler
}
console.log('Making HTTP request to ' + path)
var req = new XMLHttpRequest()
req.addEventListener('load', function () {
// The server can sometimes return a string error. Make sure we handle this.
if (this.status === 200 && this.getResponseHeader('Content-Type').indexOf('application/json') !== -1) {
successCallback(JSON.parse(this.responseText))
} else {
failCallback(this.responseText)
}
})
req.addEventListener('error', failCallback)
req.open(data == null ? 'GET' : 'POST', path + '?nocache=' + request_id++, true)
// Rocket only currently supports URL encoded forms.
req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
if (data != null) {
var form = ''
for (var key in data) {
if (!data.hasOwnProperty(key)) {
continue
}
if (form !== '') {
form += '&'
}
form += encodeURIComponent(key) + '=' + encodeURIComponent(data[key])
}
req.send(form)
} else {
req.send()
}
}
/**
* Makes a AJAX request, streaming each line as it arrives. Type should be text/plain,
* each line will be interpreted as JSON separately.
*
* @param path The path to connect to.
* @param callback A callback with a JSON payload. Called for every line as it comes.
* @param successCallback A callback with a raw text payload.
* @param failCallback A fail callback. Optional.
* @param data POST data. Optional.
*/
export 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) {
successCallback(this.responseText)
} else {
failCallback(this.responseText)
}
})
var buffer = ''
var seenBytes = 0
req.onreadystatechange = function () {
if (req.readyState > 2) {
buffer += req.responseText.substr(seenBytes)
var pointer
while ((pointer = buffer.indexOf('\n')) >= 0) {
var line = buffer.substring(0, pointer).trim()
buffer = buffer.substring(pointer + 1)
if (line.length === 0) {
continue
}
var contents = JSON.parse(line)
callback(contents)
}
seenBytes = req.responseText.length
}
}
req.addEventListener('error', failCallback)
req.open(data == null ? 'GET' : 'POST', path + '?nocache=' + request_id++, true)
// Rocket only currently supports URL encoded forms.
req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
if (data != null) {
var form = ''
for (var key in data) {
if (!data.hasOwnProperty(key)) {
continue
}
if (form !== '') {
form += '&'
}
form += encodeURIComponent(key) + '=' + encodeURIComponent(data[key])
}
req.send(form)
} else {
req.send()
}
}
/**
* The default handler if a AJAX request fails. Not to be used directly.
*
* @param e The XMLHttpRequest that failed.
*/
function defaultFailHandler (e) {
console.error('A AJAX request failed, and was not caught:')
console.error(e)
}

119
ui/src/main.js Normal file
View file

@ -0,0 +1,119 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import { ajax, stream_ajax } from './helpers'
import Buefy from 'buefy'
import 'buefy/dist/buefy.css'
Vue.config.productionTip = false
Vue.use(Buefy)
// Borrowed from http://tobyho.com/2012/07/27/taking-over-console-log/
function intercept (method) {
console[method] = function () {
var message = Array.prototype.slice.apply(arguments).join(' ')
window.external.invoke(
JSON.stringify({
Log: {
kind: method,
msg: message
}
})
)
}
}
// See if we have access to the JSON interface
var has_external_interface = false;
try {
window.external.invoke(JSON.stringify({
Test: {}
}))
has_external_interface = true;
} catch (e) {
console.warn("Running without JSON interface - unexpected behaviour may occur!")
}
// Overwrite loggers with the logging backend
if (has_external_interface) {
window.onerror = function (msg, url, line) {
window.external.invoke(
JSON.stringify({
Log: {
kind: 'error',
msg: msg + ' @ ' + url + ':' + line
}
})
)
}
var methods = ['log', 'warn', 'error']
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
}
}
// Check to see if we need to enable dark mode
ajax('/api/dark-mode', function (enable) {
if (enable) {
document.body.classList.add('has-background-black-ter')
}
})
window.addEventListener('keydown', disable_shortcuts)
document.getElementById('window-title').innerText =
base_attributes.name + ' Installer'
function selectFileCallback (name) {
app.install_location = name
}
var app = new Vue({
router: router,
data: {
attrs: base_attributes,
config: {},
install_location: '',
// If the option to pick an install location should be provided
show_install_location: true,
metadata: {
database: [],
install_path: '',
preexisting_install: false
}
},
render: function (caller) {
return caller(App)
},
methods: {
exit: function () {
ajax(
'/api/exit',
function () {},
function (msg) {
var search_location = app.metadata.install_path.length > 0 ? app.metadata.install_path :
"the location where this installer is";
app.$router.replace({ name: 'showerr', params: { msg: msg +
'\n\nPlease upload the log file (in ' + search_location + ') to ' +
'the ' + app.attrs.name + ' team'
}});
}
)
},
ajax: ajax,
stream_ajax: stream_ajax
}
}).$mount('#app')
console.log("Vue started")

49
ui/src/router.js Normal file
View file

@ -0,0 +1,49 @@
import Vue from 'vue'
import Router from 'vue-router'
import DownloadConfig from './views/DownloadConfig.vue'
import SelectPackages from './views/SelectPackages.vue'
import ErrorView from './views/ErrorView.vue'
import InstallPackages from './views/InstallPackages.vue'
import CompleteView from './views/CompleteView.vue'
import ModifyView from './views/ModifyView.vue'
Vue.use(Router)
export default new Router({
routes: [
{
path: '/config',
name: 'config',
component: DownloadConfig
},
{
path: '/packages',
name: 'packages',
component: SelectPackages
},
{
path: '/install/:kind',
name: 'install',
component: InstallPackages
},
{
path: '/showerr',
name: 'showerr',
component: ErrorView
},
{
path: '/complete/:uninstall/:update/:packages_installed',
name: 'complete',
component: CompleteView
},
{
path: '/modify',
name: 'modify',
component: ModifyView
},
{
path: '/',
redirect: '/config'
}
]
})

View file

@ -0,0 +1,50 @@
<template>
<div class="column has-padding">
<div v-if="was_update">
<div v-if="has_installed">
<h4 class="subtitle">{{ $root.$data.attrs.name }} has been updated.</h4>
<p>You can find your installed applications in your start menu.</p>
</div>
<div v-else>
<h4 class="subtitle">{{ $root.$data.attrs.name }} is already up to date!</h4>
<p>You can find your installed applications in your start menu.</p>
</div>
</div>
<div v-else-if="was_install">
<h4 class="subtitle">Thanks for installing {{ $root.$data.attrs.name }}!</h4>
<p>You can find your installed applications in your start menu.</p>
<img src="../assets/how-to-open.png" alt="Where yuzu is installed"/>
</div>
<div v-else>
<h4 class="subtitle">{{ $root.$data.attrs.name }} has been uninstalled.</h4>
</div>
<div class="field is-grouped is-right-floating is-bottom-floating">
<p class="control">
<a class="button is-dark is-medium" v-on:click="exit">Exit</a>
</p>
</div>
</div>
</template>
<script>
export default {
name: 'CompleteView',
data: function () {
return {
was_install: !this.$route.params.uninstall,
was_update: this.$route.params.update,
has_installed: this.$route.params.packages_installed > 0
}
},
methods: {
exit: function () {
this.$root.exit()
}
}
}
</script>

View file

@ -0,0 +1,97 @@
<template>
<div class="column has-padding">
<h4 class="subtitle">Downloading config...</h4>
<br />
<progress class="progress is-info is-medium" max="100">
0%
</progress>
</div>
</template>
<script>
export default {
name: 'DownloadConfig',
created: function () {
this.download_install_status()
},
methods: {
download_install_status: function () {
var that = this
this.$root.ajax('/api/installation-status', function (e) {
that.$root.metadata = e
that.download_config()
})
},
download_config: function () {
var that = this
this.$root.ajax('/api/config', function (e) {
that.$root.config = e
that.choose_next_state()
}, function (e) {
console.error('Got error while downloading config: ' +
e)
if (that.$root.metadata.is_launcher) {
// Just launch the target application
that.$root.exit()
} else {
that.$router.replace({ name: 'showerr',
params: { msg: 'Got error while downloading config: ' +
e } })
}
})
},
choose_next_state: function () {
var app = this.$root
// Update the updater if needed
if (app.config.new_tool) {
this.$router.push('/install/updater')
return
}
if (app.metadata.preexisting_install) {
app.install_location = app.metadata.install_path
// Copy over installed packages
for (var x = 0; x < app.config.packages.length; x++) {
app.config.packages[x].default = false
app.config.packages[x].installed = false
}
for (var i = 0; i < app.metadata.database.packages.length; i++) {
// Find this config package
for (var x = 0; x < app.config.packages.length; x++) {
if (app.config.packages[x].name === app.metadata.database.packages[i].name) {
app.config.packages[x].default = true
app.config.packages[x].installed = true
}
}
}
if (app.metadata.is_launcher) {
this.$router.replace('/install/regular')
} else {
this.$router.replace('/modify')
}
} else {
for (var x = 0; x < app.config.packages.length; x++) {
app.config.packages[x].installed = false
}
// Need to do a bit more digging to get at the
// install location.
this.$root.ajax('/api/default-path', function (e) {
if (e.path != null) {
app.install_location = e.path
}
})
this.$router.replace('/packages')
}
}
}
}
</script>

View file

@ -0,0 +1,59 @@
<template>
<div class="column" v-bind:class="{ 'has-padding': !$root.$data.metadata.is_launcher }">
<b-message title="An error occurred" type="is-danger" :closable="false">
<div id="error_msg" v-html="msg"></div>
</b-message>
<div class="field is-grouped is-right-floating" v-bind:class="{ 'is-bottom-floating': !$root.$data.metadata.is_launcher, 'is-top-floating': $root.$data.metadata.is_launcher }">
<p class="control">
<a class="button is-primary is-medium" v-if="remaining && !$root.$data.metadata.is_launcher" v-on:click="go_back">Back</a>
<a class="button is-primary is-medium" v-if="$root.$data.metadata.is_launcher" v-on:click="exit">Exit</a>
</p>
</div>
</div>
</template>
<style>
.pre-wrap {
/* https://css-tricks.com/snippets/css/make-pre-text-wrap/ */
white-space: pre-wrap; /* css-3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
white-space: -o-pre-wrap; /* Opera 7 */
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
#error_msg {
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
cursor: text;
}
</style>
<script>
export default {
name: 'ErrorView',
data: function () {
return {
// https://stackoverflow.com/questions/6234773/can-i-escape-html-special-chars-in-javascript
msg: this.$route.params.msg
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.replace(/\n/g, "<br />"),
remaining: window.history.length > 1
}
},
methods: {
go_back: function () {
this.$router.go(-1)
},
exit: function () {
this.$root.exit()
}
}
}
</script>

View file

@ -0,0 +1,117 @@
<template>
<div class="column has-padding">
<h4 class="subtitle" v-if="$root.$data.metadata.is_launcher || is_update">Checking for updates...</h4>
<h4 class="subtitle" v-else-if="is_uninstall">Uninstalling...</h4>
<h4 class="subtitle" v-else-if="is_updater_update">Downloading self-update...</h4>
<h4 class="subtitle" v-else>Installing...</h4>
<div v-html="$root.$data.config.installing_message"></div>
<br />
<div v-html="progress_message"></div>
<progress class="progress is-info is-medium" v-bind:value="progress" max="100">
{{ progress }}%
</progress>
</div>
</template>
<script>
export default {
name: 'InstallPackages',
data: function () {
return {
progress: 0.0,
progress_message: 'Please wait...',
is_uninstall: false,
is_updater_update: false,
is_update: false,
failed_with_error: false,
packages_installed: 0
}
},
created: function () {
this.is_uninstall = this.$route.params.kind === 'uninstall'
this.is_updater_update = this.$route.params.kind === 'updater'
this.is_update = this.$route.params.kind === 'update'
console.log('Installer kind: ' + this.$route.params.kind)
this.install()
},
methods: {
install: function () {
var that = this
var app = this.$root
var results = {}
for (var package_index = 0; package_index < app.config.packages.length; package_index++) {
var current_package = app.config.packages[package_index]
if (current_package.default != null) {
results[current_package.name] = current_package.default
}
}
results['path'] = app.install_location
var targetUrl = '/api/start-install'
if (this.is_uninstall) {
targetUrl = '/api/uninstall'
}
if (this.is_updater_update) {
targetUrl = '/api/update-updater'
}
this.$root.stream_ajax(targetUrl, function (line) {
// On progress line received from server
if (line.hasOwnProperty('Status')) {
that.progress_message = line.Status[0]
that.progress = line.Status[1] * 100
}
if (line.hasOwnProperty('PackageInstalled')) {
that.packages_installed += 1
}
if (line.hasOwnProperty('Error')) {
that.failed_with_error = true
that.$router.replace({ name: 'showerr', params: { msg: line.Error } })
}
}, function (e) {
// On request completed
if (that.is_updater_update) {
// Continue with what we were doing
if (app.metadata.is_launcher) {
that.$router.replace('/install/regular')
} else {
if (app.metadata.preexisting_install) {
that.$router.replace('/modify')
} else {
that.$router.replace('/packages')
}
}
} else {
if (app.metadata.is_launcher) {
app.exit()
} else if (!that.failed_with_error) {
if (that.is_uninstall) {
that.$router.replace({ name: 'complete',
params: {
uninstall: true,
update: that.is_update,
installed: that.packages_installed
} })
} else {
that.$router.replace({ name: 'complete',
params: {
uninstall: false,
update: that.is_update,
installed: that.packages_installed
} })
}
}
}
}, undefined, results)
}
}
}
</script>

View file

@ -0,0 +1,62 @@
<template>
<div class="column has-padding">
<h4 class="subtitle">Choose an option:</h4>
<a class="button is-dark is-medium" v-on:click="update">
Update
</a>
<br />
<br />
<a class="button is-dark is-medium" v-on:click="modify_packages">
Modify
</a>
<br />
<br />
<a class="button is-dark is-medium" v-on:click="prepare_uninstall">
Uninstall
</a>
<div class="modal is-active" v-if="show_uninstall">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Are you sure you want to uninstall {{ $root.$data.attrs.name }}?</p>
</header>
<footer class="modal-card-foot">
<button class="button is-danger" v-on:click="uninstall">Yes</button>
<button class="button" v-on:click="cancel_uninstall">No</button>
</footer>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ModifyView',
data: function () {
return {
show_uninstall: false
}
},
methods: {
update: function () {
this.$router.push('/install/update')
},
modify_packages: function () {
this.$router.push('/packages')
},
prepare_uninstall: function () {
this.show_uninstall = true
},
cancel_uninstall: function () {
this.show_uninstall = false
},
uninstall: function () {
this.$router.push('/install/uninstall')
}
}
}
</script>

View file

@ -0,0 +1,87 @@
<template>
<div class="column has-padding">
<h4 class="subtitle">Select which packages you want to install:</h4>
<!-- Build options -->
<div class="tile is-ancestor">
<div class="tile is-parent" v-for="Lpackage in $root.$data.config.packages" :key="Lpackage.name" :index="Lpackage.name">
<div class="tile is-child">
<div class="box clickable-box" v-on:click.capture.stop="Lpackage.default = !Lpackage.default">
<label class="checkbox">
<b-checkbox v-model="Lpackage.default">
{{ Lpackage.name }}
</b-checkbox>
<span v-if="Lpackage.installed"><i>(installed)</i></span>
</label>
<p>
{{ Lpackage.description }}
</p>
</div>
</div>
</div>
</div>
<div class="subtitle is-6" v-if="!$root.$data.metadata.preexisting_install && advanced">Install Location</div>
<div class="field has-addons" v-if="!$root.$data.metadata.preexisting_install && advanced">
<div class="control is-expanded">
<input class="input" type="text" v-model="$root.$data.install_location"
placeholder="Enter a install path here">
</div>
<div class="control">
<a class="button is-dark" v-on:click="select_file">
Select
</a>
</div>
</div>
<div class="is-right-floating is-bottom-floating">
<div class="field is-grouped">
<p class="control">
<a class="button is-medium" v-if="!$root.$data.config.hide_advanced && !$root.$data.metadata.preexisting_install && !advanced"
v-on:click="advanced = true">Advanced...</a>
</p>
<p class="control">
<a class="button is-dark is-medium" v-if="!$root.$data.metadata.preexisting_install"
v-on:click="install">Install</a>
</p>
<p class="control">
<a class="button is-dark is-medium" v-if="$root.$data.metadata.preexisting_install"
v-on:click="install">Modify</a>
</p>
</div>
</div>
<div class="field is-grouped is-left-floating is-bottom-floating">
<p class="control">
<a class="button is-medium" v-if="$root.$data.metadata.preexisting_install"
v-on:click="go_back">Back</a>
</p>
</div>
</div>
</template>
<script>
export default {
name: 'SelectPackages',
data: function () {
return {
advanced: false
}
},
methods: {
select_file: function () {
window.external.invoke(JSON.stringify({
SelectInstallDir: {
callback_name: 'selectFileCallback'
}
}))
},
install: function () {
this.$router.push('/install/regular')
},
go_back: function () {
this.$router.go(-1)
}
}
}
</script>

6
ui/vue.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
devServer: {
proxy: 'http://127.0.0.1:3000'
},
filenameHashing: false
}

8288
ui/yarn.lock Normal file

File diff suppressed because it is too large Load diff