Dynamically fetch configuration file

This commit is contained in:
James 2018-08-07 15:34:57 +10:00 committed by James Lonie
parent 08cf5dea6f
commit b32e9f6f33
11 changed files with 203 additions and 104 deletions

View file

@ -1,31 +1,2 @@
[general]
name = "yuzu" name = "yuzu"
installing_message = "Reminder: yuzu is an <b>experimental</b> emulator. Stuff will break!" target_url = "https://raw.githubusercontent.com/j-selby/test-installer/master/config.v1.toml"
[[packages]]
name = "yuzu Nightly"
description = "The nightly build of yuzu contains already reviewed and tested features."
[packages.source]
name = "github"
match = "^yuzu-#PLATFORM#(-mingw)?-[0-9]*-[0-9a-f]*.zip$"
[packages.source.config]
repo = "yuzu-emu/yuzu-nightly"
[[packages]]
name = "yuzu Canary"
description = "The canary build of yuzu has additional features that are still waiting on review."
[packages.source]
name = "github"
match = "^yuzu-#PLATFORM#(-mingw)?-[0-9]*-[0-9a-f]*.zip$"
[packages.source.config]
repo = "yuzu-emu/yuzu-canary"
[[packages]]
name = "Test package"
description = "Just a testing package"
default = true
[packages.source]
name = "github"
match = "^TestPackage.zip$"
[packages.source.config]
repo = "j-selby/test-installer"

View file

@ -30,14 +30,26 @@ pub struct PackageDescription {
/// Describes the application itself. /// Describes the application itself.
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct GeneralConfig { pub struct BaseAttributes {
pub name: String, pub name: String,
pub installing_message: String, pub target_url: String,
}
impl BaseAttributes {
/// Serialises as a JSON string.
pub fn to_json_str(&self) -> Result<String, SerdeError> {
serde_json::to_string(self)
}
/// Builds a configuration from a specified TOML string.
pub fn from_toml_str(contents: &str) -> Result<Self, TomlError> {
toml::from_str(contents)
}
} }
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config { pub struct Config {
pub general: GeneralConfig, pub installing_message: String,
pub packages: Vec<PackageDescription>, pub packages: Vec<PackageDescription>,
} }

View file

@ -8,6 +8,19 @@ use reqwest;
use std::io::Read; use std::io::Read;
/// Downloads a text file from the specified URL.
pub fn download_text(url: &str) -> Result<String, String> {
// TODO: Decrease check time
let mut client = match reqwest::get(url) {
Ok(v) => v,
Err(v) => return Err(format!("Failed to GET resource: {:?}", v)),
};
client
.text()
.map_err(|v| format!("Failed to get text from resource: {:?}", v))
}
/// Streams a file from a HTTP server. /// Streams a file from a HTTP server.
pub fn stream_file<F>(url: &str, mut callback: F) -> Result<(), String> pub fn stream_file<F>(url: &str, mut callback: F) -> Result<(), String>
where where

View file

@ -13,6 +13,7 @@ use std::path::PathBuf;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use config::BaseAttributes;
use config::Config; use config::Config;
use sources::types::Version; use sources::types::Version;
@ -36,7 +37,8 @@ pub enum InstallMessage {
/// The installer framework contains metadata about packages, what is installable, what isn't, /// The installer framework contains metadata about packages, what is installable, what isn't,
/// etc. /// etc.
pub struct InstallerFramework { pub struct InstallerFramework {
pub config: Config, pub base_attributes: BaseAttributes,
pub config: Option<Config>,
pub database: Vec<LocalInstallation>, pub database: Vec<LocalInstallation>,
pub install_path: Option<PathBuf>, pub install_path: Option<PathBuf>,
pub preexisting_install: bool, pub preexisting_install: bool,
@ -64,13 +66,13 @@ pub struct LocalInstallation {
impl InstallerFramework { impl InstallerFramework {
/// Returns a copy of the configuration. /// Returns a copy of the configuration.
pub fn get_config(&self) -> Config { pub fn get_config(&self) -> Option<Config> {
self.config.clone() self.config.clone()
} }
/// Returns the default install path. /// Returns the default install path.
pub fn get_default_path(&self) -> Option<String> { pub fn get_default_path(&self) -> Option<String> {
let app_name = &self.config.general.name; let app_name = &self.base_attributes.name;
let base_dir = match var("LOCALAPPDATA") { let base_dir = match var("LOCALAPPDATA") {
Ok(path) => PathBuf::from(path), Ok(path) => PathBuf::from(path),
@ -194,9 +196,10 @@ impl InstallerFramework {
} }
/// Creates a new instance of the Installer Framework with a specified Config. /// Creates a new instance of the Installer Framework with a specified Config.
pub fn new(config: Config) -> Self { pub fn new(attrs: BaseAttributes) -> Self {
InstallerFramework { InstallerFramework {
config, base_attributes: attrs,
config: None,
database: Vec::new(), database: Vec::new(),
install_path: None, install_path: None,
preexisting_install: false, preexisting_install: false,
@ -207,7 +210,7 @@ impl InstallerFramework {
/// Creates a new instance of the Installer Framework with a specified Config, managing /// Creates a new instance of the Installer Framework with a specified Config, managing
/// a pre-existing installation. /// a pre-existing installation.
pub fn new_with_db(config: Config, install_path: &Path) -> Result<Self, String> { pub fn new_with_db(attrs: BaseAttributes, install_path: &Path) -> Result<Self, String> {
let path = install_path.to_owned(); let path = install_path.to_owned();
let metadata_path = path.join("metadata.json"); let metadata_path = path.join("metadata.json");
let metadata_file = match File::open(metadata_path) { let metadata_file = match File::open(metadata_path) {
@ -221,7 +224,8 @@ impl InstallerFramework {
}; };
Ok(InstallerFramework { Ok(InstallerFramework {
config, base_attributes: attrs,
config: None,
database, database,
install_path: Some(path), install_path: Some(path),
preexisting_install: true, preexisting_install: true,

View file

@ -17,7 +17,8 @@ pub fn setup_logger() -> Result<(), fern::InitError> {
record.level(), record.level(),
message message
)) ))
}).level(log::LevelFilter::Info) })
.level(log::LevelFilter::Info)
.chain(io::stdout()) .chain(io::stdout())
.chain(fern::log_file("installer.log")?) .chain(fern::log_file("installer.log")?)
.apply()?; .apply()?;
@ -31,6 +32,7 @@ where
Self: Sized, Self: Sized,
{ {
/// Unwraps this object. See `unwrap()`. /// Unwraps this object. See `unwrap()`.
#[inline]
fn log_unwrap(self) -> T { fn log_unwrap(self) -> T {
self.log_expect("Failed to unwrap") self.log_expect("Failed to unwrap")
} }
@ -40,6 +42,7 @@ where
} }
impl<T, E: Debug> LoggingErrors<T> for Result<T, E> { impl<T, E: Debug> LoggingErrors<T> for Result<T, E> {
#[inline]
fn log_expect(self, msg: &str) -> T { fn log_expect(self, msg: &str) -> T {
match self { match self {
Ok(v) => v, Ok(v) => v,
@ -52,6 +55,7 @@ impl<T, E: Debug> LoggingErrors<T> for Result<T, E> {
} }
impl<T> LoggingErrors<T> for Option<T> { impl<T> LoggingErrors<T> for Option<T> {
#[inline]
fn log_expect(self, msg: &str) -> T { fn log_expect(self, msg: &str) -> T {
match self { match self {
Some(v) => v, Some(v) => v,

View file

@ -50,8 +50,6 @@ mod tasks;
use web_view::*; use web_view::*;
use config::Config;
use installer::InstallerFramework; use installer::InstallerFramework;
#[cfg(windows)] #[cfg(windows)]
@ -71,6 +69,8 @@ use clap::App;
use clap::Arg; use clap::Arg;
use log::Level; use log::Level;
use config::BaseAttributes;
// TODO: Fetch this over a HTTP request? // TODO: Fetch this over a HTTP request?
static RAW_CONFIG: &'static str = include_str!("../config.toml"); static RAW_CONFIG: &'static str = include_str!("../config.toml");
@ -83,9 +83,10 @@ enum CallbackType {
fn main() { fn main() {
logging::setup_logger().expect("Unable to setup logging!"); logging::setup_logger().expect("Unable to setup logging!");
let config = Config::from_toml_str(RAW_CONFIG).log_expect("Config file could not be read"); let config =
BaseAttributes::from_toml_str(RAW_CONFIG).log_expect("Config file could not be read");
let app_name = config.general.name.clone(); let app_name = config.name.clone();
let matches = App::new(format!("{} installer", app_name)) let matches = App::new(format!("{} installer", app_name))
.version(env!("CARGO_PKG_VERSION")) .version(env!("CARGO_PKG_VERSION"))
@ -96,7 +97,8 @@ fn main() {
.value_name("TARGET") .value_name("TARGET")
.help("Launches the specified executable after checking for updates") .help("Launches the specified executable after checking for updates")
.takes_value(true), .takes_value(true),
).get_matches(); )
.get_matches();
info!("{} installer", app_name); info!("{} installer", app_name);

View file

@ -32,6 +32,10 @@ use installer::InstallerFramework;
use logging::LoggingErrors; use logging::LoggingErrors;
use std::process::Command; use std::process::Command;
use http;
use config::Config;
#[derive(Serialize)] #[derive(Serialize)]
struct FileSelection { struct FileSelection {
path: Option<String>, path: Option<String>,
@ -55,7 +59,8 @@ impl WebServer {
Ok(WebService { Ok(WebService {
framework: framework.clone(), framework: framework.clone(),
}) })
}).log_expect("Failed to bind to port"); })
.log_expect("Failed to bind to port");
server.run().log_expect("Failed to run HTTP server"); server.run().log_expect("Failed to run HTTP server");
}); });
@ -79,16 +84,16 @@ impl Service for WebService {
fn call(&self, req: Self::Request) -> Self::Future { fn call(&self, req: Self::Request) -> Self::Future {
Box::new(future::ok(match (req.method(), req.path()) { Box::new(future::ok(match (req.method(), req.path()) {
// This endpoint should be usable directly from a <script> tag during loading. // This endpoint should be usable directly from a <script> tag during loading.
(&Get, "/api/config") => { (&Get, "/api/attrs") => {
let framework = self let framework = self
.framework .framework
.read() .read()
.log_expect("InstallerFramework has been dirtied"); .log_expect("InstallerFramework has been dirtied");
let file = encapsulate_json( let file = encapsulate_json(
"config", "base_attributes",
&framework &framework
.get_config() .base_attributes
.to_json_str() .to_json_str()
.log_expect("Failed to render JSON representation of config"), .log_expect("Failed to render JSON representation of config"),
); );
@ -98,6 +103,59 @@ impl Service for WebService {
.with_header(ContentType::json()) .with_header(ContentType::json())
.with_body(file) .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. // This endpoint should be usable directly from a <script> tag during loading.
(&Get, "/api/packages") => { (&Get, "/api/packages") => {
let framework = self let framework = self

View file

@ -40,7 +40,8 @@ impl ReleaseSource for GithubReleases {
.get(&format!( .get(&format!(
"https://api.github.com/repos/{}/releases", "https://api.github.com/repos/{}/releases",
config.repo config.repo
)).header(UserAgent::new("liftinstall (j-selby)")) ))
.header(UserAgent::new("liftinstall (j-selby)"))
.send() .send()
.map_err(|x| format!("Error while sending HTTP request: {:?}", x))?; .map_err(|x| format!("Error while sending HTTP request: {:?}", x))?;
@ -52,8 +53,8 @@ impl ReleaseSource for GithubReleases {
.text() .text()
.map_err(|x| format!("Failed to decode HTTP response body: {:?}", x))?; .map_err(|x| format!("Failed to decode HTTP response body: {:?}", x))?;
let result: serde_json::Value = serde_json::from_str(&body) let result: serde_json::Value =
.map_err(|x| format!("Failed to parse response: {:?}", x))?; serde_json::from_str(&body).map_err(|x| format!("Failed to parse response: {:?}", x))?;
let result: &Vec<serde_json::Value> = result let result: &Vec<serde_json::Value> = result
.as_array() .as_array()

View file

@ -41,7 +41,12 @@ impl Task for InstallPackageTask {
let mut installed_files = Vec::new(); let mut installed_files = Vec::new();
let mut metadata: Option<PackageDescription> = None; let mut metadata: Option<PackageDescription> = None;
for description in &context.config.packages { for description in &context
.config
.as_ref()
.log_expect("Should have packages by now")
.packages
{
if self.name == description.name { if self.name == description.name {
metadata = Some(description.clone()); metadata = Some(description.clone());
break; break;

View file

@ -26,7 +26,12 @@ impl Task for ResolvePackageTask {
) -> Result<TaskParamType, String> { ) -> Result<TaskParamType, String> {
assert_eq!(input.len(), 0); assert_eq!(input.len(), 0);
let mut metadata: Option<PackageDescription> = None; let mut metadata: Option<PackageDescription> = None;
for description in &context.config.packages { for description in &context
.config
.as_ref()
.log_expect("Should have packages by now")
.packages
{
if self.name == description.name { if self.name == description.name {
metadata = Some(description.clone()); metadata = Some(description.clone());
break; break;

View file

@ -21,7 +21,7 @@
<div class="columns"> <div class="columns">
<div class="column is-one-third" v-if="!is_launcher"> <div class="column is-one-third" v-if="!is_launcher">
<h1 class="title"> <h1 class="title">
Welcome to the {{ config.general.name }} installer! Welcome to the {{ attrs.name }} installer!
</h1> </h1>
<h2 class="subtitle"> <h2 class="subtitle">
We will have you up and running in just a few moments. We will have you up and running in just a few moments.
@ -37,6 +37,15 @@
<a class="button is-primary is-pulled-right" v-on:click="back_to_packages">Back</a> <a class="button is-primary is-pulled-right" v-on:click="back_to_packages">Back</a>
</div> </div>
<div class="column" v-else-if="is_downloading_config">
<h4 class="subtitle">Downloading config...</h4>
<div v-html="progress_message"></div>
<progress class="progress is-info is-medium" v-bind:value="progress" max="100">
{{ progress }}%
</progress>
</div>
<div class="column" v-else-if="modify_install"> <div class="column" v-else-if="modify_install">
<h4 class="subtitle">Choose an option:</h4> <h4 class="subtitle">Choose an option:</h4>
@ -99,7 +108,7 @@
<div class="column" v-else-if="is_installing"> <div class="column" v-else-if="is_installing">
<h4 class="subtitle" v-if="is_launcher">Checking for updates...</h4> <h4 class="subtitle" v-if="is_launcher">Checking for updates...</h4>
<h4 class="subtitle" v-else>Installing...</h4> <h4 class="subtitle" v-else>Installing...</h4>
<div v-html="config.general.installing_message"></div> <div v-html="config.installing_message"></div>
<br /> <br />
<div v-html="progress_message"></div> <div v-html="progress_message"></div>
@ -109,7 +118,7 @@
</div> </div>
<div class="column" v-else-if="is_finished"> <div class="column" v-else-if="is_finished">
<h4 class="subtitle">Thanks for installing {{ config.general.name }}!</h4> <h4 class="subtitle">Thanks for installing {{ attrs.name }}!</h4>
<a class="button is-primary is-pulled-right" v-on:click="exit">Exit</a> <a class="button is-primary is-pulled-right" v-on:click="exit">Exit</a>
</div> </div>
@ -126,7 +135,7 @@
<div class="modal-background"></div> <div class="modal-background"></div>
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
<p class="modal-card-title">Are you sure you want to uninstall {{ config.general.name }}?</p> <p class="modal-card-title">Are you sure you want to uninstall {{ attrs.name }}?</p>
</header> </header>
<footer class="modal-card-foot"> <footer class="modal-card-foot">
<button class="button is-danger" v-on:click="uninstall">Yes</button> <button class="button is-danger" v-on:click="uninstall">Yes</button>
@ -136,7 +145,7 @@
</div> </div>
</div> </div>
<script src="/api/config"></script> <script src="/api/attrs"></script>
<script src="/js/helpers.js"></script> <script src="/js/helpers.js"></script>
<script src="/js/vue.min.js"></script> <script src="/js/vue.min.js"></script>
<script> <script>
@ -168,7 +177,7 @@
intercept(methods[i]); intercept(methods[i]);
} }
document.getElementById("window-title").innerText = config.name + " Installer"; document.getElementById("window-title").innerText = base_attributes.name + " Installer";
function selectFileCallback(name) { function selectFileCallback(name) {
app.install_location = name; app.install_location = name;
@ -177,7 +186,8 @@
var app = new Vue({ var app = new Vue({
el: '#app', el: '#app',
data: { data: {
config : config, attrs: base_attributes,
config : {},
install_location : "", install_location : "",
// If the initial modify menu should be shown // If the initial modify menu should be shown
modify_install : false, modify_install : false,
@ -192,6 +202,8 @@
launcher_path : undefined, launcher_path : undefined,
// If a confirmation prompt should be shown // If a confirmation prompt should be shown
confirm_uninstall : false, confirm_uninstall : false,
// If the downloading config page should be shown
is_downloading_config : false,
progress : 0, progress : 0,
progress_message : "", progress_message : "",
has_error : false, has_error : false,
@ -205,6 +217,59 @@
} }
}, },
methods: { methods: {
"download_config": function() {
app.is_downloading_config = true;
ajax("/api/config", function(e) {
app.download_install_status();
app.config = e;
});
},
"download_install_status": function() {
ajax("/api/installation-status", function(e) {
app.is_downloading_config = false;
app.metadata = e;
if (e.preexisting_install) {
app.modify_install = true;
app.select_packages = false;
app.show_install_location = false;
app.install_location = e.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.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[i].name) {
app.config.packages[x].default = true;
app.config.packages[x].installed = true;
}
}
}
if (e.is_launcher) {
document.getElementById("window-title").innerText = config.name + " Updater";
app.is_launcher = true;
app.install();
}
} else {
for (var x = 0; x < app.config.packages.length; x++) {
app.config.packages[x].installed = false;
}
ajax("/api/default-path", function(e) {
if (e.path != null) {
app.install_location = e.path;
}
});
}
});
},
"select_file": function() { "select_file": function() {
window.external.invoke(JSON.stringify({ window.external.invoke(JSON.stringify({
SelectInstallDir: { SelectInstallDir: {
@ -297,48 +362,7 @@
} }
}); });
ajax("/api/installation-status", function(e) { app.download_config();
app.metadata = e;
if (e.preexisting_install) {
app.modify_install = true;
app.select_packages = false;
app.show_install_location = false;
app.install_location = e.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.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[i].name) {
app.config.packages[x].default = true;
app.config.packages[x].installed = true;
}
}
}
if (e.is_launcher) {
document.getElementById("window-title").innerText = config.name + " Updater";
app.is_launcher = true;
app.install();
}
} else {
for (var x = 0; x < app.config.packages.length; x++) {
app.config.packages[x].installed = false;
}
ajax("/api/default-path", function(e) {
if (e.path != null) {
app.install_location = e.path;
}
});
}
});
</script> </script>
</body> </body>
</html> </html>