TeaSpeak-Client/modules/core/ui-loader/loader.ts

515 lines
19 KiB
TypeScript

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<String>
}
function generate_tmp() : Promise<String> {
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<VersionedFile[]> {
return generate_tmp().then(path => new Promise<VersionedFile[]>((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<VersionedFile[]> {
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<String>((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<LocalUICache> {
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<UIVersion | undefined> {
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<UIVersion[] | UIVersion> {
return await new Promise<UIVersion[] | UIVersion>((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<void> {
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<string> {
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<String> {
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<void>[] = [];
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 */
}
}