import {is_debug} from "../main_window"; const request = require('request'); const querystring = require('querystring'); const fs = require('fs-extra'); const os = require('os'); const UUID = require('pure-uuid'); import * as path from "path"; import * as zlib from "zlib"; import * as tar from "tar-stream"; import {Arguments, process_args} from "../../shared/process-arguments"; import {parse_version} from "../../shared/version"; import * as electron from "electron"; import MessageBoxOptions = Electron.MessageBoxOptions; import {current_version, execute_graphical} from "../app-updater"; const TIMEOUT = 10000; let local_path = undefined; interface RemoteURL { (): string; cached?: string; } const remote_url: RemoteURL = () => { if(remote_url.cached) return remote_url.cached; const default_path = is_debug ? "http://localhost/home/TeaSpeak/Web-Client/client-api/environment/" : "https://clientapi.teaspeak.de/"; 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 } function generate_tmp() : Promise { if(local_path) return Promise.resolve(local_path); const id = new UUID(4).format(); const directory = path.join(os.tmpdir(), "TeaClient-" + id) + "/"; 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); }); } 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", }); 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) { 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); }); }) ); } function download_raw_app_files() : Promise { 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 write_stream = fs.createWriteStream(full_path); request.get(remote_url() + "api.php?" + querystring.stringify({ type: "file", path: file.path, name: file.name }), { timeout: TIMEOUT }).on('response', function(response) { if(response.statusCode != 200) { setImmediate(reject, "invalid status code " + response.statusCode + " for file " + file.name + " (" + file.path + ")"); return; } }).on('complete', event => { }).on('error', error => { setImmediate(reject, error); }).pipe(write_stream) .on('finish', event => { setImmediate(resolve, file.path + "/" + file.name); }); })); } return Promise.resolve(response); }).catch(error => { console.log("Failed to get file list: %o", error); return Promise.reject("Failed to get file list (" + error + ")"); }) } 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 { 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); if(!(await fs.pathExists(base_path))) return undefined; const info: { channel: string, version: string, git_hash: string, required_client: string, timestamp: number, filename: string } = await fs.readJson(path.join(base_path, "default_ui_info.json")) as any; return { channel: info.channel, client_shipped: true, filename: info.filename, git_hash: info.git_hash, required_client: info.required_client, timestamp: info.timestamp, version: info.version, } } 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 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}; 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 gunzip = zlib.createGunzip(); const extract = tar.extract(); const fpipe = fs.createReadStream(file); extract.on('entry', function(header: tar.Headers, stream, next) { if(header.type == 'file') { const target_file = path.join(target_dir, 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))) 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)); console.error(error); }).then(() => setImmediate(next)); } else { console.warn("Invalid ui tar ball entry type (" + header.type + ")"); return; } }); const finish_promise = new Promise(resolve => { extract.on('finish', resolve); extract.on('error', event => { if(!event) return; throw 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 = 0; static_cb("Downloading files", 0); for(const file of files) { console.log("Start downloading %s (%s)", file.name, file.path); const start = Date.now(); futures.push(file.local_url().then(data => { finish_count++; console.log("Downloaded %s (%s) (%ims)", file.name, file.path, Date.now() - start); static_cb("Downloading files", finish_count / files.length); })); //await new Promise(resolve => setTimeout(resolve, 1000)); } try { await Promise.all(futures); } catch (error) { throw "Failed to download files: " + error; } return await generate_tmp() + "index.html"; /* entry point */ } }