TeaSpeak-Client/modules/core/app-updater/index.ts
2019-07-03 13:16:38 +02:00

863 lines
32 KiB
TypeScript

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<UpdateData> {
if(version_cache && allow_cached) return Promise.resolve(version_cache);
return new Promise<UpdateData>((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<UpdateVersion | undefined> {
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;
}
export async function extract_updater(update_file: string) {
if(!fs.existsSync(update_file)) throw "Missing update file!";
let parent_path = app.getAppPath();
if(parent_path.endsWith(".asar")) {
parent_path = path.join(parent_path, "..", "..");
parent_path = fs.realpathSync(parent_path);
}
let post_path;
if(os.platform() == "linux")
post_path = parent_path + "/update-installer";
else
post_path = parent_path + "/update-installer.exe";
const source = fs.createReadStream(update_file);
const extract = tar.extract();
await new Promise(resolve => {
let updater_found = false;
source.on('end', () => {
if(!updater_found) {
console.error("Failed to extract the updater (Updater hasn't been found!)");
resolve(); //FIXME use reject!
}
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 + ")");
console.log("Extracting to %s", post_path);
const s = fs.createWriteStream(post_path);
stream.pipe(s).on('finish', event => {
console.log("Updater extracted and written!");
updater_found = true;
resolve();
});
} else {
stream.resume(); //Drain the stream
}
});
source.pipe(extract);
});
}
export async function update_updater() : Promise<void> {
//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<string> {
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<string>((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<string[]> {
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<NodeJS.ErrnoException>(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<install_config.ConfigFile> {
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<void> {
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));
{
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 directory = path.parse(target_file).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);
});
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";
}
}
/* 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";
}
const update_installer = path.join(application_path, "update-installer" + (os.platform() === "win32" ? ".exe" : ""));
if(!(await fs.pathExists(update_installer))) {
console.error("Missing update installer! Supposed to be at %s", update_installer);
throw "Missing update installer!";
} else {
console.log("Using update installer located at %s", update_installer);
}
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", update_installer, [log_file, config_file]);
try {
let result = child_process.spawnSync(update_installer, [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 = () => {
let pipe = child_process.spawn(update_installer, [log_file, config_file], {
detached: true,
cwd: application_path,
stdio: 'ignore',
});
pipe.unref();
app.quit();
};
restart_callback(rest_callback);
}
}
export async function current_version() : Promise<Version> {
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<T>(object: Promise<T>, time: number) : Promise<T> {
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<Boolean> {
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:<br>" + 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() + "<br>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: <br>" + 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.<br>Please execute this app with administrator (sudo) privileges.<br>Use the following command:<br><p>" +
"sudo " + path.normalize(app.getAppPath()) + " --update-execute=\"" + path.normalize(update_path) + "\"</p>");
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.<br>You may need to execute the TeaClient as Administrator!<br>Error: " + error);
await await_exit();
return false;
}
if(!await await_confirm_execute()) {
window.close();
return false;
}
set_text("Extracting update installer...<br>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...<br>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 = 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, result => {
if(result == 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<string> {
return process_args.has_value(Arguments.UPDATER_CHANNEL) ? process_args.value(Arguments.UPDATER_CHANNEL) : "release";
}