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 @@
+   \ No newline at end of file diff --git a/modules/renderer/backend-impl/audio/player.ts b/modules/renderer/backend-impl/audio/player.ts index 131dfd5..bda238e 100644 --- a/modules/renderer/backend-impl/audio/player.ts +++ b/modules/renderer/backend-impl/audio/player.ts @@ -3,7 +3,6 @@ import * as handler from "../../audio/AudioPlayer"; export const initialize = handler.initialize; export const initialized = handler.initialized; -export const context = handler.context; export const get_master_volume = handler.get_master_volume; export const set_master_volume = handler.set_master_volume; diff --git a/package-lock.json b/package-lock.json index 579b64a..117f6f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4525,6 +4525,11 @@ "resolved": "https://registry.npmjs.org/modify-filename/-/modify-filename-1.1.0.tgz", "integrity": "sha1-mi3sg4Bvuy2XXyK+7IWcoms5OqE=" }, + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 5ba9953..c4540a8 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,8 @@ "sshpk": "^1.16.1", "tar-stream": "^2.1.0", "tough-cookie": "^3.0.1", - "v8-callsites": "latest" + "v8-callsites": "latest", + "moment": "latest" }, "config": { "platformDependentModules": {