diff --git a/modules/core/app-updater/index.ts b/modules/core/app-updater/index.ts
index bfa4b89..f0d8e6f 100644
--- a/modules/core/app-updater/index.ts
+++ b/modules/core/app-updater/index.ts
@@ -23,6 +23,7 @@ import * as _main_windows from "../main_window";
import ErrnoException = NodeJS.ErrnoException;
import {EPERM} from "constants";
import * as winmgr from "../window";
+import {reference_app} from "../main_window";
const is_debug = false;
export function server_url() : string {
@@ -814,7 +815,7 @@ export async function execute_graphical(channel: string, ask_install: boolean) :
try {
await execute_update(update_path, callback => {
- _main_windows.set_prevent_instant_close(true);
+ reference_app(); /* we'll never delete this reference, but we'll call app.quit() manually */
update_restart_pending = true;
window.close();
callback();
diff --git a/modules/core/main_window.ts b/modules/core/main_window.ts
index 0a5c291..1c2eda1 100644
--- a/modules/core/main_window.ts
+++ b/modules/core/main_window.ts
@@ -3,9 +3,14 @@ import * as electron from "electron";
import * as winmgr from "./window";
import * as path from "path";
-export let prevent_instant_close: boolean = true;
-export function set_prevent_instant_close(flag: boolean) {
- prevent_instant_close = flag;
+let app_references = 0;
+export function reference_app() {
+ app_references++;
+}
+
+export function unreference_app() {
+ app_references--;
+ test_app_should_exit();
}
export let is_debug: boolean;
@@ -23,6 +28,7 @@ export let main_window: BrowserWindow = null;
function spawn_main_window(entry_point: string) {
// Create the browser window.
console.log("Spawning main window");
+ reference_app(); /* main browser window references the app */
main_window = new BrowserWindow({
width: 800,
height: 600,
@@ -47,7 +53,8 @@ function spawn_main_window(entry_point: string) {
app.releaseSingleInstanceLock();
require("./url-preview").close();
main_window = null;
- prevent_instant_close = false;
+
+ unreference_app();
});
main_window.loadFile(loader.ui.preloading_page(entry_point));
@@ -61,7 +68,6 @@ function spawn_main_window(entry_point: string) {
loader.ui.cleanup();
if(allow_dev_tools && !main_window.webContents.isDevToolsOpened())
main_window.webContents.openDevTools();
- prevent_instant_close = false; /* just to ensure that the client could be unloaded */
});
});
@@ -93,54 +99,43 @@ function spawn_main_window(entry_point: string) {
main_window.webContents.on('crashed', event => {
console.error("UI thread crashed! Closing app!");
- if(!process_args.has_flag(Arguments.DEBUG)) {
+ if(!process_args.has_flag(Arguments.DEBUG))
main_window.close();
- prevent_instant_close = false;
- }
});
}
-function handle_error(message: string) {
+function handle_uo_load_error(message: string) {
console.log("Caught loading error: %s", message);
//"A critical error happened while loading TeaClient!", "A critical error happened while loading TeaClient!
" + message
+ reference_app();
dialog.showMessageBox({
type: "error",
buttons: ["exit"],
title: "A critical error happened while loading TeaClient!",
message: message
- });
+ }).then(unreference_app);
loader.ui.cancel();
}
+function test_app_should_exit() {
+ if(app_references > 0) return;
+
+ console.log("All windows have been closed, closing app.");
+ app.quit();
+}
+
function init_listener() {
app.on('quit', () => {
- console.debug("Finalizing crash handler");
+ console.debug("Shutting down app.");
crash_handler.finalize_handler();
- console.log("RUNNING quit!");
- loader.cleanup();
- console.log("RUNNING quit 2!");
loader.ui.cleanup();
- console.log("RUNNING quit done!");
+ console.log("App has been finalized.");
});
+
app.on('window-all-closed', () => {
- console.log("RUNNING all win closed!");
- // On macOS it is common for applications and their menu bar
- // to stay active until the user quits explicitly with Cmd + Q
- if (process.platform !== 'darwin') {
- if(!prevent_instant_close) {
- console.log("All windows have been closed, closing app.");
- app.quit();
- } else {
- console.log("All windows have been closed, but we dont want to quit instantly. Waiting 10 seconds if something happens");
- setTimeout(() => {
- if(BrowserWindow.getAllWindows().length == 0) {
- console.log("All windows have been closed for over an minute. Exiting app!");
- app.quit();
- }
- }, 10 * 1000);
- }
- }
+ console.log("All windows have been closed. App reference count: %d", app_references);
+ test_app_should_exit();
});
app.on('activate', () => {
@@ -152,7 +147,6 @@ function init_listener() {
}
});
-
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
console.log("Allowing untrusted certificate for %o", url);
event.preventDefault();
@@ -195,6 +189,7 @@ export function execute() {
return entry_point;
}).then((entry_point: string) => {
+ reference_app(); /* because we've no windows when we close the loader UI */
loader.ui.cleanup(); /* close the window */
if(entry_point) //has not been canceled
@@ -202,5 +197,6 @@ export function execute() {
else {
console.warn("Missing entry point!");
}
- }).catch(handle_error);
+ unreference_app();
+ }).catch(handle_uo_load_error);
}
diff --git a/modules/core/ui-loader/graphical.ts b/modules/core/ui-loader/graphical.ts
index fb2e66d..24717ef 100644
--- a/modules/core/ui-loader/graphical.ts
+++ b/modules/core/ui-loader/graphical.ts
@@ -46,7 +46,7 @@ export namespace ui {
try {
const entry_point = await loader.load_files(channel, (status, index) => {
if(gui) {
- gui.webContents.send('progress-update', index);
+ gui.webContents.send('progress-update', status, index);
}
});
@@ -79,7 +79,7 @@ export namespace ui {
gui.focus();
return;
}
- console.log("Spawn window!");
+ console.log("Open UI loader window.");
let dev_tools = false;
const WINDOW_WIDTH = 340 + (dev_tools ? 1000 : 0);
@@ -88,7 +88,6 @@ export namespace ui {
let bounds = screen.getPrimaryDisplay().bounds;
let x = bounds.x + (bounds.width - WINDOW_WIDTH) / 2;
let y = bounds.y + (bounds.height - WINDOW_HEIGHT) / 2;
- console.log("Bounds: %o; Move loader window to %ox%o", bounds, x, y);
gui = new electron.BrowserWindow({
width: WINDOW_WIDTH,
diff --git a/modules/core/ui-loader/loader.ts b/modules/core/ui-loader/loader.ts
index 3000b84..3aa1dc5 100644
--- a/modules/core/ui-loader/loader.ts
+++ b/modules/core/ui-loader/loader.ts
@@ -1,9 +1,9 @@
import {is_debug} from "../main_window";
-
-const request = require('request');
-const querystring = require('querystring');
-const fs = require('fs-extra');
-const os = require('os');
+import * as moment from "moment";
+import * as request from "request";
+import * as querystring from "querystring";
+import * as fs from "fs-extra";
+import * as os from "os";
const UUID = require('pure-uuid');
import * as path from "path";
import * as zlib from "zlib";
@@ -14,9 +14,10 @@ import {parse_version} from "../../shared/version";
import * as electron from "electron";
import MessageBoxOptions = Electron.MessageBoxOptions;
import {current_version, execute_graphical} from "../app-updater";
+import * as local_ui_cache from "./local_ui_cache";
+import {WriteStream} from "fs";
const TIMEOUT = 30000;
-let local_path = undefined;
interface RemoteURL {
(): string;
@@ -29,85 +30,89 @@ const remote_url: RemoteURL = () => {
return remote_url.cached = (process_args.has_value(...Arguments.SERVER_URL) ? process_args.value(...Arguments.SERVER_URL) : default_path);
};
-function data_directory() : string {
- return electron.app.getPath('userData');
-}
-
-function cache_directory() : string {
- return path.join(data_directory(), "cache", "ui");
-}
-
-function working_directory() : string {
- return path.join(data_directory(), "tmp", "ui");
-}
-
export interface VersionedFile {
name: string,
hash: string,
path: string,
type: string,
- local_url: () => Promise
+ local_url: () => Promise
}
-function generate_tmp() : Promise {
- if(local_path) return Promise.resolve(local_path);
+function generate_tmp() : Promise {
+ if(generate_tmp.promise) return generate_tmp.promise;
- const id = new UUID(4).format();
- const directory = path.join(os.tmpdir(), "TeaClient-" + id) + "/";
+ return (generate_tmp.promise = fs.mkdtemp(path.join(os.tmpdir(), "TeaClient-")).then(path => {
+ process.on('exit', event => {
+ try {
+ if(fs.pathExistsSync(path))
+ fs.removeSync(path);
+ } catch (e) {
+ console.warn("Failed to delete temp directory: %o", e);
+ }
+ });
+
+ global["browser-root"] = path;
+ console.log("Local browser path: %s", path);
+ return Promise.resolve(path);
+ }));
+}
- return fs.mkdirs(directory).then(() => {
- local_path = directory;
- global["browser-root"] = local_path;
- console.log("Local browser path: %s", local_path);
- return Promise.resolve(local_path);
- });
+namespace generate_tmp {
+ export let promise: Promise;
}
function get_raw_app_files() : Promise {
- return generate_tmp().then(path => new Promise((resolve, reject) => {
- const url = remote_url() + "api.php?" + querystring.stringify({
- type: "files",
+ return new Promise((resolve, reject) => {
+ const url = remote_url() + "api.php?" + querystring.stringify({
+ type: "files",
+ });
+ console.debug("Requesting file list from %s", url);
+ request.get(url, {
+ timeout: TIMEOUT
+ }, (error, response, body: string) => {
+ if(error) {
+ reject(error);
+ return;
+ }
+
+ if(!response) {
+ reject("missing response object");
+ return;
+ }
+
+ if(response.statusCode != 200) { setImmediate(reject, "invalid status code " + response.statusCode + " for " + url); return; }
+ if(parseInt(response.headers["info-version"] as string) != 1 && !process_args.has_flag(Arguments.UPDATER_UI_IGNORE_VERSION)) { setImmediate(reject, "Invalid response version (" + response.headers["info-version"] + "). Update your app manually!"); return; }
+ if(!body) {
+ setImmediate(reject, "invalid body. (Missing)");
+ return;
+ }
+ let result: VersionedFile[] = [];
+
+ body.split("\n").forEach(entry => {
+ if(entry.length == 0) return;
+
+ let info = entry.split("\t");
+ if(info[0] == "type") return;
+
+ result.push({
+ type: info[0],
+ hash: info[1],
+ path: info[2],
+ name: info[3]
+ } as VersionedFile);
});
- console.debug("Requesting file list from %s", url);
- request.get(url, {
- timeout: TIMEOUT
- }, (error, response, body: string) => {
- response = response || {statusCode: -1};
-
- if(error) { reject(error); return; }
- if(response.statusCode != 200) { setImmediate(reject, "invalid status code " + response.statusCode + " for " + url); return; }
- if(response.headers["info-version"] != 1 && !process_args.has_flag(Arguments.UPDATER_UI_IGNORE_VERSION)) { setImmediate(reject, "Invalid response version (" + response.headers["info-version"] + "). Update your app manually!"); return; }
- if(!body) {
- setImmediate(reject, "invalid body. (Missing)");
- return;
- }
- let result: VersionedFile[] = [];
-
- body.split("\n").forEach(entry => {
- if(entry.length == 0) return;
-
- let info = entry.split("\t");
- if(info[0] == "type") return;
-
- result.push({
- type: info[0],
- hash: info[1],
- path: info[2],
- name: info[3]
- } as VersionedFile);
- });
- setImmediate(resolve, result);
- });
- })
- );
+ setImmediate(resolve, result);
+ });
+ });
}
-function download_raw_app_files() : Promise {
+async function download_raw_app_files() : Promise {
+ const local_temp_path = await generate_tmp();
return get_raw_app_files().then(response => {
for(let file of response) {
- const full_path = path.join(local_path, file.path, file.name);
- file.local_url = () => fs.mkdirs(path.dirname(full_path)).then(() => new Promise((resolve, reject) => {
+ const full_path = path.join(local_temp_path, file.path, file.name);
+ file.local_url = () => fs.mkdirs(path.dirname(full_path)).then(() => new Promise((resolve, reject) => {
const write_stream = fs.createWriteStream(full_path);
request.get(remote_url() + "api.php?" + querystring.stringify({
type: "file",
@@ -122,11 +127,16 @@ function download_raw_app_files() : Promise {
}
}).on('complete', event => {
}).on('error', error => {
+ try { write_stream.close(); } catch (e) { }
setImmediate(reject, error);
}).pipe(write_stream)
- .on('finish', event => {
- setImmediate(resolve, file.path + "/" + file.name);
- });
+ .on('finish', event => {
+ try { write_stream.close(); } catch (e) { }
+ setImmediate(resolve, file.path + "/" + file.name);
+ }).on('error', error => {
+ try { write_stream.close(); } catch (e) { }
+ setImmediate(reject, error);
+ });
}));
}
return Promise.resolve(response);
@@ -136,77 +146,13 @@ function download_raw_app_files() : Promise {
})
}
-interface LocalUICache {
- fetch_history?: FetchStatus;
- versions?: LocalUICacheEntry[];
-
- remote_index?: UIVersion[] | UIVersion;
- remote_index_channel?: string; /* only set if the last status was a channel only*/
-
- local_index?: UIVersion;
-}
-
-interface FetchStatus {
- timestamp: number;
- /**
- * 0 = success
- * 1 = connect fail
- * 2 = internal fail
- */
- status: number;
-}
-
-interface LocalUICacheEntry {
- version: UIVersion;
- download_timestamp: number;
- tar_file: string;
- checksum: string; /* SHA512 */
-}
-
-export interface UIVersion {
- channel: string;
- version: string;
- git_hash: string;
- timestamp: number;
-
- required_client?: string;
- filename?: string;
-
- client_shipped?: boolean;
-}
-
-function ui_file_path(version: UIVersion) : string {
- if(version.client_shipped) {
- const app_path = electron.app.getAppPath();
- if(!app_path.endsWith(".asar"))
- return undefined;
-
- return path.join(path.join(path.dirname(app_path), "ui"), version.filename);
- }
-
- const file_name = "ui_" + version.channel + "_" + version.version + "_" + version.git_hash + "_" + version.timestamp + ".tar.gz";
- return path.join(cache_directory(), file_name);
-}
-
-let _ui_load_cache: LocalUICache;
-async function ui_load_cache() : Promise {
- if(_ui_load_cache) return _ui_load_cache;
-
- const file = path.join(cache_directory(), "data.json");
- if(!fs.existsSync(file)) return {} as LocalUICache;
-
- console.log("Loading UI cache file %s", file);
- _ui_load_cache = await fs.readJson(file) as LocalUICache;
- return _ui_load_cache;
-}
-
-async function client_shipped_ui() : Promise {
+async function client_shipped_ui() : Promise {
const app_path = electron.app.getAppPath();
if(!app_path.endsWith(".asar"))
return undefined;
const base_path = path.join(path.dirname(app_path), "ui");
- console.debug("Looking for client shipped UI pack at %s", base_path);
+ //console.debug("Looking for client shipped UI pack at %s", base_path);
if(!(await fs.pathExists(base_path)))
return undefined;
@@ -220,138 +166,173 @@ async function client_shipped_ui() : Promise {
} = await fs.readJson(path.join(base_path, "default_ui_info.json")) as any;
return {
- channel: info.channel,
- client_shipped: true,
+ download_timestamp: info.timestamp,
+ status: "valid",
+ invalid_reason: undefined,
+ local_checksum: "none",
+ local_file_path: path.join(path.join(path.dirname(app_path), "ui"), info.filename),
+ pack_info: {
+ channel: info.channel,
+ min_client_version: "0.0.0", //TODO: Just take the current client version
+ timestamp: info.timestamp,
+ version: info.version,
+ versions_hash: info.git_hash
+ }
+ };
+}
- filename: info.filename,
- git_hash: info.git_hash,
- required_client: info.required_client,
- timestamp: info.timestamp,
- version: info.version,
+async function query_ui_pack_versions() : Promise {
+ const url = remote_url() + "api.php?" + querystring.stringify({
+ type: "ui-info"
+ });
+ console.debug("Loading UI pack information (URL: %s)", url);
+
+ let body = await new Promise((resolve, reject) => request.get(url, { timeout: TIMEOUT }, (error, response, body: string) => {
+ if(error)
+ reject(error);
+ else if(!response)
+ reject("missing response object");
+ else {
+ if(response.statusCode !== 200)
+ reject(response.statusCode + " " + response.statusMessage);
+ else if(!body)
+ reject("missing body in response");
+ else
+ resolve(body);
+ }
+ }));
+
+ let response;
+ try {
+ response = JSON.parse(body);
+ } catch (error) {
+ console.error("Received unparsable response for UI pack info. Response: %s", body);
+ throw "failed to parse response";
+ }
+
+ if(!response["success"])
+ throw "request failed: " + (response["msg"] || "unknown error");
+
+ if(!Array.isArray(response["versions"])) {
+ console.error("Response object misses 'versions' tag or has an invalid value. Object: %o", response);
+ throw "response contains invalid data";
+ }
+
+ let ui_versions: local_ui_cache.UIPackInfo[] = [];
+ for(const entry of response["versions"]) {
+ ui_versions.push({
+ channel: entry["channel"],
+ versions_hash: entry["git-ref"],
+ version: entry["version"],
+ timestamp: entry["timestamp"],
+ min_client_version: entry["required_client"]
+ });
+ }
+
+ return ui_versions;
+}
+
+async function download_ui_pack(version: local_ui_cache.UIPackInfo) : Promise {
+ const target_file = path.join(local_ui_cache.cache_path(), version.channel + "_" + version.versions_hash + "_" + version.timestamp + ".tar.gz");
+ if(await fs.pathExists(target_file)) {
+ try {
+ await fs.remove(target_file);
+ } catch (error) {
+ console.error("Tried to download UI version %s, but we failed to delete the old file: %o", version.versions_hash, error);
+ throw "failed to delete old file";
+ }
+ }
+ try {
+ await fs.mkdirp(path.dirname(target_file));
+ } catch (error) {
+ console.error("Failed to create target UI pack download directory at %s: %o", path.dirname(target_file), error);
+ throw "failed to create target directories";
+ }
+
+ await new Promise((resolve, reject) => {
+ let fstream: WriteStream;
+ try {
+ request.get(remote_url() + "api.php?" + querystring.stringify({
+ "type": "ui-download",
+ "git-ref": version.versions_hash,
+ "version": version.version,
+ "timestamp": version.timestamp,
+ "channel": version.channel
+ }), {
+ timeout: TIMEOUT
+ }).on('response', function(response: request.Response) {
+ if(response.statusCode != 200)
+ reject(response.statusCode + " " + response.statusMessage);
+ }).on('error', error => {
+ reject(error);
+ }).pipe(fstream = fs.createWriteStream(target_file)).on('finish', () => {
+ try { fstream.close(); } catch (e) { }
+
+ resolve();
+ });
+ } catch (error) {
+ try { fstream.close(); } catch (e) { }
+
+ reject(error);
+ }
+ });
+
+ try {
+ const cache = await local_ui_cache.load();
+ const info: local_ui_cache.CachedUIPack = {
+ pack_info: version,
+ local_file_path: target_file,
+ local_checksum: "none", //TODO!
+ invalid_reason: undefined,
+ status: "valid",
+ download_timestamp: Date.now()
+ };
+ cache.cached_ui_packs.push(info);
+ await local_ui_cache.save();
+ return info;
+ } catch (error) {
+ console.error("Failed to register downloaded UI pack to the UI cache: %o", error);
+ throw "failed to register downloaded UI pack to the UI cache";
}
}
-async function ui_save_cache(cache: LocalUICache) {
- const file = path.join(cache_directory(), "data.json");
- if(!fs.existsSync(path.dirname(file)))
- await fs.mkdirs(path.dirname(file));
- await fs.writeJson(file, cache);
+async function ui_pack_usable(version: local_ui_cache.CachedUIPack) : Promise {
+ if(version.status !== "valid") return false;
+ return await fs.pathExists(version.local_file_path);
}
-async function get_ui_pack(channel?: string) : Promise {
- return await new Promise((resolve, reject) => {
- const url = remote_url() + "api.php?" + querystring.stringify({
- type: "ui-info"
- });
- request.get(url, {
- timeout: TIMEOUT
- }, (error, response, body: string) => {
- try {
- response = response || {statusCode: -1};
+async function unpack_local_ui_pack(version: local_ui_cache.CachedUIPack) : Promise {
+ if(!await ui_pack_usable(version))
+ throw "UI pack has been invalidated";
- if(error) { throw error; }
- if(response.statusCode != 200) { throw "invalid status code " + response.statusCode + " for " + url; }
- if(!body) throw "invalid response body";
-
- let result: UIVersion[] = [];
-
- const json = JSON.parse(body) || {success: false, msg: "invalid body"};
- if(!json["success"]) throw "Failed to get ui info: " + json["msg"];
-
- for(const entry of json["versions"]) {
- if(!channel || entry["channel"] == channel)
- result.push({
- channel: entry["channel"],
- version: entry["version"],
- git_hash: entry["git-ref"],
- timestamp: entry["timestamp"],
- required_client: entry["required_client"]
- });
- }
-
- if(result.length == 0 && channel) result.push(undefined);
- const res = channel ? result[0] : result;
- ui_load_cache().then(async cache => {
- cache.fetch_history = cache.fetch_history || {} as any;
- cache.fetch_history.timestamp = Date.now();
- cache.fetch_history.status = 0;
- cache.remote_index = res as any;
- cache.remote_index_channel = channel;
- await ui_save_cache(cache);
- }).catch(error => {
- console.warn("Failed to save UI cache info: %o", error);
- resolve(res);
- }).then(err => resolve(res));
- } catch(error) {
- reject(error);
- }
- });
- })
-}
-
-async function download_ui_pack(version: UIVersion) : Promise {
- const directory = cache_directory();
- const file = ui_file_path(version);
- await fs.mkdirs(directory);
-
- await new Promise((resolve, reject) => {
- request.get(remote_url() + "api.php?" + querystring.stringify({
- type: "ui-download",
- "git-ref": version.git_hash,
- version: version.version,
- timestamp: version.timestamp,
- channel: version.channel
- }), {
- timeout: TIMEOUT
- }).on('response', function(response) {
- if(response.statusCode != 200) { reject("Failed to download UI files (Status code " + response.statusCode + ")"); }
- }).on('error', error => {
- reject("Failed to download UI files: " + error);
- }).pipe(fs.createWriteStream(file)).on('finish', () => {
- ui_load_cache().then(cache => {
- cache.versions.push({
- checksum: "undefined",
- tar_file: file,
- download_timestamp: Date.now(),
- version: version
- });
- return ui_save_cache(cache);
- }).catch(error => resolve()).then(() => resolve());
-
- });
- });
-}
-
-function ui_pack_exists(version: UIVersion) : boolean {
- return fs.existsSync(ui_file_path(version));
-}
-
-async function unpack_cached(version: UIVersion) : Promise {
- const file = ui_file_path(version);
- if(!fs.existsSync(file)) throw "missing file";
-
- const target_dir = path.join(working_directory(), version.channel + "_" + version.timestamp);
- if(fs.existsSync(target_dir)) fs.removeSync(target_dir);
-
- await fs.mkdirs(target_dir);
+ const target_directory = await generate_tmp();
+ if(!await fs.pathExists(target_directory))
+ throw "failed to create temporary directory";
const gunzip = zlib.createGunzip();
const extract = tar.extract();
- const fpipe = fs.createReadStream(file);
+ let fpipe: fs.ReadStream;
+
+ try {
+ fpipe = fs.createReadStream(version.local_file_path);
+ } catch (error) {
+ console.error("Failed to open UI pack at %s: %o", version.local_file_path, error);
+ throw "failed to open UI pack";
+ }
extract.on('entry', function(header: tar.Headers, stream, next) {
if(header.type == 'file') {
- const target_file = path.join(target_dir, header.name);
+ const target_file = path.join(target_directory, header.name);
if(!fs.existsSync(path.dirname(target_file))) fs.mkdirsSync(path.dirname(target_file));
stream.on('end', () => setImmediate(next));
const wfpipe = fs.createWriteStream(target_file);
stream.pipe(wfpipe);
} else if(header.type == 'directory') {
- if(fs.existsSync(path.join(target_dir, header.name)))
+ if(fs.existsSync(path.join(target_directory, header.name)))
setImmediate(next);
- fs.mkdirs(path.join(target_dir, header.name)).catch(error => {
- console.warn("Failed to create unpacking fir " + path.join(target_dir, header.name));
+ fs.mkdirs(path.join(target_directory, header.name)).catch(error => {
+ console.warn("Failed to create unpacking dir " + path.join(target_directory, header.name));
console.error(error);
}).then(() => setImmediate(next));
} else {
@@ -360,162 +341,254 @@ async function unpack_cached(version: UIVersion) : Promise {
}
});
- const finish_promise = new Promise(resolve => {
+ const finish_promise = new Promise((resolve, reject) => {
extract.on('finish', resolve);
extract.on('error', event => {
if(!event) return;
- throw event;
+ reject(event);
});
});
fpipe.pipe(gunzip).pipe(extract);
- await finish_promise;
-
- return target_dir;
-}
-
-export async function cleanup() {
- if(await fs.pathExists(local_path))
- await fs.remove(local_path);
-}
-
-export async function load_files(channel: string, static_cb: (message: string, index: number) => any) : Promise {
- const type = parseInt(process_args.has_value(Arguments.UPDATER_UI_LOAD_TYPE) ? process_args.value(Arguments.UPDATER_UI_LOAD_TYPE) : "-1");
- if(type == 0 || !is_debug) {
- console.log("Loading ui package");
-
- static_cb("Fetching info", 0);
- const cache = await ui_load_cache();
- console.log("Local cache: %o", cache);
-
- let ui_info: UIVersion;
- try {
- ui_info = await get_ui_pack(channel) as UIVersion;
- } catch(error) {
- if(error instanceof Error)
- console.error("Failed to fetch ui info: %s. Using cached info!", error.message);
- else
- console.error("Failed to fetch ui info: %o. Using cached info!", error);
- }
- if(!ui_info) {
- if(cache && !process_args.has_flag(Arguments.UPDATER_UI_NO_CACHE)) {
- if(Array.isArray(cache.remote_index)) {
- for(const index of cache.remote_index) {
- if(index && index.channel == "release") {
- ui_info = index;
- break;
- }
- }
- } else {
- //TODO: test channel?
- ui_info = cache.remote_index;
- }
- }
- if(ui_info) {
- console.debug("Found local UI pack.");
- } else {
- //Test for the client shipped ui pack
- try {
- console.info("Looking for client shipped UI pack.");
- ui_info = await client_shipped_ui();
- if(!ui_info)
- throw "failed to load info";
- console.info("Using client shipped UI pack because we've no active internet connection.")
- } catch(error) {
- console.warn("Failed to load client shipped UI pack: %o", error);
- throw "Failed to load UI pack from cache!\nPlease ensure a valid internet connection.";
- }
- }
- }
-
- static_cb("Searching cache for file", .33);
- console.log("Loading UI from data: %o. Target path: %s", ui_info, ui_file_path(ui_info));
- if(ui_info.required_client && !process_args.has_flag(Arguments.DEBUG)) {
- const ui_vers = parse_version(ui_info.required_client);
- const current_vers = await current_version();
- console.log("Checking required client version (Required: %s, Version: %s)", ui_vers.toString(true), current_vers.toString(true));
- if(ui_vers.newer_than(current_vers) && !current_vers.in_dev()) {
- const local_available = cache && cache.local_index ? ui_pack_exists(cache.local_index) : undefined;
-
- const result = await electron.dialog.showMessageBox({
- type: "question",
- message:
- "Local client is outdated.\n" +
- "Newer UI packs (>= " + ui_info.version + ") require client " + ui_info.required_client + "\n" +
- "Do you want to upgrade?",
- title: "Client outdated!",
- buttons: ["yes", local_available ? "ignore and use last possible (" + cache.local_index.version + ")" : "close client"]
- } as MessageBoxOptions);
- if(result.response == 0) {
- await execute_graphical(channel, true);
- throw "client outdated";
- } else {
- if(!local_available) {
- electron.app.exit(1);
- return;
- }
-
- ui_info = cache.local_index;
- }
- }
- }
-
- if(!ui_pack_exists(ui_info)) {
- console.log("Ui version does not locally exists. Downloading new one");
- static_cb("Downloading files", .34);
- await download_ui_pack(ui_info);
- console.log("Download completed!");
- }
-
- console.log("Unpacking cached ui info");
- static_cb("Unpacking files", .66);
- const target_path = await unpack_cached(ui_info);
- cache.local_index = ui_info;
- await ui_save_cache(cache);
-
- console.log("Unpacked. Target path: %s", target_path);
- static_cb("UI loaded", 1);
-
- return path.join(target_path, "index.html");
- } else {
- console.log("Loading file by file");
-
- static_cb("Fetching files", 0);
- let files;
- try {
- files = await download_raw_app_files()
- } catch (error) {
- throw "Failed to get file list: " + error;
- }
- console.log("Get raw files:");
- let futures: Promise[] = [];
- let finish_count = files.length;
- static_cb("Downloading files", 0);
-
- const chunk_size = 5;
- let left = [...files];
- while(left.length > 0) {
- const queue = left.slice(0, chunk_size);
- left = left.slice(chunk_size);
-
- futures = [];
- for(const file of queue) {
- console.log("Start downloading %s (%s)", file.name, file.path);
-
- const start = Date.now();
- futures.push(file.local_url().then(data => {
- console.log("Downloaded %s (%s) (%ims)", file.name, file.path, Date.now() - start);
- static_cb("Downloading files", finish_count / files.length);
- }));
- }
-
- try {
- await Promise.all(futures);
- } catch (error) {
- throw "Failed to download files: " + error;
- }
- }
-
- return await generate_tmp() + "index.html"; /* entry point */
+ try {
+ await finish_promise;
+ } catch(error) {
+ console.error("Failed to extract UI files to %s: %o", target_directory, error);
+ throw "failed to unpack the UI pack";
}
+
+ return target_directory;
+}
+
+async function load_files_from_dev_server(channel: string, stats_update: (message: string, index: number) => any) : Promise {
+ stats_update("Fetching files", 0);
+ let files: VersionedFile[];
+ try {
+ files = await download_raw_app_files()
+ } catch (error) {
+ console.log("Failed to fetch raw UI file list: %o", error);
+ let msg;
+ if(error instanceof Error)
+ msg = error.message;
+ else if(typeof error === "string")
+ msg = error;
+ throw "failed to get file list" + (msg ? " (" + msg + ")" : "");
+ }
+
+
+ const max_simultaneously_downloads = 8;
+ let pending_files: VersionedFile[] = files.slice(0);
+ let current_downloads: {[key: string]:Promise} = {};
+
+ const update_download_status = () => {
+ const indicator = (pending_files.length + Object.keys(current_downloads).length) / files.length;
+ stats_update("Downloading raw UI files", 1 - indicator);
+ };
+ update_download_status();
+
+ let errors: { file: VersionedFile; error: any }[] = [];
+ while(pending_files.length > 0) {
+ while(pending_files.length > 0 && Object.keys(current_downloads).length < max_simultaneously_downloads) {
+ const file = pending_files.pop();
+ current_downloads[file.hash] = file.local_url().catch(error => {
+ errors.push({ file: file, error: error});
+ }).then(() => {
+ delete current_downloads[file.hash];
+ });
+ }
+
+ update_download_status();
+ await Promise.race(Object.keys(current_downloads).map(e => current_downloads[e]));
+
+ if(errors.length > 0)
+ break;
+ }
+
+ /* await full finish */
+ while(Object.keys(current_downloads).length > 0) {
+ update_download_status();
+ await Promise.race(Object.keys(current_downloads).map(e => current_downloads[e]));
+ }
+
+ if(errors.length > 0) {
+ console.log("Failed to load UI files (%d):", errors.length);
+ for(const error of errors)
+ console.error(" - %s: %o", path.join(error.file.path + error.file.name), error.error);
+ throw "failed to download file " + path.join(errors[0].file.path + errors[0].file.name) + " (" + errors[0].error + ")\nView console for a full error report.";
+ }
+
+ console.log("Successfully loaded UI files from remote server.");
+ /* generate_tmp has already been called an its the file destination */
+ return path.join(await generate_tmp(), "index.html"); /* entry point */
+}
+async function load_bundles_ui_pack(channel: string, stats_update: (message: string, index: number) => any) : Promise {
+ stats_update("Query local UI pack info", .33);
+ const bundles_ui = await client_shipped_ui();
+ if(!bundles_ui) throw "client has no bundled UI pack";
+
+ stats_update("Unpacking bundled UI", .66);
+ const result = await unpack_local_ui_pack(bundles_ui);
+ stats_update("Local UI pack loaded", 1);
+ console.log("Loaded bundles UI pack successfully. Version: {timestamp: %d, hash: %s}", bundles_ui.pack_info.timestamp, bundles_ui.pack_info.versions_hash);
+ return path.join(result, "index.html");
+}
+
+async function load_cached_or_remote_ui_pack(channel: string, stats_update: (message: string, index: number) => any, ignore_new_version_timestamp: boolean) : Promise {
+ stats_update("Fetching info", 0);
+ const ui_cache = await local_ui_cache.load();
+ const bundles_ui = await client_shipped_ui();
+ const client_version = await current_version();
+
+ let available_versions: local_ui_cache.CachedUIPack[] = ui_cache.cached_ui_packs.filter(e => {
+ if(e.status !== "valid")
+ return false;
+
+ if(bundles_ui) {
+ if(e.pack_info.timestamp <= bundles_ui.download_timestamp)
+ return false;
+ }
+
+ const required_version = parse_version(e.pack_info.min_client_version);
+ return client_version.newer_than(required_version) || client_version.equals(required_version);
+ });
+ if(process_args.has_flag(Arguments.UPDATER_UI_NO_CACHE))
+ available_versions = [];
+
+ let remote_version_dropped = false;
+ /* remote version gathering */
+ {
+ stats_update("Loading remote info", .25);
+ let remote_versions: local_ui_cache.UIPackInfo[];
+ try {
+ remote_versions = await query_ui_pack_versions();
+ } catch (error) {
+ if(available_versions.length === 0)
+ throw "failed to query remote UI packs: " + error;
+ console.error("Failed to query remote UI packs: %o", error);
+ }
+
+ stats_update("Parsing UI packs", .40);
+ const remote_version = remote_versions.find(e => e.channel === channel);
+ if(!remote_version && available_versions.length === 0)
+ throw "no UI pack available for channel " + channel;
+
+ let newest_local_version = available_versions.map(e => e.download_timestamp).reduce((a, b) => Math.max(a, b), bundles_ui ? bundles_ui.download_timestamp : 0);
+ const required_version = parse_version(remote_version.min_client_version);
+ if(required_version.newer_than(client_version)) {
+ const result = await electron.dialog.showMessageBox({
+ type: "question",
+ message:
+ "Your client is outdated.\n" +
+ "Newer UI packs (>= " + remote_version.version + ", " + remote_version.versions_hash + ") require client " + remote_version.min_client_version + "\n" +
+ "Do you want to update your client?",
+ title: "Client outdated!",
+ buttons: ["yes", available_versions.length === 0 ? "close client" : "ignore and use last possible"]
+ } as MessageBoxOptions);
+
+ if(result.response == 0) {
+ await execute_graphical(channel, true);
+ throw "client outdated";
+ } else {
+ if(available_versions.length === 0) {
+ electron.app.exit(1);
+ return;
+ }
+ }
+ } else if(remote_version.timestamp <= newest_local_version && !ignore_new_version_timestamp) {
+ /* We've already a equal or newer version. Don't use the remote version */
+ remote_version_dropped = true;
+ } else {
+ /* update is possible because the timestamp is newer than out latest local version */
+ try {
+ stats_update("Download new UI pack", .55);
+ available_versions.push(await download_ui_pack(remote_version));
+ } catch (error) {
+ console.error("Failed to download new UI pack: %o", error);
+ }
+ }
+ }
+
+ stats_update("Unpacking UI", .70);
+ available_versions.sort((a, b) => a.pack_info.timestamp - b.pack_info.timestamp);
+
+ /* Only invalidate the version if any other succeeded to load. Else we might fucked up (no permission to write etc) */
+ let invalidate_versions: local_ui_cache.CachedUIPack[] = [];
+ while(available_versions.length > 0) {
+ const pack = available_versions.pop();
+ console.log("Trying to load UI pack from %s (%s). Downloaded at %s", moment(pack.pack_info.timestamp).format("llll"), moment(pack.pack_info.versions_hash).format("llll"), moment(pack.download_timestamp).format("llll"))
+
+ try {
+ const target = await unpack_local_ui_pack(pack);
+ stats_update("UI pack loaded", 1);
+
+ if(invalidate_versions.length > 0) {
+ for(const version of invalidate_versions) {
+ version.invalid_reason = "failed to unpack";
+ version.status = "invalid";
+ }
+ await local_ui_cache.save();
+ }
+
+ return path.join(target, "index.html");
+ } catch (error) {
+ invalidate_versions.push(pack);
+ console.log("Failed to unpack UI pack: %o", error);
+ }
+ }
+
+ if(remote_version_dropped) {
+ /* try again, but this time enforce a remote download */
+ await load_cached_or_remote_ui_pack(channel, stats_update, true);
+ }
+
+ throw "Failed to load any UI pack (local and remote)\nView the console for more details.\n";
+}
+
+enum UILoaderMethod {
+ PACK,
+ BUNDLED_PACK,
+ RAW_FILES
+}
+
+export async function load_files(channel: string, stats_update: (message: string, index: number) => any) : Promise {
+ let enforced_loading_method = parseInt(process_args.has_value(Arguments.UPDATER_UI_LOAD_TYPE) ? process_args.value(Arguments.UPDATER_UI_LOAD_TYPE) : "-1") as UILoaderMethod;
+
+ if(typeof UILoaderMethod[enforced_loading_method] !== "undefined") {
+ switch (enforced_loading_method) {
+ case UILoaderMethod.PACK:
+ return await load_cached_or_remote_ui_pack(channel, stats_update, false);
+
+ case UILoaderMethod.BUNDLED_PACK:
+ return await load_bundles_ui_pack(channel, stats_update);
+
+ case UILoaderMethod.RAW_FILES:
+ return await load_files_from_dev_server(channel, stats_update);
+ }
+ }
+
+ let first_error;
+ if(is_debug) {
+ try {
+ return await load_files_from_dev_server(channel, stats_update);
+ } catch(error) {
+ console.warn("Failed to load raw UI files: %o", error);
+ first_error = first_error || error;
+ }
+ }
+
+ try {
+ return await load_cached_or_remote_ui_pack(channel, stats_update, false);
+ } catch(error) {
+ console.warn("Failed to load cached/remote UI pack: %o", error);
+ first_error = first_error || error;
+ }
+
+ try {
+ return await load_bundles_ui_pack(channel, stats_update);
+ } catch(error) {
+ console.warn("Failed to load bundles UI pack: %o", error);
+ first_error = first_error || error;
+ }
+
+ throw first_error;
}
\ No newline at end of file
diff --git a/modules/core/ui-loader/local_ui_cache.ts b/modules/core/ui-loader/local_ui_cache.ts
new file mode 100644
index 0000000..e4f3fbc
--- /dev/null
+++ b/modules/core/ui-loader/local_ui_cache.ts
@@ -0,0 +1,136 @@
+import * as path from "path";
+import * as util from "util";
+import * as fs from "fs-extra";
+import * as electron from "electron";
+
+export namespace v1 {
+ /* main entry */
+ interface LocalUICache {
+ fetch_history?: FetchStatus;
+ versions?: LocalUICacheEntry[];
+
+ remote_index?: UIVersion[] | UIVersion;
+ remote_index_channel?: string; /* only set if the last status was a channel only*/
+
+ local_index?: UIVersion;
+ }
+
+ interface FetchStatus {
+ timestamp: number;
+ /**
+ * 0 = success
+ * 1 = connect fail
+ * 2 = internal fail
+ */
+ status: number;
+ }
+
+ interface LocalUICacheEntry {
+ version: UIVersion;
+ download_timestamp: number;
+ tar_file: string;
+ checksum: string; /* SHA512 */
+ }
+
+ export interface UIVersion {
+ channel: string;
+ version: string;
+ git_hash: string;
+ timestamp: number;
+
+ required_client?: string;
+ filename?: string;
+
+ client_shipped?: boolean;
+ }
+}
+
+export interface CacheFile {
+ version: number; /* currently 2 */
+
+ cached_ui_packs: CachedUIPack[];
+}
+
+export interface UIPackInfo {
+ timestamp: number; /* build timestamp */
+ version: string; /* not really used anymore */
+ versions_hash: string; /* used, identifies the version. Its the git hash. */
+
+ channel: string;
+ min_client_version: string; /* minimum version from the client required for the pack */
+}
+
+export interface CachedUIPack {
+ download_timestamp: number;
+ local_file_path: string;
+ local_checksum: string | "none"; /* sha512 of the locally downloaded file. */
+ //TODO: Get the remote checksum and compare them instead of the local one
+
+ pack_info: UIPackInfo;
+
+ status: "valid" | "invalid";
+ invalid_reason?: string;
+}
+
+let cached_loading_promise_: Promise;
+let ui_cache_: CacheFile = {
+ version: 2,
+ cached_ui_packs: []
+};
+async function load_() : Promise {
+ const file = path.join(cache_path(), "data.json");
+ if(!(await util.promisify(fs.exists)(file)))
+ return ui_cache_;
+
+ try {
+ const data = await fs.readJSON(file) as CacheFile;
+ if(!data)
+ throw "invalid data object";
+ else if(typeof data["version"] !== "number")
+ throw "invalid versions tag";
+ else if(data["version"] !== 2) {
+ console.warn("UI cache file contains an old version. Ignoring file and may override with newer version.");
+ return ui_cache_;
+ }
+
+ /* validating data */
+ if(!Array.isArray(data.cached_ui_packs))
+ throw "Invalid 'cached_ui_packs' entry within the UI cache file";
+
+ return (ui_cache_ = data as CacheFile);
+ } catch(error) {
+ console.warn("Failed to load UI cache file: %o. This will cause loss of the file content.", error);
+ return ui_cache_;
+ }
+}
+
+/**
+ * Will not throw or return undefined!
+ */
+export function load() : Promise {
+ if(cached_loading_promise_) return cached_loading_promise_;
+ return (cached_loading_promise_ = load_());
+}
+
+export function unload() {
+ ui_cache_ = undefined;
+ cached_loading_promise_ = undefined;
+}
+
+/**
+ * Will not throw anything
+ */
+export async function save() {
+ const file = path.join(cache_path(), "data.json");
+ try {
+ if(!(await util.promisify(fs.exists)(path.dirname(file))))
+ await fs.mkdirs(path.dirname(file));
+ await fs.writeJson(file, ui_cache_);
+ } catch (error) {
+ console.error("Failed to save UI cache file. This will may cause some data loss: %o", error);
+ }
+}
+
+export function cache_path() {
+ return path.join(electron.app.getPath('userData'), "cache", "ui");
+}
\ No newline at end of file
diff --git a/modules/core/ui-loader/ui/loader.ts b/modules/core/ui-loader/ui/loader.ts
index 796a9f0..ee9f0b0 100644
--- a/modules/core/ui-loader/ui/loader.ts
+++ b/modules/core/ui-loader/ui/loader.ts
@@ -5,9 +5,10 @@ interface Window {
}
(window as any).$ = require("jquery");
-icp.on('progress-update', (event, count) => {
- console.log("Process update to %f", count);
+icp.on('progress-update', (event, status, count) => {
+ console.log("Process update \"%s\" to %d", status, count);
+ $("#current-status").text(status);
$(".container-bar .bar").css("width", (count * 100) + "%");
});
diff --git a/modules/core/ui-loader/ui/loading_screen.html b/modules/core/ui-loader/ui/loading_screen.html
index 7ab97ca..f65b17b 100644
--- a/modules/core/ui-loader/ui/loading_screen.html
+++ b/modules/core/ui-loader/ui/loading_screen.html
@@ -78,6 +78,19 @@
width: 0%;
height: 100%;
}
+
+ #current-status {
+ margin-top: 3px;
+ font-size: 18px;
+
+ max-width: 100%;
+ width: 100%;
+ text-align: left;
+
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ }
@@ -93,6 +106,7 @@
+