import * as querystring from "querystring"; import * as request from "request"; import {app, dialog, ipcMain} from "electron"; import * as fs from "fs-extra"; import * as ofs from "original-fs"; import * as os from "os"; import * as tar from "tar-stream"; import * as path from "path"; import * as zlib from "zlib"; import * as child_process from "child_process"; import * as progress from "request-progress"; import * as util from "util"; import {parse_version, Version} from "../../shared/version"; import Timer = NodeJS.Timer; import MessageBoxOptions = Electron.MessageBoxOptions; import {Headers} from "tar-stream"; import {Arguments, process_args} from "../../shared/process-arguments"; import * as electron from "electron"; import {PassThrough} from "stream"; import * as _main_windows from "../main_window"; import ErrnoException = NodeJS.ErrnoException; import {EPERM} from "constants"; import * as winmgr from "../window"; const is_debug = false; export function server_url() : string { const default_path = is_debug ? "http://localhost/home/TeaSpeak/TeaSpeak/Web-Client/client-api/environment/" : "http://clientapi.teaspeak.de/"; return process_args.has_value(...Arguments.SERVER_URL) ? process_args.value(...Arguments.SERVER_URL) : default_path; } export interface UpdateVersion { channel: string; platform: string, arch: string; version: Version; } export interface UpdateData { versions: UpdateVersion[]; updater_version: UpdateVersion; } let version_cache: UpdateData = undefined; export async function load_data(allow_cached: boolean = true) : Promise { if(version_cache && allow_cached) return Promise.resolve(version_cache); return new Promise((resolve, reject) => { const request_url = server_url() + "/api.php?" + querystring.stringify({ type: "update-info" }); console.log("request: %s", request_url); request.get(request_url, { timeout: 2000 }, (error, response, body) => { if(!response || response.statusCode != 200) { let info; try { info = JSON.parse(body) || {msg: error}; } catch(e) { info = {msg: "!-- failed to parse json --!"}; } setImmediate(reject, "Invalid status code (" + (response || {statusCode: -1}).statusCode + " | " + (info || {msg: "undefined"}).msg + ")"); return; } const data = JSON.parse(body); if(!data) { setImmediate(reject, "Invalid response"); return; } if(!data["success"]) { setImmediate(reject, "Action failed (" + data["msg"] + ")"); return; } let resp: UpdateData = {} as any; resp.versions = []; for(const channel of Object.keys(data)) { if(channel == "success") continue; for(const entry of data[channel]) { let version: UpdateVersion = {} as any; version.channel = channel; version.arch = entry["arch"]; version.platform = entry["platform"]; version.version = new Version(entry["version"]["major"], entry["version"]["minor"], entry["version"]["patch"], entry["version"]["build"], entry["version"]["timestamp"]); if(version.channel == 'updater') resp.updater_version = version; else resp.versions.push(version); } } resolve(resp); }); }); } export async function newest_version(current_version: Version, channel?: string) : Promise { if(!app.getAppPath().endsWith(".asar")) { throw "You cant run an update when you're executing the source code!"; } const data = await load_data(); let had_data = false; for(const version of data.versions) { if(version.arch == os.arch() && version.platform == os.platform()) { if(!channel || version.channel == channel) { if(!current_version || version.version.newer_than(current_version)) return version; else had_data = true; } } } if(!had_data) throw "Missing data"; return undefined; } /** * @param update_file The input file from where the update will get installed * @return The target executable file */ export async function extract_updater(update_file: string) : Promise { if(!fs.existsSync(update_file)) throw "Missing update file!"; let update_installer = app.getPath('temp') + "/teaclient-update-installer-" + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); if(os.platform() == "win32") update_installer += ".exe"; const source = fs.createReadStream(update_file); const extract = tar.extract(); await new Promise((resolve, reject) => { let updater_found = false; source.on('end', () => { if(!updater_found) { console.error("Failed to extract the updater (Updater hasn't been found!)"); reject("Updater hasn't been found in bundle"); } resolve(); }); extract.on('entry', (header: Headers, stream, callback) => { stream.on('end', callback); console.log("Got entry " + header.name); if(header.name == "./update-installer" || header.name == "./update-installer.exe") { console.log("Found updater! (" + header.size + " bytes)"); console.log("Extracting to %s", update_installer); const s = fs.createWriteStream(update_installer); stream.pipe(s).on('finish', event => { console.log("Updater extracted and written!"); updater_found = true; resolve(); }).on('error', event => { console.error("Failed write update file: %o", event); reject("failed to write file") }); } else { stream.resume(); //Drain the stream } }); source.pipe(extract); }); return update_installer; } export async function update_updater() : Promise { //TODO here return Promise.resolve(); } function data_directory() : string { return electron.app.getPath('userData'); } function get_update_file(channel: string, version: Version) : string { let _path = fs.realpathSync(data_directory()); const name = channel + "_" + version.major + "_" + version.minor + "_" + version.patch + "_" + version.build + ".tar"; return path.join(_path, "app_versions", name); } export interface ProgressState { percent: number, // Overall percent (between 0 to 1) speed: number, // The download speed in bytes/sec size: { total: number, // The total payload size in bytes transferred: number// The transferred payload size in bytes }, time: { elapsed: number,// The total elapsed seconds since the start (3 decimals) remaining: number // The remaining seconds to finish (3 decimals) } } export async function download_version(channel: string, version: Version, status?: (state: ProgressState) => any) : Promise { const target_path = get_update_file(channel, version); console.log("Downloading version %s to %s", version.toString(false), target_path); if(fs.existsSync(target_path)) { /* TODO test if this file is valid and can be used */ try { await fs.remove(target_path); } catch(error) { throw "Failed to remove old file: " + error; } } try { await fs.mkdirp(path.dirname(target_path)); } catch(error) { throw "Failed to make target directory: " + path.dirname(target_path); } const url = server_url() + "/api.php?" + querystring.stringify({ type: "update-download", platform: os.platform(), arch: os.arch(), version: version.toString(), channel: channel }); console.log("Downloading update from %s. (%s)", server_url(), url); return new Promise((resolve, reject) => { let fired = false; let stream = progress(request.get(url, { timeout: 2000 }, (error, response, body) => { if(!response || response.statusCode != 200) { let info; try { info = JSON.parse(body) } catch(e) { info = {"msg": "!-- failed to parse json --!"}; } if(!fired && (fired = true)) setImmediate(reject, "Invalid status code (" + (response || {statusCode: -1}).statusCode + "|" + (info || {"msg": "undefined"}).msg + ")"); return; } })).on('progress', _state => status ? status(_state) : {}).on('error', error => { console.warn("Encountered error within download pipe. Ignoring error: %o", error); }).on('end', function () { console.log("Update downloaded successfully. Waiting for write stream to finish."); if(status) status({ percent: 1, speed: 0, size: { total: 0, transferred: 0}, time: { elapsed: 0, remaining: 0} }) }); console.log("Decompressing update package while streaming!"); stream = stream.pipe(zlib.createGunzip()); stream.pipe(fs.createWriteStream(target_path, { autoClose: true })).on('finish', () => { console.log("Write stream has finished. Download successfully."); if(!fired && (fired = true)) setImmediate(resolve, target_path); }).on('error', error => { console.log("Write stream encountered an error while downloading update. Error: %o", error); if(!fired && (fired = true)) setImmediate(reject,"failed to write"); }); }); } if(typeof(String.prototype.trim) === "undefined") { String.prototype.trim = function() { return String(this).replace(/^\s+|\s+$/g, ''); }; } export async function test_file_accessibility(update_file: string) : Promise { if(os.platform() === "win32") return []; /* within windows the update installer request admin privileges if required */ const original_fs = require('original-fs'); if(!fs.existsSync(update_file)) throw "Missing update file (" + update_file + ")"; let parent_path = app.getAppPath(); if(parent_path.endsWith(".asar")) { parent_path = path.join(parent_path, "..", ".."); parent_path = fs.realpathSync(parent_path); } const test_access = async (file: string, mode: number) => { return await new Promise(resolve => original_fs.access(file, mode, resolve)); }; let code = await test_access(update_file, original_fs.constants.R_OK); if(code) throw "Failed test read for update file. (" + update_file + " results in " + code.code + ")"; const fstream = original_fs.createReadStream(update_file); const tar_stream = tar.extract(); const errors: string[] = []; const tester = async (header: Headers) => { const entry_path = path.normalize(path.join(parent_path, header.name)); if(header.type == "file") { if(original_fs.existsSync(entry_path)) { code = await test_access(entry_path, original_fs.constants.W_OK); if(code) errors.push("Failed to acquire write permissions for file " + entry_path + " (Code " + code.code + ")"); } else { let directory = path.dirname(entry_path); while(directory.length != 0 && !original_fs.existsSync(directory)) directory = path.normalize(path.join(directory, "..")); code = await test_access(directory, original_fs.constants.W_OK); if(code) errors.push("Failed to acquire write permissions for directory " + entry_path + " (Code " + code.code + ". Target directory " + directory + ")"); } } else if(header.type == "directory") { let directory = path.dirname(entry_path); while(directory.length != 0 && !original_fs.existsSync(directory)) directory = path.normalize(path.join(directory, "..")); code = await test_access(directory, original_fs.constants.W_OK); if(code) errors.push("Failed to acquire write permissions for directory " + entry_path + " (Code " + code.code + ". Target directory " + directory + ")"); } }; tar_stream.on('entry', (header: Headers, stream, next) => { tester(header).catch(error => { console.log("Emit out of tar_stream.on('entry' ...)"); tar_stream.emit('error', error); }).then(() => { stream.on('end', next); stream.resume(); }); }); fstream.pipe(tar_stream); try { await new Promise((resolve, reject) => { tar_stream.on('finish', resolve); tar_stream.on('error', error => { reject(error); }); }); } catch(error) { throw "Failed to list files within tar: " + error; } return errors; } namespace install_config { export interface LockFile { filename: string; timeout: number; "error-id": string; } export interface MoveFile { source: string; target: string; "error-id": string; } export interface ConfigFile { version: number; backup: boolean; "backup-directory": string; "callback_file": string; "callback_argument_fail": string; "callback_argument_success": string; moves: MoveFile[]; locks: LockFile[]; } } async function build_install_config(source_root: string, target_root: string) : Promise { console.log("Building update install config for target directory: %s. Update source: %o", target_root, source_root); const result: install_config.ConfigFile = { } as any; result.version = 1; result.backup = true; { const data = path.parse(source_root); result["backup-directory"] = path.join(data.dir, data.name + "_backup"); } result.callback_file = app.getPath("exe"); result.callback_argument_fail = "--no-single-instance --update-failed-new="; result.callback_argument_success = "--no-single-instance --update-succeed-new="; result.moves = []; result.locks = [ { "error-id": "main-exe-lock", filename: app.getPath("exe"), timeout: 5000 } ]; const ignore_list = [ "update-installer.exe", "update-installer" ]; const dir_walker = async (relative_path: string) => { const source_directory = path.join(source_root, relative_path); const target_directory = path.join(target_root, relative_path); let files: string[]; try { files = await util.promisify(ofs.readdir)(source_directory); } catch(error) { console.warn("Failed to iterate over source directory \"%s\": %o", source_directory, error); return; } for(const file of files) { let _exclude = false; for(const exclude of ignore_list) { if(exclude == file) { console.debug("Ignoring file to update (%s/%s)", relative_path, file); _exclude = true; break; } } if(_exclude) continue; const source_file = path.join(source_directory, file); const target_file = path.join(target_directory, file); //TODO check if file content has changed else ignore? const info = await util.promisify(ofs.stat)(source_file); if(info.isDirectory()) { await dir_walker(path.join(relative_path, file)); } else { /* TODO: ensure its a file! */ result.moves.push({ "error-id": "move-file-" + result.moves.length, source: source_file, target: target_file }); } } }; await dir_walker("."); return result; } export async function execute_update(update_file: string, restart_callback: (callback: () => void) => any) : Promise { let application_path = app.getAppPath(); if(application_path.endsWith(".asar")) { console.log("App path points to ASAR file (Going up to root directory)"); application_path = await fs.realpath(path.join(application_path, "..", "..")); } else if(await fs.pathExists(application_path) && (await fs.stat(application_path)).isFile()) application_path = path.dirname(application_path); console.log("Located target app path: %s", application_path); console.log("Using update file: %s", update_file); const temp_directory = path.join(app.getPath("temp"), "teaclient_update_" + Math.random().toString(36).substring(7)); let updater_executable; { console.log("Preparing update source directory at %s", temp_directory); try { await fs.mkdirp(temp_directory) } catch(error) { console.error("failed to create update source directory: %o", error); throw "failed to create update source directory"; } const source = fs.createReadStream(update_file); const extract = tar.extract(); extract.on('entry', (header: Headers, stream: PassThrough, callback) => { const extract = async (header: Headers, stream: PassThrough) => { const target_file = path.join(temp_directory, header.name); console.debug("Extracting entry %s of type %s to %s", header.name, header.type, target_file); if(header.type == "directory") { await fs.mkdirp(target_file); } else if(header.type == "file") { const target_finfo = path.parse(target_file); { const directory = target_finfo.dir; console.debug("Testing for directory: %s", directory); if(!(await util.promisify(ofs.exists)(directory)) || !(await util.promisify(ofs.stat)(directory)).isDirectory()) { console.log("Creating directory %s", directory); try { await fs.mkdirp(directory); } catch(error) { console.warn("failed to create directory for file %s", header.type); } } } const write_stream = ofs.createWriteStream(target_file); try { await new Promise((resolve, reject) => { stream.pipe(write_stream) .on('error', reject) .on('finish', resolve); }); if(target_finfo.name === "update-installer" || target_finfo.name === "update-installer.exe") { updater_executable = target_file; console.log("Found update installer: %s", target_file); } return; /* success */ } catch(error) { console.error("Failed to extract update file %s: %o", header.name, error); } } else { console.debug("Skipping this unknown file type"); } stream.resume(); /* drain the stream */ }; extract(header, stream).catch(error => { console.log("Ignoring file %s due to an error: %o", header.name, error); }).then(() => { callback(); }); }); source.pipe(extract); try { await new Promise((resolve, reject) => { extract.on('finish', resolve); extract.on('error', reject); }); } catch(error) { console.error("Failed to unpack update: %o", error); throw "update unpacking failed"; } } if(typeof(updater_executable) !== "string" || !(await fs.pathExists(updater_executable))) throw "missing update installer executable within update package"; /* the "new" environment should now be available at 'temp_directory' */ console.log("Update unpacked successfully. Building update extractor file."); let install_config; try { install_config = await build_install_config(temp_directory, application_path); } catch(error) { console.error("Failed to build update installer config: %o", error); throw "failed to build update installer config"; } const log_file = path.join(temp_directory, "update-log.txt"); const config_file = path.join(temp_directory, "update_install.json"); console.log("Writing config to %s", config_file); try { await fs.writeJSON(config_file, install_config); } catch(error) { console.error("Failed to write update install config file: %s", error); throw "failed to write update install config file"; } if(os.platform() == "linux") { console.log("Executing update install on linux"); //We have to unpack it later const rest_callback = () => { console.log("Executing command %s with args %o", updater_executable, [log_file, config_file]); try { let result = child_process.spawnSync(updater_executable, [log_file, config_file]); if(result.status != 0) { console.error("Failed to execute update installer! Return code: %d", result.status); dialog.showMessageBox({ buttons: ["update now", "remind me later"], title: "Update available", message: "Failed to execute update installer\n" + "Installer exited with code " + result.status } as MessageBoxOptions); } } catch(error) { console.error("Failed to execute update installer (%o)", error); if("errno" in error) { const errno = error as ErrnoException; if(errno.errno == EPERM) { dialog.showMessageBox({ buttons: ["quit"], title: "Update execute failed", message: "Failed to execute update installer. (No permissions)\nPlease execute the client with admin privileges!" } as MessageBoxOptions); return; } dialog.showMessageBox({ buttons: ["quit"], title: "Update execute failed", message: "Failed to execute update installer.\nError: " + errno.message } as MessageBoxOptions); return; } dialog.showMessageBox({ buttons: ["quit"], title: "Update execute failed", message: "Failed to execute update installer.\nLookup console for more detail" } as MessageBoxOptions); return; } if(electron.app.hasSingleInstanceLock()) electron.app.releaseSingleInstanceLock(); const ids = child_process.execSync("pgrep TeaClient").toString().split(os.EOL).map(e => e.trim()).reverse().join(" "); console.log("Executing %s", "kill -9 " + ids); child_process.execSync("kill -9 " + ids); }; restart_callback(rest_callback); } else { console.log("Executing update install on windows"); //We have to unpack it later const rest_callback = () => { console.log("Executing command %s with args %o", updater_executable, [log_file, config_file]); try { const pipe = child_process.spawn(updater_executable, [log_file, config_file], { detached: true, shell: true, cwd: path.dirname(app.getAppPath()), stdio: "ignore" }); pipe.unref(); app.quit(); } catch(error) { console.dir(error); electron.dialog.showErrorBox("Failed to finalize update", "Failed to finalize update.\nInvoking the update-installer.exe failed.\nLookup the console for more details."); } }; restart_callback(rest_callback); } } export async function current_version() : Promise { if(process_args.has_value(Arguments.UPDATER_LOCAL_VERSION)) return parse_version(process_args.value(Arguments.UPDATER_LOCAL_VERSION)); let parent_path = app.getAppPath(); if(parent_path.endsWith(".asar")) { parent_path = path.join(parent_path, "..", ".."); parent_path = fs.realpathSync(parent_path); } try { const info = await fs.readJson(path.join(parent_path, "app_version.json")); let result = parse_version(info["version"]); result.timestamp = info["timestamp"]; return result; } catch (error) { console.log("Got no version!"); return new Version(0, 0, 0, 0, 0); } } async function minawait(object: Promise, time: number) : Promise { const begin = Date.now(); const r = await object; const end = Date.now(); if(end - begin < time) await new Promise(resolve => setTimeout(resolve, time + begin - end)); return r; } export let update_restart_pending = false; export async function execute_graphical(channel: string, ask_install: boolean) : Promise { const electron = require('electron'); const ui_debug = process_args.has_flag(Arguments.UPDATER_UI_DEBUG); const window = new electron.BrowserWindow({ show: false, width: ui_debug ? 1200 : 600, height: ui_debug ? 800 : 400, webPreferences: { devTools: true, nodeIntegration: true, javascript: true } }); window.loadFile(path.join(path.dirname(module.filename), "ui", "index.html")); if(ui_debug) { window.webContents.openDevTools(); } await new Promise(resolve => window.on('ready-to-show', resolve)); window.show(); await winmgr.apply_bounds('update-installer', window); winmgr.track_bounds('update-installer', window); const current_vers = await current_version(); console.log("Current version: " + current_vers.toString(true)); console.log("Showed"); const set_text = text => window.webContents.send('status-update-text', text); const set_error = text => window.webContents.send('status-error', text); const set_progress = progress => window.webContents.send('status-update', progress); const await_exit = () => { return new Promise(resolve => window.on('closed', resolve))}; const await_version_confirm = version => { const id = "version-accept-" + Date.now(); window.webContents.send('status-confirm-update', id, current_vers, version); return new Promise((resolve, reject) => { window.on('closed', () => resolve(false)); ipcMain.once(id, (event, result) => { console.log("Got response %o", result); resolve(result); }); }); }; const await_confirm_execute = () => { const id = "status-confirm-execute-" + Date.now(); window.webContents.send('status-confirm-execute', id); return new Promise((resolve, reject) => { window.on('closed', () => resolve(false)); ipcMain.once(id, (event, result) => { console.log("Got response %o", result); resolve(result); }); }); }; set_text("Loading data"); let version: UpdateVersion; try { version = await minawait(newest_version(process_args.has_flag(Arguments.UPDATER_ENFORCE) ? undefined : current_vers, channel), 3000); } catch (error) { set_error("Failed to get newest information:
" + error); await await_exit(); return false; } console.log("Got version %o", version); if(!version) { set_error("You're already on the newest version!"); await await_exit(); return false; } if(ask_install) { try { const test = await await_version_confirm(version.version); if(!test) { window.close(); return false; } } catch (error) { console.dir(error); window.close(); return false; } } set_text("Updating to version " + version.version.toString() + "
Downloading...."); let update_path: string; try { update_path = await download_version(version.channel, version.version, status => { setImmediate(set_progress, status.percent); }); } catch (error) { set_error("Failed to download version:
" + error); console.error(error); await await_exit(); return false; } try { const inaccessible = await test_file_accessibility(update_path); if(inaccessible.length > 0) { console.log("Failed to access the following files:"); for(const fail of inaccessible) console.log(" - " + fail); if(os.platform() == "linux") { set_error("Failed to access target files.
Please execute this app with administrator (sudo) privileges.
Use the following command:

" + "sudo " + path.normalize(app.getAppPath()) + " --update-execute=\"" + path.normalize(update_path) + "\"

"); await await_exit(); return false; } else if(os.platform() == "win32") { /* the updater asks for admin rights anyway :/ */ } } } catch(error) { set_error("Failed to access target files.
You may need to execute the TeaClient as Administrator!
Error: " + error); await await_exit(); return false; } if(!await await_confirm_execute()) { window.close(); return false; } set_text("Extracting update installer...
Please wait"); try { await extract_updater(update_path); } catch(error) { console.error("Failed to update the updater! (%o)", error); set_error("Failed to update the update installer.\nUpdate failed!"); await await_exit(); return false; } set_text("Executing update...
Please wait"); try { await execute_update(update_path, callback => { _main_windows.set_prevent_instant_close(true); update_restart_pending = true; window.close(); callback(); }); } catch (error) { dialog.showErrorBox("Update error", "Failed to execute update!\n" + error); return false; } return true; } export let update_question_open = false; async function check_update(channel: string) { let version: UpdateVersion; try { version = await newest_version(await current_version(), channel); } catch(error) { console.warn("failed check for newer versions!"); console.error(error); return; } if(version && !update_question_open) { update_question_open = true; dialog.showMessageBox({ buttons: ["update now", "remind me later"], title: "TeaClient: Update available", message: "There is an update available!\n" + "Should we update now?\n" + "\n" + "Current version: " + (await current_version()).toString() + "\n" + "Target version: " + version.version.toString() } as MessageBoxOptions).then(result => { if(result.response == 0) { execute_graphical(channel, false).then(() => { update_question_open = false; }); } else { update_question_open = false; } }); } } let update_task: Timer; export function start_auto_update_check() { if(update_task) return; update_task = setInterval(check_update, 2 * 60 * 60 * 1000); setImmediate(check_update); } export function stop_auto_update_check() { clearInterval(update_task); update_task = undefined; } export async function selected_channel() : Promise { return process_args.has_value(Arguments.UPDATER_CHANNEL) ? process_args.value(Arguments.UPDATER_CHANNEL) : "release"; }