794 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			794 lines
		
	
	
		
			29 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import * as querystring from "querystring";
 | |
| import * as request from "request";
 | |
| import {app, dialog} 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 {parseVersion, Version} from "../../shared/version";
 | |
| 
 | |
| import MessageBoxOptions = Electron.MessageBoxOptions;
 | |
| import {Headers} from "tar-stream";
 | |
| import {Arguments, processArguments} from "../../shared/process-arguments";
 | |
| import * as electron from "electron";
 | |
| import {PassThrough} from "stream";
 | |
| import ErrnoException = NodeJS.ErrnoException;
 | |
| import { default as validateUpdateConfig } from "./UpdateConfigFile.validator";
 | |
| import { default as validateAppInfo } from "./AppInfoFile.validator";
 | |
| import UpdateConfigFile from "./UpdateConfigFile";
 | |
| import AppInfoFile from "./AppInfoFile";
 | |
| 
 | |
| export type UpdateStatsCallback = (message: string, progress: number) => void;
 | |
| export type UpdateLogCallback = (type: "error" | "info", message: string) => void;
 | |
| 
 | |
| export function updateServerUrl() : string {
 | |
|     /* FIXME! */
 | |
|     return "https://clientapi.teaspeak.de/";
 | |
|     return processArguments.has_value(...Arguments.SERVER_URL) ? processArguments.value(...Arguments.SERVER_URL) : "https://clientapi.teaspeak.de/";
 | |
| }
 | |
| 
 | |
| export interface UpdateVersion {
 | |
|     channel: string;
 | |
|     platform: string,
 | |
|     arch: string;
 | |
|     version: Version;
 | |
| }
 | |
| 
 | |
| export interface UpdateData {
 | |
|     versions: UpdateVersion[];
 | |
|     updater_version: UpdateVersion;
 | |
| }
 | |
| 
 | |
| let remoteVersionCacheTimestamp: number;
 | |
| let remoteVersionCache: Promise<UpdateData>;
 | |
| export async function fetchRemoteUpdateData() : Promise<UpdateData> {
 | |
|     if(remoteVersionCache && remoteVersionCacheTimestamp > Date.now() - 60 * 60 * 1000) {
 | |
|         return remoteVersionCache;
 | |
|     }
 | |
| 
 | |
|     /* TODO: Validate remote response schema */
 | |
|     remoteVersionCacheTimestamp = Date.now();
 | |
|     return (remoteVersionCache = new Promise<UpdateData>((resolve, reject) => {
 | |
|         const request_url = updateServerUrl() + "/api.php?" + querystring.stringify({
 | |
|             type: "update-info"
 | |
|         });
 | |
|         console.log("request: %s", request_url);
 | |
|         request.get(request_url, {
 | |
|             timeout: 2000
 | |
|         }, (error, response, body) => {
 | |
|             if(response.statusCode !== 200) {
 | |
|                 setImmediate(reject, "Invalid status code (" + response.statusCode + (response.statusMessage ? "/" + response.statusMessage : "") + ")");
 | |
|                 return;
 | |
|             }
 | |
|             if(!response) {
 | |
|                 setImmediate(reject, "Missing response object");
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             let data: any;
 | |
|             try {
 | |
|                 data = JSON.parse(body);
 | |
|             } catch (_error) {
 | |
|                 setImmediate(reject, "Failed to parse response");
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             if(!data["success"]) {
 | |
|                 setImmediate(reject, "Action failed (" + (data["msg"] || "unknown error") + ")");
 | |
|                 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);
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             setImmediate(resolve, resp);
 | |
|         });
 | |
|     })).catch(error => {
 | |
|         /* Don't cache errors */
 | |
|         remoteVersionCache = undefined;
 | |
|         remoteVersionCacheTimestamp = undefined;
 | |
|         return Promise.reject(error);
 | |
|     });
 | |
| }
 | |
| 
 | |
| export async function availableRemoteChannels() : Promise<string[]> {
 | |
|     const versions = (await fetchRemoteUpdateData()).versions.map(e => e.channel);
 | |
| 
 | |
|     return [...new Set(versions)];
 | |
| }
 | |
| 
 | |
| export async function newestRemoteClientVersion(channel: string) : Promise<UpdateVersion | undefined> {
 | |
|     const data = await fetchRemoteUpdateData();
 | |
| 
 | |
|     let currentVersion: UpdateVersion;
 | |
|     for(const version of data.versions) {
 | |
|         if(version.arch == os.arch() && version.platform == os.platform()) {
 | |
|             if(version.channel == channel) {
 | |
|                 if(!currentVersion || version.version.newerThan(currentVersion.version)) {
 | |
|                     currentVersion = version;
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     return currentVersion;
 | |
| }
 | |
| 
 | |
| function getAppDataDirectory() : string {
 | |
|     return electron.app.getPath('userData');
 | |
| }
 | |
| 
 | |
| function generateUpdateFilePath(channel: string, version: Version) : string {
 | |
|     let directory = fs.realpathSync(getAppDataDirectory());
 | |
| 
 | |
|     const name = channel + "_" + version.major + "_" + version.minor + "_" + version.patch + "_" + version.build + ".tar";
 | |
|     return path.join(directory, "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 downloadClientVersion(channel: string, version: Version, status: (state: ProgressState) => any, callbackLog: UpdateLogCallback) : Promise<string> {
 | |
|     const targetFilePath = generateUpdateFilePath(channel, version);
 | |
| 
 | |
|     if(fs.existsSync(targetFilePath)) {
 | |
|         callbackLog("info", "Removing old update file located at " + targetFilePath);
 | |
| 
 | |
|         /* TODO test if this file is valid and can be used */
 | |
|         try {
 | |
|             await fs.remove(targetFilePath);
 | |
|         } catch(error) {
 | |
|             throw "Failed to remove old file: " + error;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     try {
 | |
|         await fs.mkdirp(path.dirname(targetFilePath));
 | |
|     } catch(error) {
 | |
|         throw "Failed to make target directory: " + path.dirname(targetFilePath);
 | |
|     }
 | |
| 
 | |
|     const requestUrl = updateServerUrl() + "/api.php?" + querystring.stringify({
 | |
|         type: "update-download",
 | |
|         platform: os.platform(),
 | |
|         arch: os.arch(),
 | |
|         version: version.toString(),
 | |
|         channel: channel
 | |
|     });
 | |
| 
 | |
|     callbackLog("info", "Downloading version " + version.toString(false) + " to " + targetFilePath + " from " + updateServerUrl());
 | |
|     console.log("Downloading update from %s. (%s)", updateServerUrl(), requestUrl);
 | |
| 
 | |
|     return new Promise<string>((resolve, reject) => {
 | |
|         let fired = false;
 | |
|         const fireFailed = (reason: string) => {
 | |
|             if(fired) { return; }
 | |
|             fired = true;
 | |
| 
 | |
|             setImmediate(reject, reason);
 | |
|         };
 | |
| 
 | |
|         let stream = progress(request.get(requestUrl, {
 | |
|             timeout: 10_000
 | |
|         }, (error, response, _body) => {
 | |
|             if(!response) {
 | |
|                 fireFailed("Missing response object");
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             if(response.statusCode != 200) {
 | |
|                 fireFailed("Invalid HTTP response code: " + response.statusCode + (response.statusMessage ? "/" + response.statusMessage : ""));
 | |
|                 return;
 | |
|             }
 | |
|         })).on('progress', status).on('error', error => {
 | |
|             console.warn("Encountered error within download pipe. Ignoring error: %o", error);
 | |
|         }).on('end', function () {
 | |
|             callbackLog("info", "Update downloaded.");
 | |
|             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(targetFilePath, {
 | |
|             autoClose: true
 | |
|         })).on('finish', () => {
 | |
|             console.log("Write stream has finished. Download successfully.");
 | |
|             if(!fired && (fired = true)) {
 | |
|                 setImmediate(resolve, targetFilePath);
 | |
|             }
 | |
|         }).on('error', error => {
 | |
|             console.log("Write stream encountered an error while downloading update. Error: %o", error);
 | |
|             fireFailed("disk write error");
 | |
|         });
 | |
|     });
 | |
| }
 | |
| 
 | |
| if(typeof(String.prototype.trim) === "undefined")
 | |
| {
 | |
|     String.prototype.trim = function()
 | |
|     {
 | |
|         return String(this).replace(/^\s+|\s+$/g, '');
 | |
|     };
 | |
| }
 | |
| 
 | |
| export async function ensureTargetFilesAreWriteable(updateFile: string) : Promise<string[]> {
 | |
|     const original_fs = require('original-fs');
 | |
|     if(!fs.existsSync(updateFile)) {
 | |
|         throw "Missing update file (" + updateFile + ")";
 | |
|     }
 | |
| 
 | |
|     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(updateFile, original_fs.constants.R_OK);
 | |
|     if(code)
 | |
|         throw "Failed test read for update file. (" + updateFile + " results in " + code.code + ")";
 | |
| 
 | |
|     const fstream = original_fs.createReadStream(updateFile);
 | |
|     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 createUpdateInstallConfig(sourceRoot: string, targetRoot: string) : Promise<install_config.ConfigFile> {
 | |
|     console.log("Building update install config for target directory: %s. Update source: %o", targetRoot, sourceRoot);
 | |
|     const result: install_config.ConfigFile = { } as any;
 | |
| 
 | |
|     result.version = 1;
 | |
| 
 | |
|     result.backup = true;
 | |
| 
 | |
|     {
 | |
|         const data = path.parse(sourceRoot);
 | |
|         result["backup-directory"] = path.join(data.dir, data.name + "_backup");
 | |
|     }
 | |
| 
 | |
|     result["permission-test-directory"] = targetRoot;
 | |
|     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 ignoreFileList = [
 | |
|         "update-installer.exe", "update-installer"
 | |
|     ];
 | |
| 
 | |
|     const dirWalker = async (relative_path: string) => {
 | |
|         const source_directory = path.join(sourceRoot, relative_path);
 | |
|         const target_directory = path.join(targetRoot, 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 shouldBeExcluded = false;
 | |
|             for(const ignoredFile of ignoreFileList) {
 | |
|                 if(ignoredFile == file) {
 | |
|                     console.debug("Ignoring file to update (%s/%s)", relative_path, file);
 | |
|                     shouldBeExcluded = true;
 | |
|                     break;
 | |
|                 }
 | |
|             }
 | |
|             if(shouldBeExcluded) {
 | |
|                 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 dirWalker(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 dirWalker(".");
 | |
|     return result;
 | |
| }
 | |
| 
 | |
| export async function extractUpdateFile(updateFile: string, callbackLog: UpdateLogCallback) : Promise<{ updateSourceDirectory: string, updateInstallerExecutable: string }> {
 | |
|     const temporaryDirectory = path.join(app.getPath("temp"), "teaclient_update_" + Math.random().toString(36).substring(7));
 | |
| 
 | |
|     try {
 | |
|         await fs.mkdirp(temporaryDirectory)
 | |
|     } catch(error) {
 | |
|         console.error("failed to create update source directory (%s): %o", temporaryDirectory, error);
 | |
|         throw "failed to create update source directory";
 | |
|     }
 | |
| 
 | |
|     callbackLog("info", "Extracting update to " + temporaryDirectory);
 | |
|     console.log("Extracting update file %s to %s", updateFile, temporaryDirectory);
 | |
| 
 | |
|     let updateInstallerPath = undefined;
 | |
| 
 | |
|     const updateFileStream = fs.createReadStream(updateFile);
 | |
|     const extract = tar.extract();
 | |
| 
 | |
|     extract.on('entry', (header: Headers, stream: PassThrough, callback) => {
 | |
|         const extract = async (header: Headers, stream: PassThrough) => {
 | |
|             const targetFile = path.join(temporaryDirectory, header.name);
 | |
|             console.debug("Extracting entry %s of type %s to %s", header.name, header.type, targetFile);
 | |
| 
 | |
|             if(header.type == "directory") {
 | |
|                 await fs.mkdirp(targetFile);
 | |
|             } else if(header.type == "file") {
 | |
|                 const targetPath = path.parse(targetFile);
 | |
| 
 | |
|                 {
 | |
|                     const directory = targetPath.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(targetFile);
 | |
|                 try {
 | |
|                     await new Promise((resolve, reject) => {
 | |
|                         stream.pipe(write_stream)
 | |
|                             .on('error', reject)
 | |
|                             .on('finish', resolve);
 | |
|                     });
 | |
| 
 | |
|                     if(targetPath.name === "update-installer" || targetPath.name === "update-installer.exe") {
 | |
|                         updateInstallerPath = targetFile;
 | |
|                         callbackLog("info", "Found update installer at " + targetFile);
 | |
|                     }
 | |
| 
 | |
|                     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();
 | |
|         });
 | |
|     });
 | |
| 
 | |
| 
 | |
|     updateFileStream.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 updateInstallerPath !== "string" || !(await fs.pathExists(updateInstallerPath))) {
 | |
|         throw "missing update installer executable within update package";
 | |
|     }
 | |
| 
 | |
|     callbackLog("info", "Update successfully extracted");
 | |
|     return { updateSourceDirectory: temporaryDirectory, updateInstallerExecutable: updateInstallerPath }
 | |
| }
 | |
| 
 | |
| let cachedAppInfo: AppInfoFile;
 | |
| async function initializeAppInfo() {
 | |
|     let directory = app.getAppPath();
 | |
|     if(!directory.endsWith(".asar")) {
 | |
|         /* we're in a development version */
 | |
|         cachedAppInfo = {
 | |
|             version: 2,
 | |
|             clientVersion: {
 | |
|                 major: 0,
 | |
|                 minor: 0,
 | |
|                 patch: 0,
 | |
|                 buildIndex: 0,
 | |
|                 timestamp: Date.now()
 | |
|             },
 | |
| 
 | |
|             uiPackChannel: "release",
 | |
|             clientChannel: "release"
 | |
|         };
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     cachedAppInfo = validateAppInfo(await fs.readJson(path.join(directory, "..", "..", "app-info.json")));
 | |
|     if(cachedAppInfo.version !== 2) {
 | |
|         cachedAppInfo = undefined;
 | |
|         throw "invalid app info version";
 | |
|     }
 | |
| }
 | |
| 
 | |
| export function clientAppInfo() : AppInfoFile {
 | |
|     if(typeof cachedAppInfo !== "object") {
 | |
|         throw "app info not initialized";
 | |
|     }
 | |
| 
 | |
|     return cachedAppInfo;
 | |
| }
 | |
| 
 | |
| export async function currentClientVersion() : Promise<Version> {
 | |
|     if(processArguments.has_value(Arguments.UPDATER_LOCAL_VERSION)) {
 | |
|         return parseVersion(processArguments.value(Arguments.UPDATER_LOCAL_VERSION));
 | |
|     }
 | |
| 
 | |
|     const info = clientAppInfo();
 | |
|     return new Version(info.clientVersion.major, info.clientVersion.minor, info.clientVersion.patch, info.clientVersion.buildIndex, info.clientVersion.timestamp);
 | |
| }
 | |
| 
 | |
| let cachedUpdateConfig: UpdateConfigFile;
 | |
| function updateConfigFile() : string {
 | |
|     return path.join(electron.app.getPath('userData'), "update-settings.json");
 | |
| }
 | |
| 
 | |
| export async function initializeAppUpdater() {
 | |
|     try {
 | |
|         await initializeAppInfo();
 | |
|     } catch (error) {
 | |
|         console.error("Failed to parse app info: %o", error);
 | |
|         throw "Failed to parse app info file";
 | |
|     }
 | |
| 
 | |
|     const config = updateConfigFile();
 | |
|     if(await fs.pathExists(config)) {
 | |
|         try {
 | |
|             cachedUpdateConfig = validateUpdateConfig(await fs.readJson(config));
 | |
|             if(cachedUpdateConfig.version !== 1) {
 | |
|                 cachedUpdateConfig = undefined;
 | |
|                 throw "invalid update config version";
 | |
|             }
 | |
|         } catch (error) {
 | |
|             console.warn("Failed to parse update config file: %o. Invalidating it.", error);
 | |
|             try {
 | |
|                 await fs.rename(config, config + "." + Date.now());
 | |
|             } catch (_) {}
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     if(!cachedUpdateConfig) {
 | |
|         cachedUpdateConfig = {
 | |
|             version: 1,
 | |
|             selectedChannel: "release"
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| export function updateConfig() {
 | |
|     if(typeof cachedUpdateConfig === "string") {
 | |
|         throw "app updater hasn't been initialized yet";
 | |
|     }
 | |
|     return cachedUpdateConfig;
 | |
| }
 | |
| 
 | |
| export function saveUpdateConfig() {
 | |
|     const file = updateConfigFile();
 | |
|     fs.writeJson(file, cachedUpdateConfig).catch(error => {
 | |
|         console.error("Failed to save update config: %o", error);
 | |
|     });
 | |
| }
 | |
| 
 | |
| /* Attention: The current channel might not be the channel the client has initially been loaded with! */
 | |
| export function clientUpdateChannel() : string {
 | |
|     return updateConfig().selectedChannel;
 | |
| }
 | |
| 
 | |
| export function setClientUpdateChannel(channel: string) {
 | |
|     if(updateConfig().selectedChannel == channel) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     updateConfig().selectedChannel = channel;
 | |
|     saveUpdateConfig();
 | |
| }
 | |
| 
 | |
| export async function availableClientUpdate() : Promise<UpdateVersion | undefined> {
 | |
|     const version = await newestRemoteClientVersion(clientAppInfo().clientChannel);
 | |
|     if(!version) { return undefined; }
 | |
| 
 | |
|     const localVersion = await currentClientVersion();
 | |
|     return !localVersion.isDevelopmentVersion() && version.version.newerThan(localVersion) ? version : undefined;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * @returns The callback to execute the update
 | |
|  */
 | |
| export async function prepareUpdateExecute(targetVersion: UpdateVersion, callbackStats: UpdateStatsCallback, callbackLog: UpdateLogCallback) : Promise<{ callbackExecute: () => void, callbackAbort: () => void }> {
 | |
|     let targetApplicationPath = app.getAppPath();
 | |
|     if(targetApplicationPath.endsWith(".asar")) {
 | |
|         console.log("App path points to ASAR file (Going up to root directory)");
 | |
|         targetApplicationPath = await fs.realpath(path.join(targetApplicationPath, "..", ".."));
 | |
|     } else {
 | |
|         throw "the source can't be updated";
 | |
|     }
 | |
| 
 | |
|     callbackStats("Downloading update", 0);
 | |
| 
 | |
|     const updateFilePath = await downloadClientVersion(targetVersion.channel, targetVersion.version, status => {
 | |
|         callbackStats("Downloading update", status.percent);
 | |
|     }, callbackLog);
 | |
| 
 | |
|     /* TODO: Remove this step and let the actual updater so this. If this fails we'll already receiving appropiate error messages. */
 | |
|     if(os.platform() !== "win32") {
 | |
|         callbackLog("info", "Checking file permissions");
 | |
|         callbackStats("Checking file permissions", .25);
 | |
| 
 | |
|         /* We must be on a unix based system */
 | |
|         try {
 | |
|             const inaccessiblePaths = await ensureTargetFilesAreWriteable(updateFilePath);
 | |
|             if(inaccessiblePaths.length > 0) {
 | |
|                 console.log("Failed to access the following files:");
 | |
|                 for(const fail of inaccessiblePaths) {
 | |
|                     console.log(" - " + fail);
 | |
|                 }
 | |
| 
 | |
|                 const executeCommand = "sudo " + path.normalize(app.getAppPath()) + " --update-execute";
 | |
|                 throw "Failed to access target files.\nPlease execute this app with administrator (sudo) privileges.\nUse the following command:\n" + executeCommand;
 | |
|             }
 | |
|         } catch(error) {
 | |
|             console.warn("Failed to validate target file accessibility: %o", error);
 | |
|         }
 | |
|     } else {
 | |
|         /* the windows update already requests admin privileges */
 | |
|     }
 | |
| 
 | |
|     callbackStats("Extracting update", .5);
 | |
|     const { updateSourceDirectory, updateInstallerExecutable } = await extractUpdateFile(updateFilePath, callbackLog);
 | |
| 
 | |
|     callbackStats("Generating install config", .5);
 | |
| 
 | |
|     callbackLog("info", "Generating install config");
 | |
|     let installConfig;
 | |
|     try {
 | |
|         installConfig = await createUpdateInstallConfig(updateSourceDirectory, targetApplicationPath);
 | |
|     } catch(error) {
 | |
|         console.error("Failed to build update installer config: %o", error);
 | |
|         throw "failed to build update installer config";
 | |
|     }
 | |
| 
 | |
|     const installLogFile = path.join(updateSourceDirectory, "update-log.txt");
 | |
|     const installConfigFile = path.join(updateSourceDirectory, "update_install.json");
 | |
|     console.log("Writing config to %s", installConfigFile);
 | |
|     try {
 | |
|         await fs.writeJSON(installConfigFile, installConfig);
 | |
|     } catch(error) {
 | |
|         console.error("Failed to write update install config file: %s", error);
 | |
|         throw "failed to write update install config file";
 | |
|     }
 | |
| 
 | |
|     callbackLog("info", "Generating config generated at " + installConfigFile);
 | |
| 
 | |
|     let executeCallback: () => void;
 | |
|     if(os.platform() == "linux") {
 | |
|         console.log("Executing update install on linux");
 | |
| 
 | |
|         //We have to unpack it later
 | |
|         executeCallback = () => {
 | |
|             console.log("Executing command %s with args %o", updateInstallerExecutable, [installLogFile, installConfigFile]);
 | |
|             try {
 | |
|                 let result = child_process.spawnSync(updateInstallerExecutable, [installLogFile, installConfigFile]);
 | |
|                 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 == os.constants.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);
 | |
|         };
 | |
|     } else {
 | |
|         console.log("Executing update install on windows");
 | |
| 
 | |
|         executeCallback = () => {
 | |
|             console.log("Executing command %s with args %o", updateInstallerExecutable, [installLogFile, installConfigFile]);
 | |
| 
 | |
|             try {
 | |
|                 const pipe = child_process.spawn(updateInstallerExecutable, [installLogFile, installConfigFile], {
 | |
|                     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.");
 | |
|             }
 | |
|         };
 | |
|     }
 | |
| 
 | |
|     callbackStats("Update successfully prepared", 1);
 | |
|     callbackLog("info", "Update successfully prepared");
 | |
| 
 | |
|     return {
 | |
|         callbackExecute: executeCallback,
 | |
|         callbackAbort: () => {
 | |
|             /* TODO: Cleanup */
 | |
|         }
 | |
|     }
 | |
| } |