diff --git a/generate-json-validators.sh b/generate-json-validators.sh new file mode 100644 index 0000000..1a833c6 --- /dev/null +++ b/generate-json-validators.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +npx typescript-json-validator ./modules/core/ui-loader/CacheFile.ts || exit 1 +npx typescript-json-validator modules/core/ui-loader/ShippedFileInfo.ts || exit 1 +npx typescript-json-validator modules/core/app-updater/UpdateConfigFile.ts || exit 1 +npx typescript-json-validator modules/core/app-updater/AppInfoFile.ts || exit 1 \ No newline at end of file diff --git a/github b/github index 30d1bc0..989bdd6 160000 --- a/github +++ b/github @@ -1 +1 @@ -Subproject commit 30d1bc01979c59d3d869f3be733b8849b173b42c +Subproject commit 989bdd62182ba2d4ad040c4177d3ab72eb10e408 diff --git a/installer/build.ts b/installer/build.ts index 7a12a70..99aae5f 100644 --- a/installer/build.ts +++ b/installer/build.ts @@ -1,19 +1,19 @@ import {Options} from "electron-packager"; import * as packager from "electron-packager" const pkg = require('../package.json'); -const dev_dependencies = Object.keys(pkg.devDependencies); import * as fs from "fs-extra"; import * as path_helper from "path"; -import {parse_version} from "../modules/shared/version"; +import {parseVersion} from "../modules/shared/version"; import * as child_process from "child_process"; import * as os from "os"; import * as querystring from "querystring"; import request = require("request"); import * as deployer from "./deploy"; +import AppInfoFile from "../modules/core/app-updater/AppInfoFile"; let options: Options = {} as any; -let version = parse_version(pkg.version); +let version = parseVersion(pkg.version); version.timestamp = Date.now(); options.dir = '.'; @@ -29,7 +29,7 @@ if(!pkg.dependencies['electron']) { options["version-string"] = { 'CompanyName': 'TeaSpeak', - 'LegalCopyright': '© 2018-2020 Markus Hadenfeldt All Rights Reserved', + 'LegalCopyright': options.appCopyright, 'FileDescription' : 'TeaSpeak-Client', 'OriginalFilename' : 'TeaClient.exe', 'FileVersion' : pkg.version, @@ -37,17 +37,13 @@ options["version-string"] = { 'ProductName' : 'TeaSpeak-Client', 'InternalName' : 'TeaClient.exe' }; + options.electronVersion = pkg.dependencies['electron']; options.protocols = [{name: "TeaSpeak - Connect", schemes: ["teaserver"]}]; options.overwrite = true; options.derefSymlinks = true; options.buildVersion = version.toString(true); -options.asar = { - unpackDir: "teaclient-unpacked" -}; - - interface ProjectEntry { type: ProjectEntryType; } @@ -114,6 +110,24 @@ project_files.push({ } as ProjectDirectory); +if(process.argv.length < 4) { + console.error("Missing process argument:"); + console.error(" "); + process.exit(1); +} + +switch (process.argv[3]) { + case "release": + case "beta": + break; + + default: + console.error("Invalid release channel: %o", process.argv[3]); + process.exit(1); + break; + +} + if (process.argv[2] == "linux") { options.arch = "x64"; options.platform = "linux"; @@ -127,7 +141,7 @@ if (process.argv[2] == "linux") { process.exit(1); } -const path_validator = (path: string) => { +const packagePathValidator = (path: string) => { path = path.replace(/\\/g,"/"); const kIgnoreFile = true; @@ -195,7 +209,7 @@ options.ignore = path => { if(path.length == 0) return false; //Dont ignore root paths - const ignore_path = path_validator(path); + const ignore_path = packagePathValidator(path); if(!ignore_path) { console.log(" + " + path); } else { @@ -245,7 +259,6 @@ async function copy_striped(source: string, target: string, symbol_directory: st { console.log("Striping file"); - //TODO: Keep node module names! const strip_command = await exec("strip -s " + target, { maxBuffer: 1024 * 1024 * 512 }); @@ -284,15 +297,14 @@ interface UIVersion { filename?: string; } -async function create_default_ui_pack(target_directory: string) { +async function downloadBundledUiPack(channel: string, targetDirectory: string) { const remote_url = "http://clientapi.teaspeak.dev/"; - const channel = "release"; - const file = path_helper.join(target_directory, "default_ui.tar.gz"); + const file = path_helper.join(targetDirectory, "bundled-ui.tar.gz"); console.log("Creating default UI pack. Downloading from %s (channel: %s)", remote_url, channel); - await fs.ensureDir(target_directory); + await fs.ensureDir(targetDirectory); - let ui_info: UIVersion; + let bundledUiInfo: UIVersion; await new Promise((resolve, reject) => { request.get(remote_url + "api.php?" + querystring.stringify({ type: "ui-download", @@ -301,10 +313,11 @@ async function create_default_ui_pack(target_directory: string) { }), { timeout: 5000 }).on('response', function(response) { - if(response.statusCode != 200) + if(response.statusCode != 200) { reject("Failed to download UI files (Status code " + response.statusCode + ")"); + } - ui_info = { + bundledUiInfo = { channel: channel, version: response.headers["x-ui-version"] as string, git_hash: response.headers["x-ui-git-ref"] as string, @@ -317,10 +330,10 @@ async function create_default_ui_pack(target_directory: string) { }).pipe(fs.createWriteStream(file)).on('finish', resolve); }); - if(!ui_info) + if(!bundledUiInfo) throw "failed to generate ui info!"; - await fs.writeJson(path_helper.join(target_directory, "default_ui_info.json"), ui_info); + await fs.writeJson(path_helper.join(targetDirectory, "bundled-ui.json"), bundledUiInfo); console.log("UI-Pack downloaded!"); } @@ -334,20 +347,31 @@ new Promise((resolve, reject) => packager(options, (err, appPaths) => err ? reje await create_native_addons(path_helper.join(app_paths[0], "resources", "natives"), "build/symbols"); return app_paths; }).then(async app_paths => { - await create_default_ui_pack(path_helper.join(app_paths[0], "resources", "ui")); + await downloadBundledUiPack(process.argv[3], path_helper.join(app_paths[0], "resources", "ui")); return app_paths; }).then(async appPaths => { - ///native/build/linux_amd64 path = appPaths[0]; if(process.argv[2] == "linux") { await copy_striped(options.dir + "/native/build/exe/update-installer", path + "/update-installer", "build/symbols"); } else if (process.argv[2] == "win32") { await copy_striped(options.dir + "/native/build/exe/update-installer.exe", path + "/update-installer.exe", "build/symbols"); } - await fs.writeJson(path + "/app_version.json", { - version: version.toString(true), - timestamp: version.timestamp - }); + + await fs.writeJson(path + "/app-info.json", { + version: 2, + + clientVersion: { + timestamp: version.timestamp, + buildIndex: version.build, + patch: version.patch, + minor: version.minor, + major: version.major + }, + + clientChannel: process.argv[3], + uiPackChannel: process.argv[3] + } as AppInfoFile); + return appPaths; }).then(async app_path => { console.log("Fixing versions file"); diff --git a/installer/deploy/index.ts b/installer/deploy/index.ts index 4230420..1d57461 100644 --- a/installer/deploy/index.ts +++ b/installer/deploy/index.ts @@ -47,8 +47,10 @@ declare namespace node_ssh { let instance: node_ssh.Instance; export async function setup() { - if(instance) + if(instance) { throw "already initiaized"; + } + instance = new _node_ssh(); try { await instance.connect({ @@ -96,8 +98,10 @@ function version_string(version: Version) { export async function latest_version(platform: PlatformSpecs) { const path = "versions/" + platform_path(platform); - if(!instance) + if(!instance) { throw "Invalid instance"; + } + const sftp = await instance.requestSFTP(); try { if(!sftp) diff --git a/installer/package.ts b/installer/package.ts index fde7dfc..aa09547 100644 --- a/installer/package.ts +++ b/installer/package.ts @@ -128,7 +128,7 @@ export async function write_version(file: string, platform: string, arch: string await fs.writeJson(file, versions); } export async function deploy(platform: string, arch: string, channel: string, version: Version, update_file: string, install_file: string, install_suffix: string) { - await new Promise((resolve, reject) => { + await new Promise(resolve => { const url = (process.env["teaclient_deploy_url"] || "http://clientapi.teaspeak.de/") + "api.php"; console.log("Requesting " + url); console.log("Uploading update file " + update_file); diff --git a/installer/package_linux.ts b/installer/package_linux.ts index 914dafe..4a0f4b9 100644 --- a/installer/package_linux.ts +++ b/installer/package_linux.ts @@ -1,6 +1,6 @@ const installer = require("electron-installer-debian"); import * as packager from "./package"; -import {parse_version, Version} from "../modules/shared/version"; +import {parseVersion, Version} from "../modules/shared/version"; const package_path = "build/TeaClient-linux-x64/"; const filename_update = "TeaClient-linux-x64.tar.gz"; @@ -42,7 +42,7 @@ if(process.argv.length < 3) { let version: Version; const alive = setInterval(() => {}, 1000); packager.pack_info(package_path).then(package_info => { - options.options.version = (version = parse_version(package_info["version"])).toString(); + options.options.version = (version = parseVersion(package_info["version"])).toString(); options.dest = "build/output/" + process.argv[2] + "/" + options.options.version + "/"; console.log('Creating package for version ' + options.options.version + ' (this may take a while)'); diff --git a/installer/package_windows.ts b/installer/package_windows.ts index 740d64f..fdec6ec 100644 --- a/installer/package_windows.ts +++ b/installer/package_windows.ts @@ -1,7 +1,7 @@ import * as packager from "./package"; import * as deployer from "./deploy"; import * as glob from "glob"; -import {parse_version, Version} from "../modules/shared/version"; +import {parseVersion, Version} from "../modules/shared/version"; const fs = require("fs-extra"); const path = require("path"); @@ -89,7 +89,8 @@ packager.pack_info(package_path).then(async info => { return info; }).then(async _info => { info = _info; - version = parse_version(_info["version"]); + version = parseVersion(_info["version"]); + version.timestamp = Date.now(); dest_path = "build/output/" + process.argv[2] + "/" + version.toString() + "/"; await packager.pack_update(package_path, dest_path + "TeaClient-windows-x64.tar.gz"); }).then(async () => { @@ -102,17 +103,22 @@ packager.pack_info(package_path).then(async info => { console.log("Deploying PDB files"); const files = []; for(const file of await fs.readdir(symbol_binary_path)) { - if(!file.endsWith(".node")) + if(!file.endsWith(".node")) { continue; + } + let file_name = path.basename(file); - if(file_name.endsWith(".node")) + if(file_name.endsWith(".node")) { file_name = file_name.substr(0, file_name.length - 5); + } + const binary_path = path.join(symbol_binary_path, file); const pdb_path = path.join(symbol_pdb_path, file_name + ".pdb"); if(!fs.existsSync(pdb_path)) { console.warn("Missing PDB file for binary %s", file); continue; } + files.push({ binary: binary_path, pdb: pdb_path diff --git a/jenkins/create_build.sh b/jenkins/create_build.sh index d96f108..1c8e690 100755 --- a/jenkins/create_build.sh +++ b/jenkins/create_build.sh @@ -68,19 +68,19 @@ function compile_native() { eval ${_command} check_err_exit ${project_name} "Failed create build targets!" - cmake --build `pwd` --target update_installer -- ${CMAKE_MAKE_OPTIONS} + cmake --build "$(pwd)" --target update_installer -- ${CMAKE_MAKE_OPTIONS} check_err_exit ${project_name} "Failed build teaclient update installer!" - cmake --build `pwd` --target teaclient_connection -- ${CMAKE_MAKE_OPTIONS} + cmake --build "$(pwd)" --target teaclient_connection -- ${CMAKE_MAKE_OPTIONS} check_err_exit ${project_name} "Failed build teaclient connection!" - cmake --build `pwd` --target teaclient_crash_handler -- ${CMAKE_MAKE_OPTIONS} + cmake --build "$(pwd)" --target teaclient_crash_handler -- ${CMAKE_MAKE_OPTIONS} check_err_exit ${project_name} "Failed build teaclient crash handler!" - cmake --build `pwd` --target teaclient_ppt -- ${CMAKE_MAKE_OPTIONS} + cmake --build "$(pwd)" --target teaclient_ppt -- ${CMAKE_MAKE_OPTIONS} check_err_exit ${project_name} "Failed build teaclient ppt!" - cmake --build `pwd` --target teaclient_dns -- ${CMAKE_MAKE_OPTIONS} + cmake --build "$(pwd)" --target teaclient_dns -- ${CMAKE_MAKE_OPTIONS} check_err_exit ${project_name} "Failed to build teaclient dns!" end_task "${project_name}_native" "Native extensions compiled" @@ -89,10 +89,10 @@ function compile_native() { function package_client() { begin_task "${project_name}_package" "Packaging client" if [[ ${build_os_type} == "win32" ]]; then - npm run build-windows-64 + npm run build-windows-64 "${teaclient_deploy_channel}" check_err_exit ${project_name} "Failed to package client!" else - npm run build-linux-64 + npm run build-linux-64 "${teaclient_deploy_channel}" check_err_exit ${project_name} "Failed to package client!" fi end_task "${project_name}_package" "Client package created" @@ -110,10 +110,10 @@ function deploy_client() { } if [[ ${build_os_type} == "win32" ]]; then - npm run package-windows-64 ${teaclient_deploy_channel} + npm run package-windows-64 "${teaclient_deploy_channel}" check_err_exit ${project_name} "Failed to deploying client!" else - npm run package-linux-64 ${teaclient_deploy_channel} + npm run package-linux-64 "${teaclient_deploy_channel}" check_err_exit ${project_name} "Failed to deploying client!" fi end_task "${project_name}_package" "Client successfully deployed!" diff --git a/main.ts b/main.ts index ec07e59..35623df 100644 --- a/main.ts +++ b/main.ts @@ -46,8 +46,9 @@ if(process_arguments.length > 0 && process_arguments[0] === "crash-handler") { setTimeout(() => app.exit(0), 2000); } else { - if(process_arguments.length > 0 && process_arguments[0] == "--main-crash-handler") + if(process_arguments.length > 0 && process_arguments[0] == "--main-crash-handler") { crash_handler.initialize_handler("main", is_electron_run); + } /* app execute */ { diff --git a/modules/core/AppInstance.ts b/modules/core/AppInstance.ts index 995d40b..cad8ce2 100644 --- a/modules/core/AppInstance.ts +++ b/modules/core/AppInstance.ts @@ -1,8 +1,8 @@ -import {app} from "electron"; +import {app, BrowserWindow} from "electron"; import * as crash_handler from "../crash_handler"; -import * as loader from "./ui-loader/graphical"; let appReferences = 0; +let windowOpen = false; /** * Normally the app closes when all windows have been closed. @@ -17,9 +17,9 @@ export function dereferenceApp() { testAppState(); } - function testAppState() { if(appReferences > 0) { return; } + if(windowOpen) { return; } console.log("All windows have been closed, closing app."); app.quit(); @@ -29,16 +29,20 @@ function initializeAppListeners() { app.on('quit', () => { console.debug("Shutting down app."); crash_handler.finalize_handler(); - loader.ui.cleanup(); console.log("App has been finalized."); }); app.on('window-all-closed', () => { + windowOpen = false; console.log("All windows have been closed. Manual app reference count: %d", appReferences); testAppState(); }); + app.on("browser-window-created", () => { + windowOpen = true; + }) + app.on('activate', () => { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. diff --git a/modules/core/app-updater/AppInfoFile.ts b/modules/core/app-updater/AppInfoFile.ts new file mode 100644 index 0000000..2a61204 --- /dev/null +++ b/modules/core/app-updater/AppInfoFile.ts @@ -0,0 +1,21 @@ +export interface AppInfoFile { + version: 2, + + clientVersion: { + major: number, + minor: number, + patch: number, + + buildIndex: number, + + timestamp: number + }, + + /* The channel where the client has been downloaded from */ + clientChannel: string, + + /* The channel where UI - Packs should be downloaded from */ + uiPackChannel: string +} + +export default AppInfoFile; \ No newline at end of file diff --git a/modules/core/app-updater/AppInfoFile.validator.ts b/modules/core/app-updater/AppInfoFile.validator.ts new file mode 100644 index 0000000..a0233b1 --- /dev/null +++ b/modules/core/app-updater/AppInfoFile.validator.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +// generated by typescript-json-validator +import {inspect} from 'util'; +import Ajv = require('ajv'); +import AppInfoFile from './AppInfoFile'; +export const ajv = new Ajv({"allErrors":true,"coerceTypes":false,"format":"fast","nullable":true,"unicode":true,"uniqueItems":true,"useDefaults":true}); + +ajv.addMetaSchema(require('ajv/lib/refs/json-schema-draft-06.json')); + +export {AppInfoFile}; +export const AppVersionFileSchema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "defaultProperties": [ + ], + "properties": { + "clientVersion": { + "defaultProperties": [ + ], + "properties": { + "buildIndex": { + "type": "number" + }, + "major": { + "type": "number" + }, + "minor": { + "type": "number" + }, + "patch": { + "type": "number" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "buildIndex", + "major", + "minor", + "patch", + "timestamp" + ], + "type": "object" + }, + "uiPackChannel": { + "type": "string" + }, + "version": { + "enum": [ + 2 + ], + "type": "number" + } + }, + "required": [ + "clientVersion", + "uiPackChannel", + "version" + ], + "type": "object" +}; +export type ValidateFunction = ((data: unknown) => data is T) & Pick +export const isAppVersionFile = ajv.compile(AppVersionFileSchema) as ValidateFunction; +export default function validate(value: unknown): AppInfoFile { + if (isAppVersionFile(value)) { + return value; + } else { + throw new Error( + ajv.errorsText(isAppVersionFile.errors!.filter((e: any) => e.keyword !== 'if'), {dataVar: 'AppVersionFile'}) + + '\n\n' + + inspect(value), + ); + } +} diff --git a/modules/core/app-updater/UpdateConfigFile.ts b/modules/core/app-updater/UpdateConfigFile.ts new file mode 100644 index 0000000..e19a71a --- /dev/null +++ b/modules/core/app-updater/UpdateConfigFile.ts @@ -0,0 +1,6 @@ +export interface UpdateConfigFile { + version: number, + selectedChannel: string +} + +export default UpdateConfigFile; \ No newline at end of file diff --git a/modules/core/app-updater/UpdateConfigFile.validator.ts b/modules/core/app-updater/UpdateConfigFile.validator.ts new file mode 100644 index 0000000..1482f98 --- /dev/null +++ b/modules/core/app-updater/UpdateConfigFile.validator.ts @@ -0,0 +1,41 @@ +/* tslint:disable */ +// generated by typescript-json-validator +import {inspect} from 'util'; +import Ajv = require('ajv'); +import UpdateConfigFile from './UpdateConfigFile'; +export const ajv = new Ajv({"allErrors":true,"coerceTypes":false,"format":"fast","nullable":true,"unicode":true,"uniqueItems":true,"useDefaults":true}); + +ajv.addMetaSchema(require('ajv/lib/refs/json-schema-draft-06.json')); + +export {UpdateConfigFile}; +export const UpdateConfigFileSchema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "defaultProperties": [ + ], + "properties": { + "selectedChannel": { + "type": "string" + }, + "version": { + "type": "number" + } + }, + "required": [ + "selectedChannel", + "version" + ], + "type": "object" +}; +export type ValidateFunction = ((data: unknown) => data is T) & Pick +export const isUpdateConfigFile = ajv.compile(UpdateConfigFileSchema) as ValidateFunction; +export default function validate(value: unknown): UpdateConfigFile { + if (isUpdateConfigFile(value)) { + return value; + } else { + throw new Error( + ajv.errorsText(isUpdateConfigFile.errors!.filter((e: any) => e.keyword !== 'if'), {dataVar: 'UpdateConfigFile'}) + + '\n\n' + + inspect(value), + ); + } +} diff --git a/modules/core/app-updater/changelog/index.ts b/modules/core/app-updater/changelog/index.ts index 8b41f17..4f186d8 100644 --- a/modules/core/app-updater/changelog/index.ts +++ b/modules/core/app-updater/changelog/index.ts @@ -1,43 +1,50 @@ -import {BrowserWindow} from "electron"; +import {BrowserWindow, dialog} from "electron"; import * as electron from "electron"; import * as path from "path"; import * as url from "url"; -let changelog_window: BrowserWindow; -export function open() { - if(changelog_window) { - changelog_window.focus(); +let changeLogWindow: BrowserWindow; +export function openChangeLog() { + if(changeLogWindow) { + changeLogWindow.focus(); return; } - changelog_window = new BrowserWindow({ + changeLogWindow = new BrowserWindow({ show: false }); - changelog_window.setMenu(null); + changeLogWindow.setMenu(null); - let file = ""; + let file; { - const app_path = electron.app.getAppPath(); - if(app_path.endsWith(".asar")) - file = path.join(path.dirname(app_path), "..", "ChangeLog.txt"); - else - file = path.join(app_path, "github", "ChangeLog.txt"); /* We've the source master :D */ + const appPath = electron.app.getAppPath(); + if(appPath.endsWith(".asar")) { + file = path.join(path.dirname(appPath), "..", "ChangeLog.txt"); + } else { + file = path.join(appPath, "github", "ChangeLog.txt"); /* We've the source ;) */ + } } - changelog_window.loadURL(url.pathToFileURL(file).toString()); - changelog_window.setTitle("TeaClient ChangeLog"); - changelog_window.on('ready-to-show', () => { - changelog_window.show(); + changeLogWindow.loadURL(url.pathToFileURL(file).toString()).catch(error => { + console.error("Failed to open changelog: %o", error); + dialog.showErrorBox("Failed to open the ChangeLog", "Failed to open the changelog file.\nLookup the console for more details."); + closeChangeLog(); }); - changelog_window.on('close', () => { - changelog_window = undefined; + + changeLogWindow.setTitle("TeaClient ChangeLog"); + changeLogWindow.on('ready-to-show', () => { + changeLogWindow.show(); + }); + + changeLogWindow.on('close', () => { + changeLogWindow = undefined; }); } -export function close() { - if(changelog_window) { - changelog_window.close(); - changelog_window = undefined; +export function closeChangeLog() { + if(changeLogWindow) { + changeLogWindow.close(); + changeLogWindow = undefined; } } \ No newline at end of file diff --git a/modules/core/app-updater/index.ts b/modules/core/app-updater/index.ts index 6ed0e33..3fcbf85 100644 --- a/modules/core/app-updater/index.ts +++ b/modules/core/app-updater/index.ts @@ -1,6 +1,6 @@ import * as querystring from "querystring"; import * as request from "request"; -import {app, dialog, ipcMain} from "electron"; +import {app, dialog} from "electron"; import * as fs from "fs-extra"; import * as ofs from "original-fs"; import * as os from "os"; @@ -11,23 +11,26 @@ 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 {parseVersion, Version} from "../../shared/version"; -import Timer = NodeJS.Timer; 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 * as url from "url"; -import {loadWindowBounds, startTrackWindowBounds} from "../../shared/window"; -import {referenceApp} from "../AppInstance"; +import { default as validateUpdateConfig } from "./UpdateConfigFile.validator"; +import { default as validateAppInfo } from "./AppInfoFile.validator"; +import UpdateConfigFile from "./UpdateConfigFile"; +import AppInfoFile from "./AppInfoFile"; -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 processArguments.has_value(...Arguments.SERVER_URL) ? processArguments.value(...Arguments.SERVER_URL) : default_path; +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 { @@ -42,35 +45,42 @@ export interface UpdateData { 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); +let remoteVersionCacheTimestamp: number; +let remoteVersionCache: Promise; +export async function fetchRemoteUpdateData() : Promise { + if(remoteVersionCache && remoteVersionCacheTimestamp > Date.now() - 60 * 60 * 1000) { + return remoteVersionCache; + } - return new Promise((resolve, reject) => { - const request_url = server_url() + "/api.php?" + querystring.stringify({ + /* TODO: Validate remote response schema */ + remoteVersionCacheTimestamp = Date.now(); + return (remoteVersionCache = new Promise((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 || 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 + ")"); + if(response.statusCode !== 200) { + setImmediate(reject, "Invalid status code (" + response.statusCode + (response.statusMessage ? "/" + response.statusMessage : "") + ")"); return; } - const data = JSON.parse(body); - if(!data) { - setImmediate(reject, "Invalid response"); + 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"] + ")"); + setImmediate(reject, "Action failed (" + (data["msg"] || "unknown error") + ")"); return; } @@ -85,105 +95,57 @@ export async function load_data(allow_cached: boolean = true) : Promise { + /* Don't cache errors */ + remoteVersionCache = undefined; + remoteVersionCacheTimestamp = undefined; + return Promise.reject(error); }); } -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; +export async function availableRemoteChannels() : Promise { + const versions = (await fetchRemoteUpdateData()).versions.map(e => e.channel); + + versions.push("beta"); + return [...new Set(versions)]; +} + +export async function newestRemoteClientVersion(channel: string) : Promise { + const data = await fetchRemoteUpdateData(); + + let currentVersion: UpdateVersion; 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(version.channel == channel) { + if(!currentVersion || version.version.newerThan(currentVersion.version)) { + currentVersion = version; + } } } } - if(!had_data) - throw "Missing data"; - return undefined; + return currentVersion; } -/** - * @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 { +function getAppDataDirectory() : string { return electron.app.getPath('userData'); } -function get_update_file(channel: string, version: Version) : string { - let _path = fs.realpathSync(data_directory()); +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(_path, "app_versions", name); + return path.join(directory, "app_versions", name); } export interface ProgressState { @@ -199,73 +161,86 @@ export interface ProgressState { } } -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)) { +export async function downloadClientVersion(channel: string, version: Version, status: (state: ProgressState) => any, callbackLog: UpdateLogCallback) : Promise { + 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(target_path); + await fs.remove(targetFilePath); } catch(error) { throw "Failed to remove old file: " + error; } } try { - await fs.mkdirp(path.dirname(target_path)); + await fs.mkdirp(path.dirname(targetFilePath)); } catch(error) { - throw "Failed to make target directory: " + path.dirname(target_path); + throw "Failed to make target directory: " + path.dirname(targetFilePath); } - const url = server_url() + "/api.php?" + querystring.stringify({ + const requestUrl = updateServerUrl() + "/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); + + callbackLog("info", "Downloading version " + version.toString(false) + " to " + targetFilePath + " from " + updateServerUrl()); + console.log("Downloading update from %s. (%s)", updateServerUrl(), requestUrl); + 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 + ")"); + 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; } - })).on('progress', _state => status ? status(_state) : {}).on('error', error => { + + 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) + + 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, { + stream.pipe(fs.createWriteStream(targetFilePath, { autoClose: true })).on('finish', () => { console.log("Write stream has finished. Download successfully."); - if(!fired && (fired = true)) - setImmediate(resolve, target_path); + if(!fired && (fired = true)) { + setImmediate(resolve, targetFilePath); + } }).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"); + fireFailed("disk write error"); }); }); } @@ -278,13 +253,11 @@ if(typeof(String.prototype.trim) === "undefined") }; } -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 */ - +export async function ensureTargetFilesAreWriteable(updateFile: string) : Promise { const original_fs = require('original-fs'); - if(!fs.existsSync(update_file)) - throw "Missing update file (" + update_file + ")"; + if(!fs.existsSync(updateFile)) { + throw "Missing update file (" + updateFile + ")"; + } let parent_path = app.getAppPath(); if(parent_path.endsWith(".asar")) { @@ -296,11 +269,11 @@ export async function test_file_accessibility(update_file: string) : Promise(resolve => original_fs.access(file, mode, resolve)); }; - let code = await test_access(update_file, original_fs.constants.R_OK); + let code = await test_access(updateFile, original_fs.constants.R_OK); if(code) - throw "Failed test read for update file. (" + update_file + " results in " + code.code + ")"; + throw "Failed test read for update file. (" + updateFile + " results in " + code.code + ")"; - const fstream = original_fs.createReadStream(update_file); + const fstream = original_fs.createReadStream(updateFile); const tar_stream = tar.extract(); const errors: string[] = []; @@ -378,8 +351,8 @@ namespace install_config { } } -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); +async function createUpdateInstallConfig(sourceRoot: string, targetRoot: string) : Promise { + 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; @@ -387,7 +360,7 @@ async function build_install_config(source_root: string, target_root: string) : result.backup = true; { - const data = path.parse(source_root); + const data = path.parse(sourceRoot); result["backup-directory"] = path.join(data.dir, data.name + "_backup"); } @@ -404,13 +377,13 @@ async function build_install_config(source_root: string, target_root: string) : } ]; - const ignore_list = [ + const ignoreFileList = [ "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); + 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 { @@ -421,16 +394,17 @@ async function build_install_config(source_root: string, target_root: string) : } for(const file of files) { - let _exclude = false; - for(const exclude of ignore_list) { - if(exclude == file) { + let shouldBeExcluded = false; + for(const ignoredFile of ignoreFileList) { + if(ignoredFile == file) { console.debug("Ignoring file to update (%s/%s)", relative_path, file); - _exclude = true; + shouldBeExcluded = true; break; } } - if(_exclude) + if(shouldBeExcluded) { continue; + } const source_file = path.join(source_directory, file); const target_file = path.join(target_directory, file); @@ -439,7 +413,7 @@ async function build_install_config(source_root: string, target_root: string) : const info = await util.promisify(ofs.stat)(source_file); if(info.isDirectory()) { - await dir_walker(path.join(relative_path, file)); + await dirWalker(path.join(relative_path, file)); } else { /* TODO: ensure its a file! */ result.moves.push({ @@ -451,131 +425,297 @@ async function build_install_config(source_root: string, target_root: string) : } }; - await dir_walker("."); + await dirWalker("."); 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); +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)); - console.log("Located target app path: %s", application_path); - console.log("Using update file: %s", update_file); + 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"; + } - 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"; - } + callbackLog("info", "Extracting update to " + temporaryDirectory); + console.log("Extracting update file %s to %s", updateFile, temporaryDirectory); - const source = fs.createReadStream(update_file); - const extract = tar.extract(); + let updateInstallerPath = undefined; - 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); + const updateFileStream = fs.createReadStream(updateFile); + const extract = tar.extract(); - 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); - } + 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(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(); - }); + + 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(); }); + }); - source.pipe(extract); + 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 { + 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 { - 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"; + 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(typeof(updater_executable) !== "string" || !(await fs.pathExists(updater_executable))) - throw "missing update installer executable within update package"; + if(!cachedUpdateConfig) { + cachedUpdateConfig = { + version: 1, + selectedChannel: "release" + } + } +} - /* the "new" environment should now be available at 'temp_directory' */ - console.log("Update unpacked successfully. Building update extractor file."); +export function updateConfig() { + if(typeof cachedUpdateConfig === "string") { + throw "app updater hasn't been initialized yet"; + } + return cachedUpdateConfig; +} - let install_config; +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 { + 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 { - install_config = await build_install_config(temp_directory, application_path); + 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 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); + 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(config_file, install_config); + 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 - const rest_callback = () => { - console.log("Executing command %s with args %o", updater_executable, [log_file, config_file]); + executeCallback = () => { + console.log("Executing command %s with args %o", updateInstallerExecutable, [installLogFile, installConfigFile]); try { - let result = child_process.spawnSync(updater_executable, [log_file, config_file]); + 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({ @@ -620,16 +760,14 @@ export async function execute_update(update_file: string, restart_callback: (cal 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]); + executeCallback = () => { + console.log("Executing command %s with args %o", updateInstallerExecutable, [installLogFile, installConfigFile]); try { - const pipe = child_process.spawn(updater_executable, [log_file, config_file], { + const pipe = child_process.spawn(updateInstallerExecutable, [installLogFile, installConfigFile], { detached: true, shell: true, cwd: path.dirname(app.getAppPath()), @@ -642,236 +780,15 @@ export async function execute_update(update_file: string, restart_callback: (cal 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(processArguments.has_value(Arguments.UPDATER_LOCAL_VERSION)) - return parse_version(processArguments.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 = processArguments.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.loadURL(url.pathToFileURL(path.join(path.dirname(module.filename), "ui", "index.html")).toString()); - if(ui_debug) { - window.webContents.openDevTools(); - } - await new Promise(resolve => window.on('ready-to-show', resolve)); - await loadWindowBounds('update-installer', window); - startTrackWindowBounds('update-installer', window); - - window.show(); - - 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(processArguments.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; + callbackStats("Update successfully prepared", 1); + callbackLog("info", "Update successfully prepared"); + + return { + callbackExecute: executeCallback, + callbackAbort: () => { + /* TODO: Cleanup */ } } - - 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 => { - referenceApp(); /* we'll never delete this reference, but we'll call app.quit() manually */ - 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 processArguments.has_value(Arguments.UPDATER_CHANNEL) ? processArguments.value(Arguments.UPDATER_CHANNEL) : "release"; } \ No newline at end of file diff --git a/modules/core/main-window/index.ts b/modules/core/main-window/index.ts index 6948bff..103bd27 100644 --- a/modules/core/main-window/index.ts +++ b/modules/core/main-window/index.ts @@ -1,134 +1,38 @@ -import {BrowserWindow, app, dialog} from "electron"; +import {BrowserWindow, app, dialog, MessageBoxOptions} from "electron"; import * as path from "path"; export let is_debug: boolean; export let allow_dev_tools: boolean; import {Arguments, processArguments} from "../../shared/process-arguments"; -import * as updater from "./../app-updater"; -import * as loader from "./../ui-loader"; import * as url from "url"; import {loadWindowBounds, startTrackWindowBounds} from "../../shared/window"; import {referenceApp, dereferenceApp} from "../AppInstance"; import {closeURLPreview, openURLPreview} from "../url-preview"; +import { + getLoaderWindow, + hideAppLoaderWindow, + setAppLoaderStatus, + showAppLoaderWindow +} from "../windows/app-loader/controller/AppLoader"; +import {loadUiPack} from "../ui-loader/Loader"; +import {loadLocalUiCache} from "../ui-loader/Cache"; +import {showMainWindow} from "../windows/main-window/controller/MainWindow"; +import {showUpdateWindow} from "../windows/client-updater/controller/ClientUpdate"; +import { + clientUpdateChannel, + currentClientVersion, + availableClientUpdate, + setClientUpdateChannel, + initializeAppUpdater +} from "../app-updater"; +import * as app_updater from "../app-updater"; // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. export let mainWindow: BrowserWindow = null; -function spawnMainWindow(rendererEntryPoint: string) { - app.on('certificate-error', (event, webContents, url, error, certificate, callback) => { - console.log("Allowing untrusted certificate for %o", url); - event.preventDefault(); - callback(true); - }); - - // Create the browser window. - console.log("Spawning main window"); - - referenceApp(); /* main browser window references the app */ - mainWindow = new BrowserWindow({ - width: 800, - height: 600, - - minHeight: 600, - minWidth: 600, - - show: false, - webPreferences: { - webSecurity: false, - nodeIntegrationInWorker: true, - nodeIntegration: true, - preload: path.join(__dirname, "preload.js") - }, - icon: path.join(__dirname, "..", "..", "resources", "logo.ico"), - }); - - mainWindow.webContents.on('devtools-closed', () => { - console.log("Dev tools destroyed!"); - }); - - mainWindow.on('closed', () => { - app.releaseSingleInstanceLock(); - closeURLPreview().then(undefined); - mainWindow = null; - - dereferenceApp(); - }); - - mainWindow.loadURL(url.pathToFileURL(loader.ui.preloading_page(rendererEntryPoint)).toString()).catch(error => { - console.error("Failed to load UI entry point: %o", error); - handleUILoadingError("UI entry point failed to load"); - }); - - mainWindow.once('ready-to-show', () => { - mainWindow.show(); - loadWindowBounds('main-window', mainWindow).then(() => { - startTrackWindowBounds('main-window', mainWindow); - - mainWindow.focus(); - loader.ui.cleanup(); - if(allow_dev_tools && !mainWindow.webContents.isDevToolsOpened()) - mainWindow.webContents.openDevTools(); - }); - }); - - mainWindow.webContents.on('new-window', (event, url_str, frameName, disposition, options, additionalFeatures) => { - if(frameName.startsWith("__modal_external__")) { - return; - } - - event.preventDefault(); - try { - let url: URL; - try { - url = new URL(url_str); - } catch(error) { - throw "failed to parse URL"; - } - - { - let protocol = url.protocol.endsWith(":") ? url.protocol.substring(0, url.protocol.length - 1) : url.protocol; - if(protocol !== "https" && protocol !== "http") { - throw "invalid protocol (" + protocol + "). HTTP(S) are only supported!"; - } - } - - console.log("Got new window " + frameName); - openURLPreview(url_str).then(() => {}); - } catch(error) { - console.error("Failed to open preview window for URL %s: %o", url_str, error); - dialog.showErrorBox("Failed to open preview", "Failed to open preview URL: " + url_str + "\nError: " + error); - } - }); - - mainWindow.webContents.on('crashed', () => { - console.error("UI thread crashed! Closing app!"); - if(!processArguments.has_flag(Arguments.DEBUG)) { - mainWindow.close(); - } - }); -} - -function handleUILoadingError(message: string) { - referenceApp(); - - console.log("Caught loading error: %s", message); - if(mainWindow) { - mainWindow.close(); - mainWindow = undefined; - } - - dialog.showMessageBox({ - type: "error", - buttons: ["exit"], - title: "A critical error happened while loading TeaClient!", - message: (message || "no error").toString() - }).then(dereferenceApp); - loader.ui.cancel(); -} - -export function execute() { +export async function execute() { console.log("Main app executed!"); is_debug = processArguments.has_flag(...Arguments.DEBUG); @@ -138,35 +42,84 @@ export function execute() { console.log("Arguments: %o", processArguments); } + setAppLoaderStatus("Bootstrapping", 0); + await showAppLoaderWindow(); + await initializeAppUpdater(); + + /* TODO: Remove this (currently required somewhere within the renderer) */ + const version = await app_updater.currentClientVersion(); + global["app_version_client"] = version.toString(); + + /* FIXME! */ + await showUpdateWindow(); + return; + + setAppLoaderStatus("Checking for updates", .1); + try { + if(processArguments.has_value(Arguments.UPDATER_CHANNEL)) { + setClientUpdateChannel(processArguments.value(Arguments.UPDATER_CHANNEL)); + } + + const newVersion = await availableClientUpdate(); + if(newVersion) { + setAppLoaderStatus("Awaiting update", .15); + + const result = await dialog.showMessageBox(getLoaderWindow(), { + buttons: ["Update now", "No thanks"], + title: "Update available!", + message: + "There is an update available!\n" + + "Should we update now?\n" + + "\n" + + "Current version: " + (await currentClientVersion()).toString() + "\n" + + "Target version: " + newVersion.version.toString(true) + } as MessageBoxOptions); + + if(result.response === 0) { + /* TODO: Execute update! */ + await showUpdateWindow(); + hideAppLoaderWindow(); + return; + } + } + } catch (error) { + console.warn("Failed to check for a client update: %o", error); + } + setAppLoaderStatus("Initialize backend", .2); + console.log("Setting up render backend"); require("../render-backend"); - console.log("Spawn loading screen"); - loader.ui.execute_loader().then(async (entry_point: string) => { - /* test if the updater may have an update found */ - let awaiting_update_set = false; - while(updater.update_question_open) { - if(!awaiting_update_set) { - awaiting_update_set = true; - loader.ui.show_await_update(); - console.log("Awaiting update stuff to be finished"); - } - await new Promise(resolve => setTimeout(resolve, 100)); + let uiEntryPoint; + try { + setAppLoaderStatus("Loading ui cache", .25); + await loadLocalUiCache(); + uiEntryPoint = await loadUiPack((message, index) => { + setAppLoaderStatus(message, index * .75 + .25); + }); + } catch (error) { + hideAppLoaderWindow(); + console.error("Failed to load ui: %o", error); + + if(mainWindow) { + mainWindow.close(); + mainWindow = undefined; } - if(updater.update_restart_pending) - return undefined; + await dialog.showMessageBox({ + type: "error", + buttons: ["exit"], + title: "A critical error happened while loading TeaClient!", + message: (error || "no error").toString() + }); + return; + } - return entry_point; - }).then((entry_point: string) => { - referenceApp(); /* because we've no windows when we close the loader UI */ - loader.ui.cleanup(); /* close the window */ + if(!uiEntryPoint) { + throw "missing ui entry point"; + } - if(entry_point) //has not been canceled - spawnMainWindow(entry_point); - else { - handleUILoadingError("Missing UI entry point"); - } - dereferenceApp(); - }).catch(handleUILoadingError); + setAppLoaderStatus("Starting client", 100); + await showMainWindow(uiEntryPoint); + hideAppLoaderWindow(); } diff --git a/modules/core/main.ts b/modules/core/main.ts index 55be823..3c1c4fc 100644 --- a/modules/core/main.ts +++ b/modules/core/main.ts @@ -1,26 +1,23 @@ import * as electron from "electron"; -import * as app_updater from "./app-updater"; import {app, Menu} from "electron"; import MessageBoxOptions = electron.MessageBoxOptions; import {processArguments, parseProcessArguments, Arguments} from "../shared/process-arguments"; -import {open as open_changelog} from "./app-updater/changelog"; +import {openChangeLog as openChangeLog} from "./app-updater/changelog"; import * as crash_handler from "../crash_handler"; import {initializeSingleInstance} from "./MultiInstanceHandler"; import "./AppInstance"; +import {dereferenceApp, referenceApp} from "./AppInstance"; +import {showUpdateWindow} from "./windows/client-updater/controller/ClientUpdate"; async function handleAppReady() { Menu.setApplicationMenu(null); if(processArguments.has_value("update-execute")) { - console.log("Executing update " + processArguments.value("update-execute")); - await app_updater.execute_update(processArguments.value("update-execute"), callback => { - console.log("Update preconfig successful. Extracting update. (The client should start automatically)"); - app.quit(); - setImmediate(callback); - }); + console.log("Showing update window"); + await showUpdateWindow(); return; } else if(processArguments.has_value("update-failed-new") || processArguments.has_value("update-succeed-new")) { const success = processArguments.has_value("update-succeed-new"); @@ -58,7 +55,7 @@ async function handleAppReady() { })[] = []; if(success) { - open_changelog(); + openChangeLog(); type = "info"; title = "Update succeeded!"; @@ -99,7 +96,7 @@ async function handleAppReady() { buttons.push({ key: "Retry update", callback: async () => { - await app_updater.execute_graphical(await app_updater.selected_channel(), false); + await showUpdateWindow(); return true; } }); @@ -138,13 +135,11 @@ async function handleAppReady() { } try { - const version = await app_updater.current_version(); - global["app_version_client"] = version.toString(); - const main = require("./main-window"); - main.execute(); - app_updater.start_auto_update_check(); + referenceApp(); + await main.execute(); + dereferenceApp(); } catch (error) { console.error(error); await electron.dialog.showMessageBox({ diff --git a/modules/core/render-backend/index.ts b/modules/core/render-backend/index.ts index 7f2b67c..dff7895 100644 --- a/modules/core/render-backend/index.ts +++ b/modules/core/render-backend/index.ts @@ -4,12 +4,13 @@ import * as electron from "electron"; import ipcMain = electron.ipcMain; import BrowserWindow = electron.BrowserWindow; -import {open as open_changelog} from "../app-updater/changelog"; +import {openChangeLog as open_changelog} from "../app-updater/changelog"; import * as updater from "../app-updater"; import {execute_connect_urls} from "../MultiInstanceHandler"; import {processArguments} from "../../shared/process-arguments"; import "./ExternalModal"; +import {showUpdateWindow} from "../windows/client-updater/controller/ClientUpdate"; ipcMain.on('basic-action', (event, action, ...args: any[]) => { const window = BrowserWindow.fromWebContents(event.sender); @@ -19,7 +20,7 @@ ipcMain.on('basic-action', (event, action, ...args: any[]) => { } else if(action === "open-changelog") { open_changelog(); } else if(action === "check-native-update") { - updater.selected_channel().then(channel => updater.execute_graphical(channel, true)); + showUpdateWindow().then(undefined); } else if(action === "open-dev-tools") { window.webContents.openDevTools(); } else if(action === "reload-window") { diff --git a/modules/core/ui-loader/Cache.ts b/modules/core/ui-loader/Cache.ts new file mode 100644 index 0000000..37c6442 --- /dev/null +++ b/modules/core/ui-loader/Cache.ts @@ -0,0 +1,97 @@ +import * as path from "path"; +import * as fs from "fs-extra"; +import * as electron from "electron"; +import validateCacheFile from "./CacheFile.validator"; + +import CacheFile, {UIPackInfo} from "./CacheFile"; + +let localUiCacheInstance: CacheFile; +async function doLoad() { + const file = path.join(uiCachePath(), "data.json"); + + if(!(await fs.pathExists(file))) { + console.debug("Missing UI cache file. Creating a new one."); + /* we've no cache */ + return; + } + + const anyData = await fs.readJSON(file); + + try { + if(anyData["version"] !== 3) { + throw "unsupported version " + anyData["version"]; + } + + localUiCacheInstance = validateCacheFile(anyData); + } catch (error) { + if(error?.message?.startsWith("CacheFile")) { + /* We have no need to fully print the read data */ + error = "\n- " + error.message.split("\n")[0].split(", ").join("\n- "); + } else if(error?.message) { + error = error.message; + } else if(typeof error !== "string") { + console.error(error); + } + console.warn("Current Ui cache file seems to be invalid. Renaming it and creating a new one: %s", error); + + try { + await fs.rename(file, path.join(uiCachePath(), "data.json." + Date.now())); + } catch (error) { + console.warn("Failed to invalidate old ui cache file: %o", error); + } + } +} + +/** + * Will not throw or return undefined! + */ +export async function loadLocalUiCache() { + if(localUiCacheInstance) { + throw "ui cache has already been loaded"; + } + + try { + await doLoad(); + } catch (error) { + console.warn("Failed to load UI cache file: %o. This will cause loss of the file content.", error); + } + + if(!localUiCacheInstance) { + localUiCacheInstance = { + version: 3, + cachedPacks: [] + } + } +} + +export function localUiCache() : CacheFile { + if(typeof localUiCacheInstance !== "object") { + throw "missing local ui cache"; + } + + return localUiCacheInstance; +} + +/** + * Will not throw anything + */ +export async function saveLocalUiCache() { + const file = path.join(uiCachePath(), "data.json"); + try { + if(!(await fs.pathExists(path.dirname(file)))) { + await fs.mkdirs(path.dirname(file)); + } + + await fs.writeJson(file, localUiCacheInstance); + } catch (error) { + console.error("Failed to save UI cache file. This will may cause some data loss: %o", error); + } +} + +export function uiCachePath() { + return path.join(electron.app.getPath('userData'), "cache", "ui"); +} + +export function uiPackCachePath(version: UIPackInfo) : string { + return path.join(uiCachePath(), version.channel + "_" + version.versions_hash + "_" + version.timestamp + ".tar.gz"); +} \ No newline at end of file diff --git a/modules/core/ui-loader/CacheFile.ts b/modules/core/ui-loader/CacheFile.ts new file mode 100644 index 0000000..13fd400 --- /dev/null +++ b/modules/core/ui-loader/CacheFile.ts @@ -0,0 +1,35 @@ +export interface CacheFile { + version: number; /* currently 2 */ + + cachedPacks: CachedUIPack[]; +} + +export interface UIPackInfo { + timestamp: number; /* build timestamp */ + version: string; /* not really used anymore */ + versions_hash: string; /* used, identifies the version. Its the git hash. */ + + channel: string; + requiredClientVersion: string; /* minimum version from the client required for the pack */ +} + +export interface CachedUIPack { + downloadTimestamp: number; + + localFilePath: string; + localChecksum: string | "none"; /* sha512 of the locally downloaded file. */ + //TODO: Get the remote checksum and compare them instead of the local one + + packInfo: UIPackInfo; + + status: { + type: "valid" + } | { + type: "invalid", + + timestamp: number, + reason: string + } +} + +export default CacheFile; \ No newline at end of file diff --git a/modules/core/ui-loader/CacheFile.validator.ts b/modules/core/ui-loader/CacheFile.validator.ts new file mode 100644 index 0000000..1d92880 --- /dev/null +++ b/modules/core/ui-loader/CacheFile.validator.ts @@ -0,0 +1,145 @@ +/* tslint:disable */ +// generated by typescript-json-validator +import {inspect} from 'util'; +import Ajv = require('ajv'); +import CacheFile from './CacheFile'; +export const ajv = new Ajv({"allErrors":true,"coerceTypes":false,"format":"fast","nullable":true,"unicode":true,"uniqueItems":true,"useDefaults":true}); + +ajv.addMetaSchema(require('ajv/lib/refs/json-schema-draft-06.json')); + +export {CacheFile}; +export const CacheFileSchema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "defaultProperties": [ + ], + "definitions": { + "CachedUIPack": { + "defaultProperties": [ + ], + "properties": { + "downloadTimestamp": { + "type": "number" + }, + "localChecksum": { + "type": "string" + }, + "localFilePath": { + "type": "string" + }, + "packInfo": { + "$ref": "#/definitions/UIPackInfo" + }, + "status": { + "anyOf": [ + { + "defaultProperties": [ + ], + "properties": { + "type": { + "enum": [ + "valid" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "defaultProperties": [ + ], + "properties": { + "reason": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "type": { + "enum": [ + "invalid" + ], + "type": "string" + } + }, + "required": [ + "reason", + "timestamp", + "type" + ], + "type": "object" + } + ] + } + }, + "required": [ + "downloadTimestamp", + "localChecksum", + "localFilePath", + "packInfo", + "status" + ], + "type": "object" + }, + "UIPackInfo": { + "defaultProperties": [ + ], + "properties": { + "channel": { + "type": "string" + }, + "requiredClientVersion": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "version": { + "type": "string" + }, + "versions_hash": { + "type": "string" + } + }, + "required": [ + "channel", + "requiredClientVersion", + "timestamp", + "version", + "versions_hash" + ], + "type": "object" + } + }, + "properties": { + "cachedPacks": { + "items": { + "$ref": "#/definitions/CachedUIPack" + }, + "type": "array" + }, + "version": { + "type": "number" + } + }, + "required": [ + "cachedPacks", + "version" + ], + "type": "object" +}; +export type ValidateFunction = ((data: unknown) => data is T) & Pick +export const isCacheFile = ajv.compile(CacheFileSchema) as ValidateFunction; +export default function validate(value: unknown): CacheFile { + if (isCacheFile(value)) { + return value; + } else { + throw new Error( + ajv.errorsText(isCacheFile.errors!.filter((e: any) => e.keyword !== 'if'), {dataVar: 'CacheFile'}) + + '\n\n' + + inspect(value), + ); + } +} diff --git a/modules/core/ui-loader/Loader.ts b/modules/core/ui-loader/Loader.ts new file mode 100644 index 0000000..6d82c5f --- /dev/null +++ b/modules/core/ui-loader/Loader.ts @@ -0,0 +1,322 @@ +import {is_debug} from "../main-window"; +import * as moment from "moment"; +import * as fs from "fs-extra"; +import * as os from "os"; + +import * as path from "path"; +import * as zlib from "zlib"; +import * as tar from "tar-stream"; +import {Arguments, processArguments} from "../../shared/process-arguments"; +import {parseVersion} from "../../shared/version"; + +import * as electron from "electron"; +import MessageBoxOptions = Electron.MessageBoxOptions; +import {clientAppInfo, currentClientVersion, executeGraphicalClientUpdate} from "../app-updater"; +import {CachedUIPack, UIPackInfo} from "./CacheFile"; +import {localUiCache, saveLocalUiCache} from "./Cache"; +import {shippedClientUi} from "./Shipped"; +import {downloadUiPack, queryRemoteUiPacks} from "./Remote"; +import * as url from "url"; + +export const remoteUiUrl = () => { + const default_path = is_debug ? "http://localhost/home/TeaSpeak/Web-Client/client-api/environment/" : "https://clientapi.teaspeak.de/"; + return processArguments.has_value(...Arguments.SERVER_URL) ? processArguments.value(...Arguments.SERVER_URL) : default_path; +}; + +let temporaryDirectoryPromise: Promise; +function generateTemporaryDirectory() : Promise { + if(temporaryDirectoryPromise) { + return temporaryDirectoryPromise; + } + + return (temporaryDirectoryPromise = fs.mkdtemp(path.join(os.tmpdir(), "TeaClient-")).then(path => { + process.on('exit', () => { + try { + if(fs.pathExistsSync(path)) { + fs.removeSync(path); + } + } catch (e) { + console.warn("Failed to delete temp directory: %o", e); + } + }); + + global["browser-root"] = path; + console.log("Local browser path: %s", path); + return path; + })); +} + +async function unpackLocalUiPack(version: CachedUIPack) : Promise { + const targetDirectory = await generateTemporaryDirectory(); + if(!await fs.pathExists(targetDirectory)) { + throw "failed to create temporary directory"; + } + + const gunzip = zlib.createGunzip(); + const extract = tar.extract(); + let fpipe: fs.ReadStream; + + try { + fpipe = fs.createReadStream(version.localFilePath); + } catch (error) { + console.error("Failed to open UI pack at %s: %o", version.localFilePath, error); + throw "failed to open UI pack"; + } + + extract.on('entry', function(header: tar.Headers, stream, next) { + if(header.type == 'file') { + const targetFile = path.join(targetDirectory, header.name); + if(!fs.existsSync(path.dirname(targetFile))) { + fs.mkdirsSync(path.dirname(targetFile)); + } + + stream.on('end', () => setImmediate(next)); + const wfpipe = fs.createWriteStream(targetFile); + stream.pipe(wfpipe); + } else if(header.type == 'directory') { + if(fs.existsSync(path.join(targetDirectory, header.name))) { + setImmediate(next); + } + + fs.mkdirs(path.join(targetDirectory, header.name)).catch(error => { + console.warn("Failed to create unpacking dir " + path.join(targetDirectory, header.name)); + console.error(error); + }).then(() => setImmediate(next)); + } else { + console.warn("Invalid ui tar ball entry type (" + header.type + ")"); + return; + } + }); + + const finishPromise = new Promise((resolve, reject) => { + gunzip.on('error', event => { + reject(event); + }); + + extract.on('finish', resolve); + extract.on('error', event => { + if(!event) return; + reject(event); + }); + + fpipe.pipe(gunzip).pipe(extract); + }); + + try { + await finishPromise; + } catch(error) { + console.error("Failed to extract UI files to %s: %o", targetDirectory, error); + throw "failed to unpack the UI pack"; + } + + return targetDirectory; +} + +async function streamFilesFromDevServer(_channel: string, _callbackStatus: (message: string, index: number) => any) : Promise { + return remoteUiUrl() + "index.html"; +} + +async function loadBundledUiPack(channel: string, callbackStatus: (message: string, index: number) => any) : Promise { + callbackStatus("Query local UI pack info", .33); + + const bundledUi = await shippedClientUi(); + if(!bundledUi) { + throw "client has no bundled UI pack"; + } + + callbackStatus("Unpacking bundled UI", .66); + const result = await unpackLocalUiPack(bundledUi); + + callbackStatus("Local UI pack loaded", 1); + console.log("Loaded bundles UI pack successfully. Version: {timestamp: %d, hash: %s}", bundledUi.packInfo.timestamp, bundledUi.packInfo.versions_hash); + return url.pathToFileURL(path.join(result, "index.html")).toString(); +} + +async function loadCachedOrRemoteUiPack(channel: string, callbackStatus: (message: string, index: number) => any, ignoreNewVersionTimestamp: boolean) : Promise { + callbackStatus("Fetching info", 0); + + const bundledUi = await shippedClientUi(); + const clientVersion = await currentClientVersion(); + + let availableCachedVersions: CachedUIPack[] = localUiCache().cachedPacks.filter(e => { + if(e.status.type !== "valid") { + return false; + } + + if(bundledUi) { + /* remove all cached ui packs which are older than our bundled one */ + if(e.packInfo.timestamp <= bundledUi.downloadTimestamp) { + return false; + } + } + + if(e.packInfo.channel !== channel) { + /* ui-pack is for another channel */ + return false; + } + + const requiredVersion = parseVersion(e.packInfo.requiredClientVersion); + return clientVersion.isDevelopmentVersion() || clientVersion.newerThan(requiredVersion) || clientVersion.equals(requiredVersion); + }); + + if(processArguments.has_flag(Arguments.UPDATER_UI_NO_CACHE)) { + console.log("Ignoring local UI cache"); + availableCachedVersions = []; + } + + let remoteVersionDropped = false; + + /* fetch the remote versions */ + executeRemoteLoader: { + callbackStatus("Loading remote info", .25); + + let remoteVersions: UIPackInfo[]; + try { + remoteVersions = await queryRemoteUiPacks(); + } catch (error) { + console.error("Failed to query remote UI packs: %o", error); + break executeRemoteLoader; + } + + callbackStatus("Parsing remote UI packs", .40); + const remoteVersion = remoteVersions.find(e => e.channel === channel); + if(!remoteVersion) { + console.info("Remote server has no ui packs for channel %o.", channel); + break executeRemoteLoader; + } + + let newestLocalVersion = availableCachedVersions.map(e => e.packInfo.timestamp) + .reduce((a, b) => Math.max(a, b), bundledUi ? bundledUi.downloadTimestamp : 0); + + console.log("Remote version %d, Local version %d", remoteVersion.timestamp, newestLocalVersion); + const requiredClientVersion = parseVersion(remoteVersion.requiredClientVersion); + if(requiredClientVersion.newerThan(clientVersion) && !is_debug) { + const result = await electron.dialog.showMessageBox({ + type: "question", + message: + "Your client is outdated.\n" + + "Newer UI packs requires client " + remoteVersion.requiredClientVersion + "\n" + + "Do you want to update your client?", + title: "Client outdated!", + buttons: ["Update client", availableCachedVersions.length === 0 ? "Close client" : "Ignore and use last possible"] + } as MessageBoxOptions); + + if(result.response == 0) { + if(!(await executeGraphicalClientUpdate(channel, false))) { + throw "Client outdated an suitable UI pack version has not been found"; + } else { + return; + } + } else if(availableCachedVersions.length === 0) { + electron.app.exit(1); + return; + } + } else if(remoteVersion.timestamp <= newestLocalVersion && !ignoreNewVersionTimestamp) { + /* We've already a equal or newer version. Don't use the remote version */ + /* if remote is older than current bundled version its not a drop since it could be used as a fallback */ + remoteVersionDropped = !!bundledUi && remoteVersion.timestamp > bundledUi.downloadTimestamp; + } else { + /* update is possible because the timestamp is newer than out latest local version */ + try { + console.log("Downloading UI pack version (%d) %s. Forced: %s. Newest local version: %d", remoteVersion.timestamp, + remoteVersion.versions_hash, ignoreNewVersionTimestamp ? "true" : "false", newestLocalVersion); + + callbackStatus("Downloading new UI pack", .55); + availableCachedVersions.push(await downloadUiPack(remoteVersion)); + } catch (error) { + console.error("Failed to download new UI pack: %o", error); + } + } + } + + callbackStatus("Unpacking UI", .70); + availableCachedVersions.sort((a, b) => a.packInfo.timestamp - b.packInfo.timestamp); + + /* Only invalidate the version if any other succeeded to load else we might fucked up (no permission to write etc) */ + let invalidatedVersions: CachedUIPack[] = []; + const doVersionInvalidate = async () => { + if(invalidatedVersions.length > 0) { + for(const version of invalidatedVersions) { + version.status = { type: "invalid", reason: "failed to unpack", timestamp: Date.now() }; + } + + await saveLocalUiCache(); + } + }; + + while(availableCachedVersions.length > 0) { + const pack = availableCachedVersions.pop(); + console.log("Trying to load UI pack from %s (%s). Downloaded at %s", + moment(pack.packInfo.timestamp).format("llll"), pack.packInfo.versions_hash, + moment(pack.downloadTimestamp).format("llll")); + + try { + const target = await unpackLocalUiPack(pack); + callbackStatus("UI pack loaded", 1); + await doVersionInvalidate(); + + return url.pathToFileURL(path.join(target, "index.html")).toString(); + } catch (error) { + invalidatedVersions.push(pack); + console.log("Failed to unpack UI pack: %o", error); + } + } + + if(remoteVersionDropped) { + /* try again, but this time enforce a remote download */ + const result = await loadCachedOrRemoteUiPack(channel, callbackStatus, true); + await doVersionInvalidate(); /* new UI pack seems to be successfully loaded */ + return result; /* if not succeeded an exception will be thrown */ + } + + throw "Failed to load any UI pack (local and remote)\nView the console for more details."; +} + +enum UILoaderMethod { + PACK = 0, + BUNDLED_PACK = 1, + /* RAW_FILES = 2, System deprecated */ + DEVELOP_SERVER = 3 +} + +/** + * @param statisticsCallback + * @returns the url of the ui pack entry point + */ +export async function loadUiPack(statisticsCallback: (message: string, index: number) => any) : Promise { + const channel = clientAppInfo().uiPackChannel; + let enforcedLoadingMethod = parseInt(processArguments.has_value(Arguments.UPDATER_UI_LOAD_TYPE) ? processArguments.value(Arguments.UPDATER_UI_LOAD_TYPE) : "-1") as UILoaderMethod; + + if(typeof UILoaderMethod[enforcedLoadingMethod] !== "undefined") { + switch (enforcedLoadingMethod) { + case UILoaderMethod.PACK: + return await loadCachedOrRemoteUiPack(channel, statisticsCallback, false); + + case UILoaderMethod.BUNDLED_PACK: + return await loadBundledUiPack(channel, statisticsCallback); + + case UILoaderMethod.DEVELOP_SERVER: + return await streamFilesFromDevServer(channel, statisticsCallback); + + default: + console.warn("Invalid ui loader type %o. Skipping loader enforcement.", enforcedLoadingMethod); + } + } + + let firstError; + try { + return await loadCachedOrRemoteUiPack(channel, statisticsCallback, false); + } catch(error) { + console.warn("Failed to load cached/remote UI pack: %o", error); + firstError = firstError || error; + } + + try { + return await loadBundledUiPack(channel, statisticsCallback); + } catch(error) { + console.warn("Failed to load bundles UI pack: %o", error); + firstError = firstError || error; + } + + throw firstError; +} \ No newline at end of file diff --git a/modules/core/ui-loader/Remote.ts b/modules/core/ui-loader/Remote.ts new file mode 100644 index 0000000..91b20ba --- /dev/null +++ b/modules/core/ui-loader/Remote.ts @@ -0,0 +1,125 @@ +import {CachedUIPack, UIPackInfo} from "./CacheFile"; +import * as request from "request"; +import {remoteUiUrl} from "./Loader"; +import * as fs from "fs-extra"; +import {WriteStream} from "fs"; +import {localUiCache, saveLocalUiCache, uiPackCachePath} from "./Cache"; +import * as querystring from "querystring"; +import * as path from "path"; + +const kDownloadTimeout = 30_000; + +export async function queryRemoteUiPacks() : Promise { + const url = remoteUiUrl() + "api.php?" + querystring.stringify({ + type: "ui-info" + }); + console.debug("Loading UI pack information (URL: %s)", url); + + let body = await new Promise((resolve, reject) => request.get(url, { timeout: kDownloadTimeout }, (error, response, body: string) => { + if(error) { + reject(error); + } else if(!response) { + reject("missing response object"); + } else if(response.statusCode !== 200) { + reject(response.statusCode + " " + response.statusMessage); + } else if(!body) { + reject("missing body in response"); + } else { + resolve(body); + } + })); + + let response; + try { + response = JSON.parse(body); + } catch (error) { + console.error("Received unparsable response for UI pack info. Response: %s", body); + throw "failed to parse response"; + } + + if(!response["success"]) { + throw "request failed: " + (response["msg"] || "unknown error"); + } + + if(!Array.isArray(response["versions"])) { + console.error("Response object misses 'versions' tag or has an invalid value. Object: %o", response); + throw "response contains invalid data"; + } + + let uiVersions: UIPackInfo[] = []; + for(const entry of response["versions"]) { + uiVersions.push({ + channel: entry["channel"], + versions_hash: entry["git-ref"], + version: entry["version"], + timestamp: parseInt(entry["timestamp"]) * 1000, /* server provices that stuff in seconds */ + requiredClientVersion: entry["required_client"] + }); + } + + return uiVersions; +} + +export async function downloadUiPack(version: UIPackInfo) : Promise { + const targetFile = uiPackCachePath(version); + if(await fs.pathExists(targetFile)) { + try { + await fs.remove(targetFile); + } catch (error) { + console.error("Tried to download UI version %s, but we failed to delete the old file: %o", version.versions_hash, error); + throw "failed to remove the old file"; + } + } + + try { + await fs.mkdirp(path.dirname(targetFile)); + } catch (error) { + console.error("Failed to create target UI pack download directory at %s: %o", path.dirname(targetFile), error); + throw "failed to create target directories"; + } + + await new Promise((resolve, reject) => { + let fstream: WriteStream; + try { + request.get(remoteUiUrl() + "api.php?" + querystring.stringify({ + "type": "ui-download", + "git-ref": version.versions_hash, + "version": version.version, + "timestamp": Math.floor(version.timestamp / 1000), /* remote server has only the timestamp in seconds*/ + "channel": version.channel + }), { + timeout: kDownloadTimeout + }).on('response', function(response: request.Response) { + if(response.statusCode != 200) + reject(response.statusCode + " " + response.statusMessage); + }).on('error', error => { + reject(error); + }).pipe(fstream = fs.createWriteStream(targetFile)).on('finish', () => { + try { fstream.close(); } catch (e) { } + + resolve(); + }); + } catch (error) { + try { fstream.close(); } catch (e) { } + + reject(error); + } + }); + + try { + const cache = await localUiCache(); + const info: CachedUIPack = { + packInfo: version, + localFilePath: targetFile, + localChecksum: "none", //TODO! + status: { type: "valid" }, + downloadTimestamp: Date.now() + }; + cache.cachedPacks.push(info); + await saveLocalUiCache(); + return info; + } catch (error) { + console.error("Failed to register downloaded UI pack to the UI cache: %o", error); + throw "failed to register downloaded UI pack to the UI cache"; + } +} \ No newline at end of file diff --git a/modules/core/ui-loader/RemoteData.ts b/modules/core/ui-loader/RemoteData.ts new file mode 100644 index 0000000..e69de29 diff --git a/modules/core/ui-loader/Shipped.ts b/modules/core/ui-loader/Shipped.ts new file mode 100644 index 0000000..03dba1e --- /dev/null +++ b/modules/core/ui-loader/Shipped.ts @@ -0,0 +1,52 @@ +import {CachedUIPack} from "./CacheFile"; +import * as fs from "fs-extra"; +import * as path from "path"; +import validate from "./ShippedFileInfo.validator"; +import {app} from "electron"; + +async function doQueryShippedUi() { + const appPath = app.getAppPath(); + if(!appPath.endsWith(".asar")) { + return undefined; + } + + const basePath = path.join(path.dirname(appPath), "ui"); + //console.debug("Looking for client shipped UI pack at %s", basePath); + if(!(await fs.pathExists(basePath))) { + return undefined; + } + + const info = validate(await fs.readJson(path.join(basePath, "bundled-ui.json"))); + return { + downloadTimestamp: info.timestamp * 1000, + status: { type: "valid" }, + localChecksum: "none", + localFilePath: path.join(path.join(path.dirname(appPath), "ui"), info.filename), + packInfo: { + channel: info.channel, + requiredClientVersion: info.required_client, + timestamp: info.timestamp * 1000, + version: info.version, + versions_hash: info.git_hash + } + }; +} + +let queryPromise: Promise; + +/** + * This function will not throw. + * + * @returns the shipped client ui. + * Will return undefined if no UI has been shipped or it's an execution from source. + */ +export async function shippedClientUi() : Promise { + if(queryPromise) { + return queryPromise; + } + + return (queryPromise = doQueryShippedUi().catch(error => { + console.warn("Failed to query shipped client ui: %o", error); + return undefined; + })); +} \ No newline at end of file diff --git a/modules/core/ui-loader/ShippedFileInfo.ts b/modules/core/ui-loader/ShippedFileInfo.ts new file mode 100644 index 0000000..23246f7 --- /dev/null +++ b/modules/core/ui-loader/ShippedFileInfo.ts @@ -0,0 +1,10 @@ +export interface ShippedFileInfo { + channel: string, + version: string, + git_hash: string, + required_client: string, + timestamp: number, + filename: string +} + +export default ShippedFileInfo; \ No newline at end of file diff --git a/modules/core/ui-loader/ShippedFileInfo.validator.ts b/modules/core/ui-loader/ShippedFileInfo.validator.ts new file mode 100644 index 0000000..8c0247e --- /dev/null +++ b/modules/core/ui-loader/ShippedFileInfo.validator.ts @@ -0,0 +1,57 @@ +/* tslint:disable */ +// generated by typescript-json-validator +import {inspect} from 'util'; +import Ajv = require('ajv'); +import ShippedFileInfo from './ShippedFileInfo'; +export const ajv = new Ajv({"allErrors":true,"coerceTypes":false,"format":"fast","nullable":true,"unicode":true,"uniqueItems":true,"useDefaults":true}); + +ajv.addMetaSchema(require('ajv/lib/refs/json-schema-draft-06.json')); + +export {ShippedFileInfo}; +export const ShippedFileInfoSchema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "defaultProperties": [ + ], + "properties": { + "channel": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "git_hash": { + "type": "string" + }, + "required_client": { + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "version": { + "type": "string" + } + }, + "required": [ + "channel", + "filename", + "git_hash", + "required_client", + "timestamp", + "version" + ], + "type": "object" +}; +export type ValidateFunction = ((data: unknown) => data is T) & Pick +export const isShippedFileInfo = ajv.compile(ShippedFileInfoSchema) as ValidateFunction; +export default function validate(value: unknown): ShippedFileInfo { + if (isShippedFileInfo(value)) { + return value; + } else { + throw new Error( + ajv.errorsText(isShippedFileInfo.errors!.filter((e: any) => e.keyword !== 'if'), {dataVar: 'ShippedFileInfo'}) + + '\n\n' + + inspect(value), + ); + } +} diff --git a/modules/core/ui-loader/graphical.ts b/modules/core/ui-loader/graphical.ts deleted file mode 100644 index 59ca93c..0000000 --- a/modules/core/ui-loader/graphical.ts +++ /dev/null @@ -1,164 +0,0 @@ -import * as electron from "electron"; -import * as path from "path"; -import {screen} from "electron"; - -import {Arguments, processArguments} from "../../shared/process-arguments"; -import * as loader from "./loader"; -import * as updater from "../app-updater"; -import * as url from "url"; -import {loadWindowBounds, startTrackWindowBounds} from "../../shared/window"; - -export namespace ui { - let gui: electron.BrowserWindow; - let promise: Promise; - let resolve: any; - let reject: any; - - export function running() : boolean { - return promise !== undefined; - } - - export function cancel() : boolean { - if(resolve) - resolve(); - - cleanup(); - return true; - } - - export function cleanup() { - if(gui) { - promise = undefined; - resolve = undefined; - - gui.destroy(); - gui = undefined; - - reject = error => { - if(error) - console.error("Received error from loader after it had been closed... Error: %o", error); - }; - } - } - - async function load_files() { - const channel = await updater.selected_channel(); - try { - const entry_point = await loader.load_files(channel, (status, index) => { - if(gui) { - gui.webContents.send('progress-update', status, index); - } - }); - - const resolved = () => { - resolve(entry_point); - - promise = undefined; - resolve = undefined; - reject = error => { - if(error) - console.error("Received error from loader after it had been closed... Error: %o", error); - }; - }; - if(!processArguments.has_flag(...Arguments.DISABLE_ANIMATION)) - setTimeout(resolved, 250); - else - setImmediate(resolved); - } catch (error) { - throw error; - } - } - - export function show_await_update() { - if(gui) - gui.webContents.send('await-update'); - } - - function spawn_gui() { - if(gui) { - gui.focus(); - return; - } - console.log("Open UI loader window."); - let dev_tools = false; - - const WINDOW_WIDTH = 340 + (dev_tools ? 1000 : 0); - const WINDOW_HEIGHT = 400 + (process.platform == "win32" ? 40 : 0); - - gui = new electron.BrowserWindow({ - width: WINDOW_WIDTH, - height: WINDOW_HEIGHT, - frame: dev_tools, - resizable: dev_tools, - show: false, - autoHideMenuBar: true, - - webPreferences: { - webSecurity: false, - nodeIntegrationInWorker: false, - nodeIntegration: true - } - }); - gui.setMenu(null); - gui.loadURL(url.pathToFileURL(path.join(path.dirname(module.filename), "ui", "loading_screen.html")).toString()) - gui.on('closed', () => { - if(resolve) { - resolve(); - } - gui = undefined; - cleanup(); - }); - - gui.on('ready-to-show', () => { - gui.show(); - - try { - let bounds = screen.getPrimaryDisplay()?.bounds; - let x, y; - if(bounds) { - x = (bounds.x | 0) + ((bounds.width | 0) - WINDOW_WIDTH) / 2; - y = (bounds.y | 0) + ((bounds.height | 0) - WINDOW_HEIGHT) / 2; - } else { - x = 0; - y = 0; - } - console.log("Setting UI position to %ox%o", x, y); - if(typeof x === "number" && typeof y === "number") - gui.setPosition(x, y); - } catch (error) { - console.warn("Failed to apply UI position: %o", error); - } - - loadWindowBounds('ui-load-window', gui, undefined, { applySize: false }).then(() => { - startTrackWindowBounds('ui-load-window', gui); - - const call_loader = () => load_files().catch(reject); - if(!processArguments.has_flag(...Arguments.DISABLE_ANIMATION)) - setTimeout(call_loader, 1000); - else - setImmediate(call_loader); - - if(dev_tools) - gui.webContents.openDevTools(); - }); - }); - - } - - export async function execute_loader() : Promise { - return promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject || (error => { - console.error("Failed to load UI files! Error: %o", error) - }); - - spawn_gui(); - }); - } - - export function preloading_page(entry_point: string) : string { - global["browser-root"] = entry_point; /* setup entry point */ - - return path.join(path.dirname(module.filename), "ui", "preload_page.html"); - } -} \ No newline at end of file diff --git a/modules/core/ui-loader/index.ts b/modules/core/ui-loader/index.ts deleted file mode 100644 index 4d9dbab..0000000 --- a/modules/core/ui-loader/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./loader.js"; -export * from "./graphical.js"; \ No newline at end of file diff --git a/modules/core/ui-loader/loader.ts b/modules/core/ui-loader/loader.ts deleted file mode 100644 index 7e8dc29..0000000 --- a/modules/core/ui-loader/loader.ts +++ /dev/null @@ -1,620 +0,0 @@ -import {is_debug} from "../main-window"; -import * as moment from "moment"; -import * as request from "request"; -import * as querystring from "querystring"; -import * as fs from "fs-extra"; -import * as os from "os"; -const UUID = require('pure-uuid'); -import * as path from "path"; -import * as zlib from "zlib"; -import * as tar from "tar-stream"; -import {Arguments, processArguments} 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"; -import * as local_ui_cache from "./local_ui_cache"; -import {WriteStream} from "fs"; - -const TIMEOUT = 30000; - -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 = (processArguments.has_value(...Arguments.SERVER_URL) ? processArguments.value(...Arguments.SERVER_URL) : default_path); -}; - -export interface VersionedFile { - name: string, - hash: string, - path: string, - type: string, - - local_url: () => Promise -} - -function generate_tmp() : Promise { - if(generate_tmp.promise) return generate_tmp.promise; - - return (generate_tmp.promise = fs.mkdtemp(path.join(os.tmpdir(), "TeaClient-")).then(path => { - process.on('exit', event => { - try { - if(fs.pathExistsSync(path)) - fs.removeSync(path); - } catch (e) { - console.warn("Failed to delete temp directory: %o", e); - } - }); - - global["browser-root"] = path; - console.log("Local browser path: %s", path); - return Promise.resolve(path); - })); -} - -namespace generate_tmp { - export let promise: Promise; -} - -function get_raw_app_files() : Promise { - return new Promise((resolve, reject) => { - const url = remote_url() + "api.php?" + querystring.stringify({ - type: "files", - }); - console.debug("Requesting file list from %s", url); - request.get(url, { - timeout: TIMEOUT - }, (error, response, body: string) => { - if(error) { - reject(error); - return; - } - - if(!response) { - reject("missing response object"); - return; - } - - if(response.statusCode != 200) { setImmediate(reject, "invalid status code " + response.statusCode + " for " + url); return; } - if(parseInt(response.headers["info-version"] as string) != 1 && !processArguments.has_flag(Arguments.UPDATER_UI_IGNORE_VERSION)) { 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); - }); - }); -} - -async function download_raw_app_files() : Promise { - const local_temp_path = await generate_tmp(); - return get_raw_app_files().then(response => { - for(let file of response) { - const full_path = path.join(local_temp_path, file.path, file.name); - file.local_url = () => fs.mkdirs(path.dirname(full_path)).then(() => new Promise((resolve, reject) => { - const write_stream = fs.createWriteStream(full_path); - request.get(remote_url() + "api.php?" + querystring.stringify({ - type: "file", - path: file.path, - name: file.name - }), { - timeout: TIMEOUT - }).on('response', function(response) { - if(response.statusCode != 200) { - setImmediate(reject, "invalid status code " + response.statusCode + " for file " + file.name + " (" + file.path + ")"); - return; - } - }).on('complete', event => { - }).on('error', error => { - try { write_stream.close(); } catch (e) { } - setImmediate(reject, error); - }).pipe(write_stream) - .on('finish', event => { - try { write_stream.close(); } catch (e) { } - setImmediate(resolve, file.path + "/" + file.name); - }).on('error', error => { - try { write_stream.close(); } catch (e) { } - setImmediate(reject, error); - }); - })); - } - return Promise.resolve(response); - }).catch(error => { - console.log("Failed to get file list: %o", error); - return Promise.reject("Failed to get file list (" + error + ")"); - }) -} - -async function client_shipped_ui() : Promise { - const app_path = electron.app.getAppPath(); - if(!app_path.endsWith(".asar")) - return undefined; - - const base_path = path.join(path.dirname(app_path), "ui"); - //console.debug("Looking for client shipped UI pack at %s", base_path); - if(!(await fs.pathExists(base_path))) - return undefined; - - const info: { - channel: string, - version: string, - git_hash: string, - required_client: string, - timestamp: number, - filename: string - } = await fs.readJson(path.join(base_path, "default_ui_info.json")) as any; - - return { - download_timestamp: info.timestamp * 1000, - status: "valid", - invalid_reason: undefined, - local_checksum: "none", - local_file_path: path.join(path.join(path.dirname(app_path), "ui"), info.filename), - pack_info: { - channel: info.channel, - min_client_version: info.required_client, - timestamp: info.timestamp * 1000, - version: info.version, - versions_hash: info.git_hash - } - }; -} - -async function query_ui_pack_versions() : Promise { - const url = remote_url() + "api.php?" + querystring.stringify({ - type: "ui-info" - }); - console.debug("Loading UI pack information (URL: %s)", url); - - let body = await new Promise((resolve, reject) => request.get(url, { timeout: TIMEOUT }, (error, response, body: string) => { - if(error) - reject(error); - else if(!response) - reject("missing response object"); - else { - if(response.statusCode !== 200) - reject(response.statusCode + " " + response.statusMessage); - else if(!body) - reject("missing body in response"); - else - resolve(body); - } - })); - - let response; - try { - response = JSON.parse(body); - } catch (error) { - console.error("Received unparsable response for UI pack info. Response: %s", body); - throw "failed to parse response"; - } - - if(!response["success"]) - throw "request failed: " + (response["msg"] || "unknown error"); - - if(!Array.isArray(response["versions"])) { - console.error("Response object misses 'versions' tag or has an invalid value. Object: %o", response); - throw "response contains invalid data"; - } - - let ui_versions: local_ui_cache.UIPackInfo[] = []; - for(const entry of response["versions"]) { - ui_versions.push({ - channel: entry["channel"], - versions_hash: entry["git-ref"], - version: entry["version"], - timestamp: parseInt(entry["timestamp"]) * 1000, /* server provices that stuff in seconds */ - min_client_version: entry["required_client"] - }); - } - - return ui_versions; -} - -async function download_ui_pack(version: local_ui_cache.UIPackInfo) : Promise { - const target_file = path.join(local_ui_cache.cache_path(), version.channel + "_" + version.versions_hash + "_" + version.timestamp + ".tar.gz"); - if(await fs.pathExists(target_file)) { - try { - await fs.remove(target_file); - } catch (error) { - console.error("Tried to download UI version %s, but we failed to delete the old file: %o", version.versions_hash, error); - throw "failed to delete old file"; - } - } - try { - await fs.mkdirp(path.dirname(target_file)); - } catch (error) { - console.error("Failed to create target UI pack download directory at %s: %o", path.dirname(target_file), error); - throw "failed to create target directories"; - } - - await new Promise((resolve, reject) => { - let fstream: WriteStream; - try { - request.get(remote_url() + "api.php?" + querystring.stringify({ - "type": "ui-download", - "git-ref": version.versions_hash, - "version": version.version, - "timestamp": Math.floor(version.timestamp / 1000), /* remote server has only the timestamp in seconds*/ - "channel": version.channel - }), { - timeout: TIMEOUT - }).on('response', function(response: request.Response) { - if(response.statusCode != 200) - reject(response.statusCode + " " + response.statusMessage); - }).on('error', error => { - reject(error); - }).pipe(fstream = fs.createWriteStream(target_file)).on('finish', () => { - try { fstream.close(); } catch (e) { } - - resolve(); - }); - } catch (error) { - try { fstream.close(); } catch (e) { } - - reject(error); - } - }); - - try { - const cache = await local_ui_cache.load(); - const info: local_ui_cache.CachedUIPack = { - pack_info: version, - local_file_path: target_file, - local_checksum: "none", //TODO! - invalid_reason: undefined, - status: "valid", - download_timestamp: Date.now() - }; - cache.cached_ui_packs.push(info); - await local_ui_cache.save(); - return info; - } catch (error) { - console.error("Failed to register downloaded UI pack to the UI cache: %o", error); - throw "failed to register downloaded UI pack to the UI cache"; - } -} - -async function ui_pack_usable(version: local_ui_cache.CachedUIPack) : Promise { - if(version.status !== "valid") return false; - return await fs.pathExists(version.local_file_path); -} - -async function unpack_local_ui_pack(version: local_ui_cache.CachedUIPack) : Promise { - if(!await ui_pack_usable(version)) - throw "UI pack has been invalidated"; - - const target_directory = await generate_tmp(); - if(!await fs.pathExists(target_directory)) - throw "failed to create temporary directory"; - - const gunzip = zlib.createGunzip(); - const extract = tar.extract(); - let fpipe: fs.ReadStream; - - try { - fpipe = fs.createReadStream(version.local_file_path); - } catch (error) { - console.error("Failed to open UI pack at %s: %o", version.local_file_path, error); - throw "failed to open UI pack"; - } - - extract.on('entry', function(header: tar.Headers, stream, next) { - if(header.type == 'file') { - const target_file = path.join(target_directory, 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_directory, header.name))) - setImmediate(next); - fs.mkdirs(path.join(target_directory, header.name)).catch(error => { - console.warn("Failed to create unpacking dir " + path.join(target_directory, 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, reject) => { - gunzip.on('error', event => { - reject(event); - }); - - extract.on('finish', resolve); - extract.on('error', event => { - if(!event) return; - reject(event); - }); - - fpipe.pipe(gunzip).pipe(extract); - }); - - try { - await finish_promise; - } catch(error) { - console.error("Failed to extract UI files to %s: %o", target_directory, error); - throw "failed to unpack the UI pack"; - } - - return target_directory; -} - -async function load_files_from_dev_server(channel: string, stats_update: (message: string, index: number) => any) : Promise { - stats_update("Fetching files", 0); - let files: VersionedFile[]; - try { - files = await download_raw_app_files() - } catch (error) { - console.log("Failed to fetch raw UI file list: %o", error); - let msg; - if(error instanceof Error) - msg = error.message; - else if(typeof error === "string") - msg = error; - throw "failed to get file list" + (msg ? " (" + msg + ")" : ""); - } - - - const max_simultaneously_downloads = 8; - let pending_files: VersionedFile[] = files.slice(0); - let current_downloads: {[key: string]: Promise} = {}; - - const update_download_status = () => { - const indicator = (pending_files.length + Object.keys(current_downloads).length) / files.length; - stats_update("Downloading raw UI files", 1 - indicator); - }; - update_download_status(); - - let errors: { file: VersionedFile; error: any }[] = []; - while(pending_files.length > 0) { - while(pending_files.length > 0 && Object.keys(current_downloads).length < max_simultaneously_downloads) { - const file = pending_files.pop(); - current_downloads[file.hash] = file.local_url().catch(error => { - errors.push({ file: file, error: error}); - }).then(() => { - delete current_downloads[file.hash]; - }); - } - - update_download_status(); - await Promise.race(Object.keys(current_downloads).map(e => current_downloads[e])); - - if(errors.length > 0) - break; - } - - /* await full finish */ - while(Object.keys(current_downloads).length > 0) { - update_download_status(); - await Promise.race(Object.keys(current_downloads).map(e => current_downloads[e])); - } - - if(errors.length > 0) { - console.log("Failed to load UI files (%d):", errors.length); - for(const error of errors) - console.error(" - %s: %o", path.join(error.file.path + error.file.name), error.error); - throw "failed to download file " + path.join(errors[0].file.path + errors[0].file.name) + " (" + errors[0].error + ")\nView console for a full error report."; - } - - console.log("Successfully loaded UI files from remote server."); - /* generate_tmp has already been called an its the file destination */ - return path.join(await generate_tmp(), "index.html"); /* entry point */ -} - -async function stream_files_from_dev_server(channel: string, stats_update: (message: string, index: number) => any) : Promise { - return remote_url() + "index.html"; -} - -async function load_bundles_ui_pack(channel: string, stats_update: (message: string, index: number) => any) : Promise { - stats_update("Query local UI pack info", .33); - const bundles_ui = await client_shipped_ui(); - if(!bundles_ui) throw "client has no bundled UI pack"; - - stats_update("Unpacking bundled UI", .66); - const result = await unpack_local_ui_pack(bundles_ui); - stats_update("Local UI pack loaded", 1); - console.log("Loaded bundles UI pack successfully. Version: {timestamp: %d, hash: %s}", bundles_ui.pack_info.timestamp, bundles_ui.pack_info.versions_hash); - return path.join(result, "index.html"); -} - -async function load_cached_or_remote_ui_pack(channel: string, stats_update: (message: string, index: number) => any, ignore_new_version_timestamp: boolean) : Promise { - stats_update("Fetching info", 0); - const ui_cache = await local_ui_cache.load(); - const bundles_ui = await client_shipped_ui(); - const client_version = await current_version(); - - let available_versions: local_ui_cache.CachedUIPack[] = ui_cache.cached_ui_packs.filter(e => { - if(e.status !== "valid") - return false; - - if(bundles_ui) { - if(e.pack_info.timestamp <= bundles_ui.download_timestamp) - return false; - } - - const required_version = parse_version(e.pack_info.min_client_version); - return client_version.in_dev() || client_version.newer_than(required_version) || client_version.equals(required_version); - }); - if(processArguments.has_flag(Arguments.UPDATER_UI_NO_CACHE)) { - console.log("Ignoring local UI cache"); - available_versions = []; - } - - let remote_version_dropped = false; - /* remote version gathering */ - remote_loader: { - stats_update("Loading remote info", .25); - let remote_versions: local_ui_cache.UIPackInfo[]; - try { - remote_versions = await query_ui_pack_versions(); - } catch (error) { - if(available_versions.length === 0) - throw "failed to query remote UI packs: " + error; - console.error("Failed to query remote UI packs: %o", error); - break remote_loader; - } - - stats_update("Parsing UI packs", .40); - const remote_version = remote_versions.find(e => e.channel === channel); - if(!remote_version && available_versions.length === 0) - throw "no UI pack available for channel " + channel; - - let newest_local_version = available_versions.map(e => e.pack_info.timestamp).reduce((a, b) => Math.max(a, b), bundles_ui ? bundles_ui.download_timestamp : 0); - console.log("Remote version %d, Local version %d", remote_version.timestamp, newest_local_version); - const required_version = parse_version(remote_version.min_client_version); - if(required_version.newer_than(client_version) && !is_debug) { - const result = await electron.dialog.showMessageBox({ - type: "question", - message: - "Your client is outdated.\n" + - "Newer UI packs (>= " + remote_version.version + ", " + remote_version.versions_hash + ") require client " + remote_version.min_client_version + "\n" + - "Do you want to update your client?", - title: "Client outdated!", - buttons: ["Update client", available_versions.length === 0 ? "Close client" : "Ignore and use last possible"] - } as MessageBoxOptions); - - if(result.response == 0) { - if(!await execute_graphical(channel, true)) - throw "Client outdated an no suitable UI pack versions found"; - else - return; - } else { - if(available_versions.length === 0) { - electron.app.exit(1); - return; - } - } - } else if(remote_version.timestamp <= newest_local_version && !ignore_new_version_timestamp) { - /* We've already a equal or newer version. Don't use the remote version */ - remote_version_dropped = !!bundles_ui && remote_version.timestamp > bundles_ui.download_timestamp; /* if remote is older than current bundled version its def. not a drop */ - } else { - /* update is possible because the timestamp is newer than out latest local version */ - try { - console.log("Downloading UI pack version (%d) %s. Forced: %s. Newest local version: %d", remote_version.timestamp, - remote_version.versions_hash, ignore_new_version_timestamp ? "true" : "false", newest_local_version); - stats_update("Downloading new UI pack", .55); - available_versions.push(await download_ui_pack(remote_version)); - } catch (error) { - console.error("Failed to download new UI pack: %o", error); - } - } - } - - stats_update("Unpacking UI", .70); - available_versions.sort((a, b) => a.pack_info.timestamp - b.pack_info.timestamp); - - /* Only invalidate the version if any other succeeded to load. Else we might fucked up (no permission to write etc) */ - let invalidate_versions: local_ui_cache.CachedUIPack[] = []; - const do_invalidate_versions = async () => { - if(invalidate_versions.length > 0) { - for(const version of invalidate_versions) { - version.invalid_reason = "failed to unpack"; - version.status = "invalid"; - } - await local_ui_cache.save(); - } - }; - - while(available_versions.length > 0) { - const pack = available_versions.pop(); - console.log("Trying to load UI pack from %s (%s). Downloaded at %s", moment(pack.pack_info.timestamp).format("llll"), pack.pack_info.versions_hash, moment(pack.download_timestamp).format("llll")); - - try { - const target = await unpack_local_ui_pack(pack); - stats_update("UI pack loaded", 1); - await do_invalidate_versions(); - return path.join(target, "index.html"); - } catch (error) { - invalidate_versions.push(pack); - console.log("Failed to unpack UI pack: %o", error); - } - } - - if(remote_version_dropped) { - /* try again, but this time enforce a remote download */ - const result = await load_cached_or_remote_ui_pack(channel, stats_update, true); - await do_invalidate_versions(); /* new UI pack seems to be successfully loaded */ - return result; /* if not succeeded an exception will be thrown */ - } - - throw "Failed to load any UI pack (local and remote)\nView the console for more details.\n"; -} - -enum UILoaderMethod { - PACK, - BUNDLED_PACK, - RAW_FILES, - DEVELOP_SERVER -} - -export async function load_files(channel: string, stats_update: (message: string, index: number) => any) : Promise { - let enforced_loading_method = parseInt(processArguments.has_value(Arguments.UPDATER_UI_LOAD_TYPE) ? processArguments.value(Arguments.UPDATER_UI_LOAD_TYPE) : "-1") as UILoaderMethod; - - if(typeof UILoaderMethod[enforced_loading_method] !== "undefined") { - switch (enforced_loading_method) { - case UILoaderMethod.PACK: - return await load_cached_or_remote_ui_pack(channel, stats_update, false); - - case UILoaderMethod.BUNDLED_PACK: - return await load_bundles_ui_pack(channel, stats_update); - - case UILoaderMethod.RAW_FILES: - return await load_files_from_dev_server(channel, stats_update); - - case UILoaderMethod.DEVELOP_SERVER: - return await stream_files_from_dev_server(channel, stats_update); - } - } - - let first_error; - if(is_debug) { - try { - return await load_files_from_dev_server(channel, stats_update); - } catch(error) { - console.warn("Failed to load raw UI files: %o", error); - first_error = first_error || error; - } - } - - try { - return await load_cached_or_remote_ui_pack(channel, stats_update, false); - } catch(error) { - console.warn("Failed to load cached/remote UI pack: %o", error); - first_error = first_error || error; - } - - try { - return await load_bundles_ui_pack(channel, stats_update); - } catch(error) { - console.warn("Failed to load bundles UI pack: %o", error); - first_error = first_error || error; - } - - throw first_error; -} \ No newline at end of file diff --git a/modules/core/ui-loader/local_ui_cache.ts b/modules/core/ui-loader/local_ui_cache.ts deleted file mode 100644 index 44994a9..0000000 --- a/modules/core/ui-loader/local_ui_cache.ts +++ /dev/null @@ -1,138 +0,0 @@ -import * as path from "path"; -import * as fs from "fs-extra"; -import * as electron from "electron"; - -export namespace v1 { - /* main entry */ - 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; - } -} - -export interface CacheFile { - version: number; /* currently 2 */ - - cached_ui_packs: CachedUIPack[]; -} - -export interface UIPackInfo { - timestamp: number; /* build timestamp */ - version: string; /* not really used anymore */ - versions_hash: string; /* used, identifies the version. Its the git hash. */ - - channel: string; - min_client_version: string; /* minimum version from the client required for the pack */ -} - -export interface CachedUIPack { - download_timestamp: number; - local_file_path: string; - local_checksum: string | "none"; /* sha512 of the locally downloaded file. */ - //TODO: Get the remote checksum and compare them instead of the local one - - pack_info: UIPackInfo; - - status: "valid" | "invalid"; - invalid_reason?: string; -} - -let cached_loading_promise_: Promise; -let ui_cache_: CacheFile = { - version: 2, - cached_ui_packs: [] -}; -async function load_() : Promise { - const file = path.join(cache_path(), "data.json"); - - try { - if(!(await fs.pathExists(file))) { - return ui_cache_; - } - - const data = await fs.readJSON(file) as CacheFile; - if(!data) { - throw "invalid data object"; - } else if(typeof data["version"] !== "number") { - throw "invalid versions tag"; - } else if(data["version"] !== 2) { - console.warn("UI cache file contains an old version. Ignoring file and may override with newer version."); - return ui_cache_; - } - - /* validating data */ - if(!Array.isArray(data.cached_ui_packs)) { - throw "Invalid 'cached_ui_packs' entry within the UI cache file"; - } - - return (ui_cache_ = data as CacheFile); - } catch(error) { - console.warn("Failed to load UI cache file: %o. This will cause loss of the file content.", error); - return ui_cache_; - } -} - -/** - * Will not throw or return undefined! - */ -export function load() : Promise { - if(cached_loading_promise_) return cached_loading_promise_; - return (cached_loading_promise_ = load_()); -} - -export function unload() { - ui_cache_ = undefined; - cached_loading_promise_ = undefined; -} - -/** - * Will not throw anything - */ -export async function save() { - const file = path.join(cache_path(), "data.json"); - try { - if(!(await fs.pathExists(path.dirname(file)))) - await fs.mkdirs(path.dirname(file)); - await fs.writeJson(file, ui_cache_); - } catch (error) { - console.error("Failed to save UI cache file. This will may cause some data loss: %o", error); - } -} - -export function cache_path() { - return path.join(electron.app.getPath('userData'), "cache", "ui"); -} \ No newline at end of file diff --git a/modules/core/ui-loader/ui/img/logo.svg b/modules/core/ui-loader/ui/img/logo.svg deleted file mode 100644 index 77dc7a5..0000000 --- a/modules/core/ui-loader/ui/img/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/modules/core/ui-loader/ui/loader.ts b/modules/core/ui-loader/ui/loader.ts deleted file mode 100644 index ee9f0b0..0000000 --- a/modules/core/ui-loader/ui/loader.ts +++ /dev/null @@ -1,22 +0,0 @@ -const icp = require("electron").ipcRenderer; - -interface Window { - $: JQuery; -} -(window as any).$ = require("jquery"); - -icp.on('progress-update', (event, status, count) => { - console.log("Process update \"%s\" to %d", status, count); - - $("#current-status").text(status); - $(".container-bar .bar").css("width", (count * 100) + "%"); -}); - -icp.on('await-update', (event) => { - console.log("Received update notification"); - - $(".container-bar .bar").css("width", "100%"); - $("#loading-text").html("Awaiting client update response
(User input required)"); -}); - -export {} \ No newline at end of file diff --git a/modules/core/ui-loader/ui/loading_screen.html b/modules/core/ui-loader/ui/loading_screen.html deleted file mode 100644 index 2973ccc..0000000 --- a/modules/core/ui-loader/ui/loading_screen.html +++ /dev/null @@ -1,111 +0,0 @@ - - - - - TeaClient - - - - - - - - - - - \ No newline at end of file diff --git a/modules/core/ui-loader/ui/preload_page.html b/modules/core/ui-loader/ui/preload_page.html deleted file mode 100644 index 6bd70da..0000000 --- a/modules/core/ui-loader/ui/preload_page.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - TeaClient - loading files - - - An unknown error happened!
- Please report this! - - \ No newline at end of file diff --git a/modules/core/url-preview/html/inject.ts b/modules/core/url-preview/html/inject.ts index 804b677..604450a 100644 --- a/modules/core/url-preview/html/inject.ts +++ b/modules/core/url-preview/html/inject.ts @@ -39,7 +39,7 @@ const html_overlay = "text-align: center;\n" + "line-height: 15px;\n" + "z-index: 1000;\n" + - "text-decoration: none;'" + + "text-decoration: none;' " + "class='button-close'>" + "✖" + "" + @@ -64,7 +64,7 @@ let _inject_overlay = () => { console.warn(log_prefix + "Failed to find close button for preview notice!"); } else { for(const button of buttons) { - (button).onclick = _close_overlay; + (button as HTMLElement).onclick = _close_overlay; } } } @@ -74,7 +74,7 @@ let _inject_overlay = () => { console.warn(log_prefix + "Failed to find open button for preview notice!"); } else { for(const element of buttons) { - (element).onclick = event => { + (element as HTMLElement).onclick = () => { console.info(log_prefix + "Opening URL with default browser"); electron.remote.shell.openExternal(location.href, { activate: true diff --git a/modules/core/windows/app-loader/controller/AppLoader.ts b/modules/core/windows/app-loader/controller/AppLoader.ts new file mode 100644 index 0000000..2467bc3 --- /dev/null +++ b/modules/core/windows/app-loader/controller/AppLoader.ts @@ -0,0 +1,114 @@ +import {loadWindowBounds, startTrackWindowBounds} from "../../../../shared/window"; +import {BrowserWindow, dialog} from "electron"; +import * as path from "path"; +import * as url from "url"; +import { screen } from "electron"; + +let kDeveloperTools = false; + +let windowInstance: BrowserWindow; +let windowSpawnPromise: Promise; + +let currentStatus: string; +let currentProgress: number; + +export async function showAppLoaderWindow() { + while(windowSpawnPromise) { + await windowSpawnPromise; + } + + if(windowInstance) { + console.error("Just focus"); + windowInstance.focus(); + return; + } + + windowSpawnPromise = spawnAppLoaderWindow().catch(error => { + console.error("Failed to open the app loader window: %o", error); + dialog.showErrorBox("Failed to open window", "Failed to open the app loader window.\nLookup the console for details."); + hideAppLoaderWindow(); + }); + /* do this after the assignment so in case the promise resolves instantly we still clear the assignment */ + windowSpawnPromise.then(() => windowSpawnPromise = undefined); + + await windowSpawnPromise; +} + +export function getLoaderWindow() : BrowserWindow { + return windowInstance; +} + +async function spawnAppLoaderWindow() { + console.debug("Opening app loader window."); + + const kWindowWidth = 340 + (kDeveloperTools ? 1000 : 0); + const kWindowHeight = 400 + (process.platform == "win32" ? 40 : 0); + + windowInstance = new BrowserWindow({ + width: kWindowWidth, + height: kWindowHeight, + frame: kDeveloperTools, + resizable: kDeveloperTools, + show: false, + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: true, + } + }); + + windowInstance.setMenu(null); + windowInstance.on('closed', () => { + windowInstance = undefined; + }); + + if(kDeveloperTools) { + windowInstance.webContents.openDevTools(); + } + + await windowInstance.loadURL(url.pathToFileURL(path.join(__dirname, "..", "renderer", "index.html")).toString()); + setAppLoaderStatus(currentStatus, currentProgress); + windowInstance.show(); + + try { + let bounds = screen.getPrimaryDisplay()?.bounds; + let x, y; + if(bounds) { + x = (bounds.x | 0) + ((bounds.width | 0) - kWindowWidth) / 2; + y = (bounds.y | 0) + ((bounds.height | 0) - kWindowHeight) / 2; + } else { + x = 0; + y = 0; + } + + console.log("Setting app loader ui position to %ox%o", x, y); + if(typeof x === "number" && typeof y === "number") { + windowInstance.setPosition(x, y); + } + } catch (error) { + console.warn("Failed to apply app loader ui position: %o", error); + } + + try { + await loadWindowBounds('ui-load-window', windowInstance, undefined, { applySize: false }); + startTrackWindowBounds('ui-load-window', windowInstance); + } catch (error) { + console.warn("Failed to load and track window bounds: %o", error); + } +} + +export function hideAppLoaderWindow() { + (async () => { + await windowSpawnPromise; + if(windowInstance) { + windowInstance.close(); + windowInstance = undefined; + } + })(); +} + +export function setAppLoaderStatus(status: string, progress: number) { + currentStatus = status; + currentProgress = progress; + + windowInstance?.webContents.send("progress-update", status, progress); +} \ No newline at end of file diff --git a/modules/core/windows/app-loader/renderer/img/logo.svg b/modules/core/windows/app-loader/renderer/img/logo.svg new file mode 100644 index 0000000..01bcbc1 --- /dev/null +++ b/modules/core/windows/app-loader/renderer/img/logo.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/core/ui-loader/ui/img/smoke.png b/modules/core/windows/app-loader/renderer/img/smoke.png similarity index 100% rename from modules/core/ui-loader/ui/img/smoke.png rename to modules/core/windows/app-loader/renderer/img/smoke.png diff --git a/modules/core/windows/app-loader/renderer/index.html b/modules/core/windows/app-loader/renderer/index.html new file mode 100644 index 0000000..7b7e22f --- /dev/null +++ b/modules/core/windows/app-loader/renderer/index.html @@ -0,0 +1,23 @@ + + + + + TeaClient + + + + + + + + + \ No newline at end of file diff --git a/modules/core/windows/app-loader/renderer/index.scss b/modules/core/windows/app-loader/renderer/index.scss new file mode 100644 index 0000000..72fa374 --- /dev/null +++ b/modules/core/windows/app-loader/renderer/index.scss @@ -0,0 +1,88 @@ +html, body { + background: #18BC9C; + user-select: none; + + -webkit-app-region: drag; +} + +body { + text-align: center; + position: absolute; + + top: 0; + bottom: 0; + right: 0; + left: 0; + + margin-left: 18px; + margin-right: 18px; + + display: flex; + flex-direction: column; + justify-content: center; + + -ms-overflow-style: none; +} + +img { + position: absolute; + display: block; + + width: 200px; + height: 200px; +} + +.smoke { + z-index: 2; +} + +.logo { + z-index: 1; +} + +.container-logo { + align-self: center; + position: relative; + display: inline-block; + + width: 200px; + height: 200px; +} + +.container-info a { + display: inline-block; + color: #FFFFFF; + font-family: "Arial",serif; + font-size: 20px; +} + +.container-bar { + position: relative; + margin-top: 5px; + border: white solid 2px; + height: 18px; +} + +.container-bar .bar { + z-index: 1; + position: absolute; + display: block; + + background: whitesmoke; + border: none; + width: 0; + height: 100%; +} + +#current-status { + margin-top: 3px; + font-size: 18px; + + max-width: 100%; + width: 100%; + text-align: left; + + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} \ No newline at end of file diff --git a/modules/core/windows/app-loader/renderer/index.ts b/modules/core/windows/app-loader/renderer/index.ts new file mode 100644 index 0000000..864f25f --- /dev/null +++ b/modules/core/windows/app-loader/renderer/index.ts @@ -0,0 +1,32 @@ +import { ipcRenderer } from "electron"; + +const currentStatus = document.getElementById("current-status") as HTMLDivElement; +const progressIndicator = document.getElementById("progress-indicator") as HTMLDivElement; + +const setStatusText = (text: string) => { + if(currentStatus) { + currentStatus.innerHTML = text; + } +} + +const setProgressIndicator = (value: number) => { + if(progressIndicator) { + progressIndicator.style.width = (value * 100) + "%"; + } +} + +ipcRenderer.on('progress-update', (event, status, count) => { + console.log("Process update \"%s\" to %d", status, count); + + setStatusText(status); + setProgressIndicator(count); +}); + +ipcRenderer.on('await-update', (event) => { + console.log("Received update notification"); + + setProgressIndicator(1); + setStatusText("Awaiting client update response
(User input required)"); +}); + +export = {}; \ No newline at end of file diff --git a/modules/core/windows/client-updater/controller/ClientUpdate.ts b/modules/core/windows/client-updater/controller/ClientUpdate.ts new file mode 100644 index 0000000..df0781a --- /dev/null +++ b/modules/core/windows/client-updater/controller/ClientUpdate.ts @@ -0,0 +1,223 @@ +import {BrowserWindow, dialog} from "electron"; +import * as url from "url"; +import * as path from "path"; +import {loadWindowBounds, startTrackWindowBounds} from "../../../../shared/window"; +import {hideAppLoaderWindow} from "../../app-loader/controller/AppLoader"; +import { + availableRemoteChannels, clientAppInfo, clientUpdateChannel, + currentClientVersion, + newestRemoteClientVersion, prepareUpdateExecute, setClientUpdateChannel, + UpdateVersion +} from "../../../app-updater"; +import {mainWindow} from "../../../main-window"; +import {closeMainWindow} from "../../main-window/controller/MainWindow"; + +const kDeveloperTools = true; + +let windowInstance: BrowserWindow; +let windowSpawnPromise: Promise; + +let currentRemoteUpdateVersion: UpdateVersion; + +let updateInstallExecuteCallback; +let updateInstallAbortCallback; + +export async function showUpdateWindow() { + while(windowSpawnPromise) { + await windowSpawnPromise; + } + + if(windowInstance) { + windowInstance.focus(); + return; + } + + windowSpawnPromise = doSpawnWindow().catch(error => { + console.error("Failed to open the client updater window: %o", error); + dialog.showErrorBox("Failed to open window", "Failed to open the client updater window.\nLookup the console for details."); + hideAppLoaderWindow(); + }); + /* do this after the assignment so in case the promise resolves instantly we still clear the assignment */ + windowSpawnPromise.then(() => windowSpawnPromise = undefined); + + await windowSpawnPromise; + console.error("Window created"); +} + +const kZoomFactor = 1; +async function doSpawnWindow() { + const kWindowWidth = kZoomFactor * 580 + (kDeveloperTools ? 1000 : 0); + const kWindowHeight = kZoomFactor * 800 + (process.platform == "win32" ? 40 : 0); + + windowInstance = new BrowserWindow({ + width: kWindowWidth, + height: kWindowHeight, + frame: kDeveloperTools, + resizable: kDeveloperTools, + show: false, + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: true, + zoomFactor: kZoomFactor + } + }); + + fatalErrorHandled = false; + targetRemoteVersion = undefined; + currentRemoteUpdateVersion = undefined; + + windowInstance.setMenu(null); + windowInstance.on('closed', () => { + windowInstance = undefined; + if(updateInstallAbortCallback) { + /* cleanup */ + updateInstallAbortCallback(); + } + updateInstallAbortCallback = undefined; + updateInstallExecuteCallback = undefined; + }); + + if(kDeveloperTools) { + windowInstance.webContents.openDevTools(); + } + + initializeIpc(); + + await windowInstance.loadURL(url.pathToFileURL(path.join(__dirname, "..", "renderer", "index.html")).toString()); + windowInstance.show(); + + try { + await loadWindowBounds('client-updater', windowInstance, undefined, { applySize: false }); + startTrackWindowBounds('client-updater', windowInstance); + } catch (error) { + console.warn("Failed to load and track window bounds"); + } +} + +let fatalErrorHandled = false; +async function handleFatalError(error: string, popupMessage?: string) { + /* Show only one error at the time */ + if(fatalErrorHandled) { return; } + fatalErrorHandled = true; + + windowInstance?.webContents.send("client-updater-set-error", error); + + await dialog.showMessageBox(windowInstance, { + type: "error", + buttons: ["Ok"], + message: "A critical error happened:\n" + (popupMessage || error) + }); + + fatalErrorHandled = false; +} + +async function sendLocalInfo() { + try { + const localVersion = await currentClientVersion(); + if(localVersion.isDevelopmentVersion()) { + windowInstance?.webContents.send("client-updater-local-status", "InDev", Date.now()); + } else { + windowInstance?.webContents.send("client-updater-local-status", localVersion.toString(false), localVersion.timestamp); + } + } catch (error) { + console.error("Failed to query/send the local client version: %o", error); + handleFatalError("Failed to query local version").then(undefined); + } +} + +let targetRemoteVersion: UpdateVersion; +function initializeIpc() { + windowInstance.webContents.on("ipc-message", (event, channel, ...args) => { + switch (channel) { + case "client-updater-close": + closeUpdateWindow(); + break; + + case "client-updater-query-local-info": + sendLocalInfo().then(undefined); + break; + + case "client-updater-query-remote-info": + newestRemoteClientVersion(clientUpdateChannel()).then(async result => { + currentRemoteUpdateVersion = result; + if(!result) { + await handleFatalError("No remote update info."); + return; + } + + const localVersion = await currentClientVersion(); + const updateAvailable = !localVersion.isDevelopmentVersion() && (result.version.newerThan(localVersion) || result.channel !== clientAppInfo().clientChannel); + targetRemoteVersion = updateAvailable ? result : undefined; + + windowInstance?.webContents.send("client-updater-remote-status", + !localVersion.isDevelopmentVersion() && result.version.newerThan(localVersion), + result.version.toString(false), + result.version.timestamp + ); + + }).catch(async error => { + currentRemoteUpdateVersion = undefined; + console.error("Failed to query remote client version: %o", error); + await handleFatalError("Failed to query server info.", typeof error === "string" ? error : undefined); + }); + break; + + case "client-updater-query-channels": + availableRemoteChannels().then(channels => { + windowInstance?.webContents.send("client-updater-channel-info", channels, clientUpdateChannel()); + }).catch(async error => { + console.error("Failed to query available channels %o", error); + await handleFatalError("Failed to query available channels.", typeof error === "string" ? error : undefined); + }); + break; + + case "client-updater-set-channel": + setClientUpdateChannel(args[0] || "release"); + break; + + case "execute-update": + doExecuteUpdate(); + break; + + case "install-update": + updateInstallExecuteCallback(); + break; + + default: + /* nothing to do */ + break; + } + }); +} + +function doExecuteUpdate() { + windowInstance?.webContents.send("client-updater-execute"); + + if(!currentRemoteUpdateVersion) { + windowInstance?.webContents.send("client-updater-execute-finish", "Missing target version"); + return; + } + + closeMainWindow(true); + prepareUpdateExecute(currentRemoteUpdateVersion, (message, progress) => { + windowInstance?.webContents.send("client-updater-execute-progress", message, progress); + }, (type, message) => { + windowInstance?.webContents.send("client-updater-execute-log", type, message); + }).then(callbacks => { + updateInstallExecuteCallback = callbacks.callbackExecute; + updateInstallAbortCallback = callbacks.callbackAbort; + windowInstance?.webContents.send("client-updater-execute-finish"); + }).catch(error => { + windowInstance?.webContents.send("client-updater-execute-finish", error); + }); +} + +export function closeUpdateWindow() { + (async () => { + await windowSpawnPromise; + if(windowInstance) { + windowInstance.close(); + windowInstance = undefined; + } + })(); +} \ No newline at end of file diff --git a/modules/core/windows/client-updater/renderer/index.html b/modules/core/windows/client-updater/renderer/index.html new file mode 100644 index 0000000..ed0c149 --- /dev/null +++ b/modules/core/windows/client-updater/renderer/index.html @@ -0,0 +1,103 @@ + + + + + Updating app + + + + + +
+ +
+
+
+
Client Version
+
+
+
Client Version
+
+
+
+
Build Timestamp
+
+
+
+
Channel
+
+ + + +
+
+
+
+
+
Latest Version
+
+
+
Client Version
+
+
+
+
Build Timestamp
+
+
+
+
+
+
+ Update unavailable +
+

Update unavailable!

+

You can't update your client.

+
+
+ +
+ Update available +
+

Update available!

+

Update your client to 1.5.1.

+
+
+ +
+ Client up2date +
+

No update available.

+

Your client is up to date!

+
+
+ + +
+

loading 

+
+
+
+
+
+
Loading client update
+
+
+
50%
+
+
+
+
+
+ +
+ + +
+
+
+ + \ No newline at end of file diff --git a/modules/core/windows/client-updater/renderer/index.scss b/modules/core/windows/client-updater/renderer/index.scss new file mode 100644 index 0000000..41ea55a --- /dev/null +++ b/modules/core/windows/client-updater/renderer/index.scss @@ -0,0 +1,407 @@ +html:root { + --progress-bar-background: #242527; + + --progress-bar-filler-normal: #4370a299; + --progress-bar-filler-error: #a1000099; + --progress-bar-filler-success: #2b854199; +} + +* { + box-sizing: border-box; + outline: none; +} + +html { + display: flex; + flex-direction: row; + justify-content: center; + + user-select: none; + + background: #2f2f35; + font-size: 12px; + + width: 100vw; + height: 100vh; + + position: relative; +} + +$window-margin: 2em; +body { + display: flex; + flex-direction: row; + justify-content: center; + + margin: 0; + + position: absolute; + + top: 1em; + right: 1em; + left: 1em; + bottom: 1.75em; + + font-family: Roboto, Helvetica, Arial, sans-serif; + line-height: 1.6em; + + -webkit-app-region: drag; +} + +.loading-dots { + width: 2em; +} + +.container { + display: flex; + flex-direction: column; + justify-content: stretch; +} + +.logo { + align-self: center; + width: 30em; + + margin-left: -1em; + margin-right: -1em; + + img { + height: 100%; + width: 100%; + } +} + +.body { + flex-grow: 1; + flex-shrink: 1; + min-height: 6em; + + display: flex; + flex-direction: column; + justify-content: flex-start; + + -webkit-app-region: no-drag; + + .buttons { + margin-top: auto; + + display: flex; + flex-direction: row; + justify-content: space-between; + + &.btn-green { + margin-left: auto; + } + } +} + +.container-loading, .container-executing { + margin-top: 2em; + + flex-grow: 1; + flex-shrink: 1; + min-height: 6em; + + display: none; + flex-direction: column; + justify-content: stretch; + + &.shown { + display: flex; + } +} + +.section { + &.remote { + margin-top: 2em; + } + + .title { + font-size: 1.2em; + color: #557edc; + text-transform: uppercase; + align-self: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .content { + color: #999; + + display: flex; + flex-direction: column; + justify-content: flex-start; + + .row { + display: flex; + flex-direction: row; + justify-content: space-between; + } + } +} + +.update-availability-status { + position: relative; + + display: flex; + flex-grow: 1; + + .content { + position: absolute; + + top: 0; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: row; + justify-content: center; + + opacity: 0; + pointer-events: none; + + &.shown { + pointer-events: all; + opacity: 1; + } + + img { + width: 5em; + height: 5em; + margin-right: 1em; + + align-self: center; + } + + > div { + display: flex; + flex-direction: column; + justify-content: center; + + * { + margin: 0; + } + } + + h2 { + color: #999; + } + + h3 { + margin-top: .1em; + color: #999; + } + + &.available { + h2 { color: #1ca037 } + > img { margin-top: -.5em; } + } + + &.loading { + h2 { + display: flex; + flex-direction: row; + + font-weight: normal; + align-self: center; + } + } + + &.unavailable { + h2 { color: #c90709 } + } + } +} + +.update-progress { + display: flex; + flex-direction: column; + justify-content: flex-start; + + .info { + color: #999; + font-size: 1.2em; + margin-bottom: .2em; + } + + .bar-container { + position: relative; + + display: flex; + flex-direction: row; + justify-content: center; + + height: 1.4em; + border-radius: 0.2em; + + overflow: hidden; + + background-color: var(--progress-bar-background); + -webkit-box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75); + -moz-box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75); + box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75); + + .filler { + position: absolute; + + top: 0; + left: 0; + bottom: 0; + + transition: .3s ease-in-out; + } + + .text { + color: #999; + align-self: center; + z-index: 1; + } + + &.type-normal { + .filler { + background-color: var(--progress-bar-filler-normal); + } + } + + &.type-error { + .filler { + background-color: var(--progress-bar-filler-error); + } + } + + &.type-success { + .filler { + background-color: var(--progress-bar-filler-success); + } + } + } +} + +.update-log { + display: flex; + flex-direction: column; + justify-content: flex-start; + + padding: .25em .5em; + border-radius: 0.2em; + + flex-grow: 1; + flex-shrink: 1; + + min-height: 2em; + + margin-top: 1em; + margin-bottom: 1em; + + overflow-x: hidden; + overflow-y: auto; + + background-color: var(--progress-bar-background); + -webkit-box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75); + -moz-box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75); + box-shadow: inset 0 0 2px 0 rgba(0, 0, 0, 0.75); + + color: #999; + + user-select: text; + + /* Scroll bar */ + &::-webkit-scrollbar-track { + border-radius: .25em; + background-color: transparent; + cursor: pointer; + } + + &::-webkit-scrollbar { + width: .5em; + height: .5em; + + background-color: transparent; + cursor: pointer; + } + + &::-webkit-scrollbar-thumb { + border-radius: .25em; + background-color: #555; + } + + &::-webkit-scrollbar-corner { + //background: #19191b; + background-color: transparent; + } + + /* End scroll bar */ + + .filler { + margin-top: auto; + } + + .message { + display: inline-block; + word-break: break-all; + + &.error { + color: #c90709; + } + + &.centered { + align-self: center; + } + } +} + +/* button look */ +.btn { + cursor: pointer; + + background-color: rgba(0, 0, 0, 0.5); + + border-width: 0; + border-radius: .2em; + border-style: solid; + + color: #7c7c7c; + + padding: .25em 1em; + + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, .14), 0 3px 1px -2px rgba(0, 0, 0, .2), 0 1px 5px 0 rgba(0, 0, 0, .12); + + &:hover { + background-color: #0a0a0a; + } + + &:disabled { + box-shadow: none; + background-color: rgba(0, 0, 0, 0.27); + + &:hover { + background-color: rgba(0, 0, 0, 0.27); + } + } + + &.btn-success, &.btn-green { + border-bottom-width: 2px; + border-bottom-color: #389738; + } + + &.btn-info, &.btn-blue { + border-bottom-width: 2px; + border-bottom-color: #386896; + } + + &.btn-red { + border-bottom-width: 2px; + border-bottom-color: #973838; + } + + transition: background-color .3s ease-in-out; +} + +select { + outline: none; + background: transparent; + border: none; + color: #999; +} \ No newline at end of file diff --git a/modules/core/windows/client-updater/renderer/index.ts b/modules/core/windows/client-updater/renderer/index.ts new file mode 100644 index 0000000..cca0082 --- /dev/null +++ b/modules/core/windows/client-updater/renderer/index.ts @@ -0,0 +1,271 @@ +import { + ipcRenderer +} from "electron"; +import moment = require("moment"); + +const buttonCancel = document.getElementById("button-cancel"); +const buttonSubmit = document.getElementById("button-submit"); + +const containerUpdateInfo = document.getElementById("container-info"); +const containerUpdateExecute = document.getElementById("container-execute"); + +const updateStatusContainer = document.getElementById("update-availability-status"); +const updateChannelSelect = document.getElementById("update-channel") as HTMLSelectElement; + +const updateExecuteLog = document.getElementById("update-execute-log"); +const updateExecuteProgress = document.getElementById("update-execute-progress"); + +let dotIndex = 0; +setInterval(() => { + dotIndex++; + let dots = "."; + for(let index = 0; index < dotIndex % 3; index++) { dots += "."; } + + for(const dotContainer of document.getElementsByClassName("loading-dots")) { + dotContainer.innerHTML = dots; + } +}, 500); + +const resetUpdateChannelDropdown = () => { + while(updateChannelSelect.options.length > 0) { + updateChannelSelect.options.remove(0); + } + + for(const defaultOption of [{ text: "", value: "loading"}, {text: "???", value: "unknown" }]) { + const element = document.createElement("option"); + element.text = defaultOption.text; + element.value = defaultOption.value; + element.style.display = "none"; + updateChannelSelect.options.add(element); + } + + updateChannelSelect.onchange = undefined; + updateChannelSelect.value = "loading"; +} + +ipcRenderer.on("client-updater-channel-info", (_event, available: string[], current: string) => { + resetUpdateChannelDropdown(); + + if(available.indexOf(current) === -1) { + available.push(current); + } + + for(const channel of available) { + const element = document.createElement("option"); + element.text = channel; + element.value = channel; + updateChannelSelect.options.add(element); + } + + updateChannelSelect.value = current; + updateChannelSelect.onchange = () => { + const value = updateChannelSelect.value; + if(value === "loading" || value === "unknown") { + return; + } + + console.error("Update channel changed to %o", value); + ipcRenderer.send("client-updater-set-channel", value); + initializeVersionsView(false); + } +}); + +ipcRenderer.on("client-updater-local-status", (_event, localVersion: string, buildTimestamp: number) => { + document.getElementById("local-client-version").innerHTML = localVersion; + document.getElementById("local-build-timestamp").innerHTML = moment(buildTimestamp).format("LTS, LL"); +}); + +ipcRenderer.on("client-updater-set-error", (_event, message) => { + for(const child of updateStatusContainer.querySelectorAll(".shown")) { + child.classList.remove("shown"); + } + + const unavailableContainer = updateStatusContainer.querySelector(".unavailable"); + if(unavailableContainer) { + unavailableContainer.classList.add("shown"); + + const h2 = unavailableContainer.querySelector("h2"); + const h3 = unavailableContainer.querySelector("h3"); + + if(h2) { + h2.innerHTML = "Update failed!"; + } + if(h3) { + h3.innerHTML = message; + } + } + + /* TODO: Find out the current view and set the error */ + + buttonSubmit.style.display = "none"; + buttonCancel.innerHTML = "Close"; +}); + +const resetRemoteInfo = () => { + document.getElementById("remote-client-version").innerText = ""; + document.getElementById("remote-build-timestamp").innerText = ""; +} + +ipcRenderer.on("client-updater-remote-status", (_event, updateAvailable: boolean, version: string, timestamp: number) => { + resetRemoteInfo(); + + for(const child of updateStatusContainer.querySelectorAll(".shown")) { + child.classList.remove("shown"); + } + + updateStatusContainer.querySelector(updateAvailable ? ".available" : ".up2date")?.classList.add("shown"); + + document.getElementById("remote-client-version").innerText = version; + document.getElementById("remote-build-timestamp").innerText = moment(timestamp).format("LTS, LL"); + + if(updateAvailable) { + const h3 = updateStatusContainer.querySelector(".available h3"); + if(h3) { + h3.innerHTML = "Update your client to " + version + "."; + } + buttonSubmit.innerHTML = "Update Client"; + buttonSubmit.style.display = null; + } +}); + +function currentLogDate() : string { + const now = new Date(); + return "<" + ("00" + now.getHours()).substr(-2) + ":" + ("00" + now.getMinutes()).substr(-2) + ":" + ("00" + now.getSeconds()).substr(-2) + "> "; +} + +let followBottom = true; +let followBottomAnimationFrame; +const logUpdateExecuteInfo = (type: "info" | "error", message: string, extraClasses?: string[]) => { + const element = document.createElement("div"); + + if(message.length === 0) { + element.innerHTML = " "; + } else { + element.textContent = (!extraClasses?.length ? currentLogDate() + " " : "") + message; + } + element.classList.add("message", type, ...(extraClasses ||[])); + updateExecuteLog.appendChild(element); + + if(!followBottomAnimationFrame && followBottom) { + followBottomAnimationFrame = requestAnimationFrame(() => { + followBottomAnimationFrame = undefined; + + if(!followBottom) { return; } + updateExecuteLog.scrollTop = updateExecuteLog.scrollHeight; + }); + } +} + +updateExecuteLog.onscroll = () => { + const bottomOffset = updateExecuteLog.scrollTop + updateExecuteLog.clientHeight; + followBottom = bottomOffset + 50 > updateExecuteLog.scrollHeight; +}; + + +ipcRenderer.on("client-updater-execute", () => initializeExecuteView()); + +ipcRenderer.on("client-updater-execute-log", (_event, type: "info" | "error", message: string) => { + message.split("\n").forEach(line => logUpdateExecuteInfo(type, line)) +}); + +const setExecuteProgress = (status: "normal" | "error" | "success", message: string, progress: number) => { + const barContainer = updateExecuteProgress.querySelector(".bar-container") as HTMLDivElement; + if(barContainer) { + [...barContainer.classList].filter(e => e.startsWith("type-")).forEach(klass => barContainer.classList.remove(klass)); + barContainer.classList.add("type-" + status); + } + const progressFiller = updateExecuteProgress.querySelector(".filler") as HTMLDivElement; + if(progressFiller) { + progressFiller.style.width = (progress * 100) + "%"; + } + + const progressText = updateExecuteProgress.querySelector(".text") as HTMLDivElement; + if(progressText) { + progressText.textContent = (progress * 100).toFixed() + "%"; + } + + const progressInfo = updateExecuteProgress.querySelector(".info") as HTMLDivElement; + if(progressInfo) { + progressInfo.textContent = message; + } +} + +ipcRenderer.on("client-updater-execute-progress", (_event, message: string, progress: number) => setExecuteProgress("normal", message, progress)); + +ipcRenderer.on("client-updater-execute-finish", (_event, error: string | undefined) => { + logUpdateExecuteInfo("info", ""); + logUpdateExecuteInfo("info", "Update result", ["centered"]); + logUpdateExecuteInfo("info", ""); + + buttonCancel.style.display = null; + if(error) { + /* Update failed */ + logUpdateExecuteInfo("error", "Failed to execute update: " + error); + setExecuteProgress("error", "Update failed", 1); + + buttonSubmit.textContent = "Retry"; + buttonSubmit.style.display = null; + buttonSubmit.onclick = () => initializeVersionsView(true); + + buttonCancel.textContent = "Close"; + } else { + setExecuteProgress("success", "Update loaded", 1); + logUpdateExecuteInfo("info", "Update successfully loaded."); + logUpdateExecuteInfo("info", "Click \"Install Update\" to update your client."); + buttonSubmit.textContent = "Install Update"; + buttonSubmit.style.display = null; + buttonSubmit.onclick = () => ipcRenderer.send("install-update"); + + buttonCancel.textContent = "Abort Update"; + } +}); + +buttonCancel.onclick = () => { + ipcRenderer.send("client-updater-close"); +}; + +const initializeExecuteView = () => { + while(updateExecuteLog.firstChild) { + updateExecuteLog.removeChild(updateExecuteLog.firstChild); + } + + { + const filler = document.createElement("div"); + filler.classList.add("filler"); + updateExecuteLog.appendChild(filler); + } + + setExecuteProgress("normal", "Loading client update", 0); + + containerUpdateExecute.classList.add("shown"); + containerUpdateInfo.classList.remove("shown"); + + buttonCancel.style.display = "none"; + buttonSubmit.onclick = undefined; +} + +const initializeVersionsView = (queryLocalInfo: boolean) => { + containerUpdateExecute.classList.remove("shown"); + containerUpdateInfo.classList.add("shown"); + + for(const child of updateStatusContainer.querySelectorAll(".shown")) { + child.classList.remove("shown"); + } + updateStatusContainer.querySelector(".loading")?.classList.add("shown"); + resetUpdateChannelDropdown(); + resetRemoteInfo(); + + if(queryLocalInfo) { + ipcRenderer.send("client-updater-query-local-info"); + } + + ipcRenderer.send("client-updater-query-channels"); + ipcRenderer.send("client-updater-query-remote-info"); + buttonSubmit.onclick = () => ipcRenderer.send("execute-update"); + buttonSubmit.style.display = "none"; + buttonCancel.innerHTML = "Close"; +} + +initializeVersionsView(true); + +export = {}; \ No newline at end of file diff --git a/modules/core/windows/client-updater/renderer/logo.png b/modules/core/windows/client-updater/renderer/logo.png new file mode 100644 index 0000000..3be59fb Binary files /dev/null and b/modules/core/windows/client-updater/renderer/logo.png differ diff --git a/modules/core/windows/client-updater/renderer/unavailable.svg b/modules/core/windows/client-updater/renderer/unavailable.svg new file mode 100644 index 0000000..900f6ff --- /dev/null +++ b/modules/core/windows/client-updater/renderer/unavailable.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/modules/core/windows/client-updater/renderer/up2date.svg b/modules/core/windows/client-updater/renderer/up2date.svg new file mode 100644 index 0000000..ae0c181 --- /dev/null +++ b/modules/core/windows/client-updater/renderer/up2date.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/modules/core/windows/client-updater/renderer/update.svg b/modules/core/windows/client-updater/renderer/update.svg new file mode 100644 index 0000000..94a0f9f --- /dev/null +++ b/modules/core/windows/client-updater/renderer/update.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/modules/core/windows/main-window/controller/MainWindow.ts b/modules/core/windows/main-window/controller/MainWindow.ts new file mode 100644 index 0000000..382cc52 --- /dev/null +++ b/modules/core/windows/main-window/controller/MainWindow.ts @@ -0,0 +1,111 @@ +import {app, BrowserWindow, dialog} from "electron"; +import {dereferenceApp, referenceApp} from "../../../AppInstance"; +import {closeURLPreview, openURLPreview} from "../../../url-preview"; +import {loadWindowBounds, startTrackWindowBounds} from "../../../../shared/window"; +import {Arguments, processArguments} from "../../../../shared/process-arguments"; +import {allow_dev_tools} from "../../../main-window"; +import * as path from "path"; + +let windowInstance: BrowserWindow; + +export async function showMainWindow(entryPointUrl: string) { + if(windowInstance) { + throw "main window already initialized"; + } + + // Create the browser window. + console.log("Spawning main window"); + + referenceApp(); /* main browser window references the app */ + windowInstance = new BrowserWindow({ + width: 800, + height: 600, + + minHeight: 600, + minWidth: 600, + + show: false, + webPreferences: { + webSecurity: false, + nodeIntegrationInWorker: true, + nodeIntegration: true, + preload: path.join(__dirname, "..", "renderer", "PreloadScript.js") + }, + icon: path.join(__dirname, "..", "..", "..", "..", "resources", "logo.ico"), + }); + + windowInstance.webContents.on("certificate-error", (event, url, error, certificate, callback) => { + console.log("Allowing untrusted certificate for %o", url); + event.preventDefault(); + callback(true); + }); + + windowInstance.on('closed', () => { + windowInstance = undefined; + + app.releaseSingleInstanceLock(); + closeURLPreview().then(undefined); + dereferenceApp(); + }); + + windowInstance.webContents.on('new-window', (event, urlString, frameName, disposition, options, additionalFeatures) => { + if(frameName.startsWith("__modal_external__")) { + return; + } + + event.preventDefault(); + try { + let url: URL; + try { + url = new URL(urlString); + } catch(error) { + throw "failed to parse URL"; + } + + { + let protocol = url.protocol.endsWith(":") ? url.protocol.substring(0, url.protocol.length - 1) : url.protocol; + if(protocol !== "https" && protocol !== "http") { + throw "invalid protocol (" + protocol + "). HTTP(S) are only supported!"; + } + } + + openURLPreview(urlString).then(() => {}); + } catch(error) { + console.error("Failed to open preview window for URL %s: %o", urlString, error); + dialog.showErrorBox("Failed to open preview", "Failed to open preview URL: " + urlString + "\nError: " + error); + } + }); + + windowInstance.webContents.on('crashed', () => { + console.error("UI thread crashed! Closing app!"); + + if(!processArguments.has_flag(Arguments.DEBUG)) { + windowInstance.close(); + } + }); + + try { + await windowInstance.loadURL(entryPointUrl); + } catch (error) { + console.error("Failed to load UI entry point (%s): %o", entryPointUrl, error); + throw "failed to load entry point"; + } + + windowInstance.show(); + + loadWindowBounds('main-window', windowInstance).then(() => { + startTrackWindowBounds('main-window', windowInstance); + + windowInstance.focus(); + if(allow_dev_tools && !windowInstance.webContents.isDevToolsOpened()) { + windowInstance.webContents.openDevTools(); + } + }); +} + +export function closeMainWindow(force: boolean) { + windowInstance?.close(); + if(force) { + windowInstance?.destroy(); + } +} \ No newline at end of file diff --git a/modules/core/main-window/preload.ts b/modules/core/windows/main-window/renderer/PreloadScript.ts similarity index 79% rename from modules/core/main-window/preload.ts rename to modules/core/windows/main-window/renderer/PreloadScript.ts index 20b6814..017fdf9 100644 --- a/modules/core/main-window/preload.ts +++ b/modules/core/windows/main-window/renderer/PreloadScript.ts @@ -6,7 +6,7 @@ declare global { } } -window.__native_client_init_hook = () => require("../../renderer/index"); +window.__native_client_init_hook = () => require("../../../../renderer/index"); window.__native_client_init_shared = webpackRequire => window["shared-require"] = webpackRequire; export = {}; \ No newline at end of file diff --git a/modules/crash_handler/index.ts b/modules/crash_handler/index.ts index 38b5c59..bddc386 100644 --- a/modules/crash_handler/index.ts +++ b/modules/crash_handler/index.ts @@ -1,5 +1,5 @@ require("../shared/require").setup_require(module); -import {app, BrowserWindow, remote} from "electron"; +import {app, BrowserWindow, dialog, remote} from "electron"; import * as path from "path"; import * as electron from "electron"; import * as os from "os"; @@ -18,24 +18,24 @@ export function handle_crash_callback(args: string[]) { } console.log("Received crash dump callback. Arguments: %o", parameter); - let error = undefined; - let crash_file = undefined; + let error; + let crashFile; if(parameter["success"] == true) { /* okey we have an crash dump */ - crash_file = parameter["dump_path"]; - if(typeof(crash_file) === "string") { + crashFile = parameter["dump_path"]; + if(typeof(crashFile) === "string") { try { - crash_file = Buffer.from(crash_file, 'base64').toString(); + crashFile = Buffer.from(crashFile, 'base64').toString(); } catch(error) { console.warn("Failed to decode dump path: %o", error); - crash_file = undefined; + crashFile = undefined; error = "failed to decode dump path!"; } } } else if(typeof(parameter["error"]) === "string") { try { - error = Buffer.from(crash_file, 'base64').toString(); + error = Buffer.from(parameter["error"], 'base64').toString(); } catch(error) { console.warn("Failed to decode error: %o", error); error = "failed to decode error"; @@ -45,7 +45,7 @@ export function handle_crash_callback(args: string[]) { } app.on('ready', () => { - const crash_window = new BrowserWindow({ + const crashWindow = new BrowserWindow({ show: false, width: 1000, height: 300 + (os.platform() === "win32" ? 50 : 0), @@ -56,30 +56,38 @@ export function handle_crash_callback(args: string[]) { javascript: true } }); - crash_window.on('focus', event => crash_window.flashFrame(false)); + crashWindow.on('focus', event => crashWindow.flashFrame(false)); - crash_window.setMenu(null); - crash_window.loadURL(url.pathToFileURL(path.join(path.dirname(module.filename), "ui", "index.html")).toString()); - crash_window.on('ready-to-show', () => { - if(error) - crash_window.webContents.send('dump-error', error); - else if(!crash_file) - crash_window.webContents.send('dump-error', "Missing crash file"); - else - crash_window.webContents.send('dump-url', crash_file); - crash_window.show(); - crash_window.setProgressBar(1, {mode: "error"}); - crash_window.flashFrame(true); + crashWindow.setMenu(null); + crashWindow.loadURL(url.pathToFileURL(path.join(path.dirname(module.filename), "ui", "index.html")).toString()).catch(error => { + dialog.showErrorBox("Crash window failed to load", "Failed to load the crash window.\nThis indicates that something went incredible wrong.\n\nError:\n" + error); }); + + crashWindow.on('ready-to-show', () => { + if(error) { + crashWindow.webContents.send('dump-error', error); + } else if(!crashFile) { + crashWindow.webContents.send('dump-error', "Missing crash file"); + } else { + crashWindow.webContents.send('dump-url', crashFile); + } + + crashWindow.show(); + crashWindow.setProgressBar(1, { mode: "error" }); + crashWindow.flashFrame(true); + }); + app.on('window-all-closed', () => { process.exit(0); }); }); app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); } -export const handler = require( "teaclient_crash_handler"); -if(typeof window === "object") + +export const handler = require("teaclient_crash_handler"); +if(typeof window === "object") { (window as any).crash = handler; +} export function initialize_handler(component_name: string, requires_file: boolean) { const start_path = requires_file ? (" " + path.join(__dirname, "..", "..")) : ""; diff --git a/modules/crash_handler/ui/index.html b/modules/crash_handler/ui/index.html index 06358b8..d61a0b3 100644 --- a/modules/crash_handler/ui/index.html +++ b/modules/crash_handler/ui/index.html @@ -8,7 +8,7 @@
- + TeaClient - Crashed

Ooops, something went incredible wrong!

It seems like your TeaSpeak Client has been crashed.

@@ -17,7 +17,7 @@

Please report this crash to TeaSpeak and help improving the client!
- Official issue and bug tracker url: https://github.com/TeaSpeak/TeaClient/issues
+ Official issue and bug tracker url: https://github.com/TeaSpeak/TeaClient/issues
Attention: Crash reports without a crash dump file will be ignored!

diff --git a/modules/crash_handler/ui/index.ts b/modules/crash_handler/ui/index.ts index 18553ef..d91f669 100644 --- a/modules/crash_handler/ui/index.ts +++ b/modules/crash_handler/ui/index.ts @@ -1,6 +1,6 @@ import { shell, ipcRenderer } from "electron"; -function open_issue_tracker() { +function openIssueTracker() { shell.openExternal("https://github.com/TeaSpeak/TeaClient/issues"); } diff --git a/modules/renderer/dns/dns_resolver.ts b/modules/renderer/dns/dns_resolver.ts index 859d467..e94c80b 100644 --- a/modules/renderer/dns/dns_resolver.ts +++ b/modules/renderer/dns/dns_resolver.ts @@ -4,24 +4,17 @@ import {AddressTarget, ResolveOptions} from "tc-shared/dns"; import * as dns_handler from "tc-native/dns"; import {ServerAddress} from "tc-shared/tree/Server"; -export async function resolve_address(address: ServerAddress, _options?: ResolveOptions) : Promise { - /* backwards compatibility */ - if(typeof(address) === "string") { - address = { - host: address, - port: 9987 - } - } - +export function resolve_address(address: ServerAddress, _options?: ResolveOptions) : Promise { return new Promise((resolve, reject) => { dns_handler.resolve_cr(address.host, address.port, result => { - if(typeof(result) === "string") + if(typeof(result) === "string") { reject(result); - else + } else { resolve({ target_ip: result.host, target_port: result.port }); + } }); }) } diff --git a/modules/shared/proxy/Client.ts b/modules/shared/proxy/Client.ts index 63261fc..99f9e82 100644 --- a/modules/shared/proxy/Client.ts +++ b/modules/shared/proxy/Client.ts @@ -103,7 +103,7 @@ export class ObjectProxyClient> { }) as any; } - private handleIPCMessage(event: IpcRendererEvent, ...args: any[]) { + private handleIPCMessage(_event: IpcRendererEvent, ...args: any[]) { const actionType = args[0]; if(actionType === "notify-event") { diff --git a/modules/shared/proxy/Definitions.ts b/modules/shared/proxy/Definitions.ts index bf12e27..fbbd263 100644 --- a/modules/shared/proxy/Definitions.ts +++ b/modules/shared/proxy/Definitions.ts @@ -18,7 +18,7 @@ export abstract class ProxiedClass; - public constructor(props: ProxiedClassProperties) { + protected constructor(props: ProxiedClassProperties) { this.ownerWindowId = props.ownerWindowId; this.instanceId = props.instanceId; this.events = props.events; diff --git a/modules/shared/proxy/Server.ts b/modules/shared/proxy/Server.ts index 2c4089c..c8a4f5f 100644 --- a/modules/shared/proxy/Server.ts +++ b/modules/shared/proxy/Server.ts @@ -67,7 +67,7 @@ export class ObjectProxyServer> { private generateEventProxy(instanceId: string, owningWindowId: number) : {} { const ipcChannel = this.ipcChannel; return new Proxy({ }, { - get(target: { }, event: PropertyKey, receiver: any): any { + get(target: { }, event: PropertyKey, _receiver: any): any { return (...args: any) => { const window = BrowserWindow.fromId(owningWindowId); if(!window) return; diff --git a/modules/shared/version/index.ts b/modules/shared/version/index.ts index f56dcf3..7a0e735 100644 --- a/modules/shared/version/index.ts +++ b/modules/shared/version/index.ts @@ -1,15 +1,17 @@ export class Version { - major: number = 0; - minor: number = 0; - patch: number = 0; - build: number = 0; - timestamp: number = 0; + major: number; + minor: number; + patch: number; + build: number; + + timestamp: number; constructor(major: number, minor: number, patch: number, build: number, timestamp: number) { this.major = major; this.minor = minor; this.patch = patch; this.build = build; + this.timestamp = timestamp; } toString(timestamp: boolean = false) { @@ -17,10 +19,13 @@ export class Version { result += this.major + "."; result += this.minor + "."; result += this.patch; - if(this.build > 0) + if(this.build > 0) { result += "-" + this.build; - if(timestamp && this.timestamp > 0) + } + + if(timestamp && this.timestamp > 0) { result += " [" + this.timestamp + "]"; + } return result; } @@ -33,12 +38,11 @@ export class Version { if(other.minor != this.minor) return false; if(other.patch != this.patch) return false; if(other.build != this.build) return false; - if(other.timestamp != this.timestamp) return false; - return true; + return other.timestamp == this.timestamp; } - newer_than(other: Version) : boolean { + newerThan(other: Version) : boolean { if(other.major > this.major) return false; else if(other.major < this.major) return true; @@ -54,13 +58,13 @@ export class Version { return false; } - in_dev() : boolean { + isDevelopmentVersion() : boolean { return this.build == 0 && this.major == 0 && this.minor == 0 && this.patch == 0 && this.timestamp == 0; } } //1.0.0-2 [1000] -export function parse_version(version: string) : Version { +export function parseVersion(version: string) : Version { let result: Version = new Version(0, 0, 0, 0, 0); const roots = version.split(" "); diff --git a/modules/shared/window.ts b/modules/shared/window.ts index cc8c2df..bd0017a 100644 --- a/modules/shared/window.ts +++ b/modules/shared/window.ts @@ -9,7 +9,7 @@ import BrowserWindow = Electron.BrowserWindow; import Rectangle = Electron.Rectangle; let changedData: {[key: string]:Rectangle} = {}; -let changedDataSaveTimeout: NodeJS.Timer; +let changedDataSaveTimeout: number; export async function save_changes() { clearTimeout(changedDataSaveTimeout); diff --git a/native/serverconnection/CMakeLists.txt b/native/serverconnection/CMakeLists.txt index ddcffbd..e57da70 100644 --- a/native/serverconnection/CMakeLists.txt +++ b/native/serverconnection/CMakeLists.txt @@ -144,7 +144,7 @@ set(REQUIRED_LIBRARIES ${LIBEVENT_STATIC_LIBRARIES} ${StringVariable_LIBRARIES_STATIC} - ${DataPipes_LIBRARIES_STATIC} #Needs to be static because something causes ca bad function call when loaded in electron + DataPipes::core::static ${ThreadPool_LIBRARIES_STATIC} ${soxr_LIBRARIES_STATIC} ${fvad_LIBRARIES_STATIC} diff --git a/native/serverconnection/src/audio/codec/OpusConverter.cpp b/native/serverconnection/src/audio/codec/OpusConverter.cpp index 411a675..ec5fc26 100644 --- a/native/serverconnection/src/audio/codec/OpusConverter.cpp +++ b/native/serverconnection/src/audio/codec/OpusConverter.cpp @@ -5,7 +5,7 @@ using namespace std; using namespace tc::audio::codec; OpusConverter::OpusConverter(size_t c, size_t s, size_t f) : Converter(c, s, f) { } -OpusConverter::~OpusConverter() {} +OpusConverter::~OpusConverter() = default; bool OpusConverter::valid() { return this->encoder && this->decoder; diff --git a/native/serverconnection/src/audio/js/AudioConsumer.cpp b/native/serverconnection/src/audio/js/AudioConsumer.cpp index b0295a8..50a664f 100644 --- a/native/serverconnection/src/audio/js/AudioConsumer.cpp +++ b/native/serverconnection/src/audio/js/AudioConsumer.cpp @@ -457,7 +457,7 @@ NAN_METHOD(AudioConsumerWrapper::_set_filter_mode) { return; } - auto value = info[0].As()->ToInteger()->Value(); + auto value = info[0].As()->Int32Value(info.GetIsolate()->GetCurrentContext()).FromMaybe(0); handle->filter_mode_ = (FilterMode) value; } @@ -474,5 +474,5 @@ NAN_METHOD(AudioConsumerWrapper::toggle_rnnoise) { return; } - handle->rnnoise = info[0]->BooleanValue(); + handle->rnnoise = info[0]->BooleanValue(info.GetIsolate()); } \ No newline at end of file diff --git a/native/serverconnection/src/audio/sounds/SoundPlayer.cpp b/native/serverconnection/src/audio/sounds/SoundPlayer.cpp index 73e06ef..fa4eeac 100644 --- a/native/serverconnection/src/audio/sounds/SoundPlayer.cpp +++ b/native/serverconnection/src/audio/sounds/SoundPlayer.cpp @@ -276,7 +276,7 @@ NAN_METHOD(tc::audio::sounds::playback_sound_js) { PlaybackSettings settings{}; settings.file = *Nan::Utf8String(file); - settings.volume = volume->Value(); + settings.volume = (float) volume->Value(); if(!callback.IsEmpty()) { if(!callback->IsFunction()) { Nan::ThrowError("invalid callback function"); diff --git a/native/serverconnection/src/bindings.cpp b/native/serverconnection/src/bindings.cpp index 7f4ab4b..51abb01 100644 --- a/native/serverconnection/src/bindings.cpp +++ b/native/serverconnection/src/bindings.cpp @@ -70,7 +70,10 @@ tc::audio::AudioOutput* global_audio_output; Nan::Set(object, (uint32_t) value, Nan::New(key).ToLocalChecked()); NAN_MODULE_INIT(init) { - logger::initialize_node(); + /* FIXME: Reenable */ + //logger::initialize_node(); + logger::initialize_raw(); + #ifndef WIN32 logger::info(category::general, tr("Hello World from C. PPID: {}, PID: {}"), getppid(), getpid()); #else diff --git a/native/serverconnection/src/connection/ServerConnection.cpp b/native/serverconnection/src/connection/ServerConnection.cpp index 5b17fb4..72131f9 100644 --- a/native/serverconnection/src/connection/ServerConnection.cpp +++ b/native/serverconnection/src/connection/ServerConnection.cpp @@ -631,10 +631,12 @@ void ServerConnection::close_connection() { } this->event_loop_execute_connection_close = false; - if(this->socket) - this->socket->finalize(); - if(this->protocol_handler) - this->protocol_handler->do_close_connection(); + if(this->socket) { + this->protocol_handler->do_close_connection(); + } + if(this->protocol_handler) { + this->protocol_handler->do_close_connection(); + } this->socket = nullptr; this->call_disconnect_result.call(0, true); diff --git a/native/serverconnection/src/connection/audio/VoiceConnection.cpp b/native/serverconnection/src/connection/audio/VoiceConnection.cpp index 3f09b33..d1f4998 100644 --- a/native/serverconnection/src/connection/audio/VoiceConnection.cpp +++ b/native/serverconnection/src/connection/audio/VoiceConnection.cpp @@ -368,10 +368,11 @@ void VoiceConnection::process_packet(const std::shared_ptrdata().length() > 5) - client->process_packet(packet_id, packet->data().range(5), (codec::value) codec_id, flag_head); - else - client->process_packet(packet_id, pipes::buffer_view{nullptr, 0}, (codec::value) codec_id, flag_head); + if(packet->data().length() > 5) { + client->process_packet(packet_id, packet->data().range(5), (codec::value) codec_id, flag_head); + } else { + client->process_packet(packet_id, pipes::buffer_view{nullptr, 0}, (codec::value) codec_id, flag_head); + } } else { //TODO implement whisper } diff --git a/native/serverconnection/test/js/RequireHandler.ts b/native/serverconnection/test/js/RequireHandler.ts new file mode 100644 index 0000000..2e75b75 --- /dev/null +++ b/native/serverconnection/test/js/RequireHandler.ts @@ -0,0 +1,23 @@ +import * as path from "path"; +import * as os from "os"; + +const Module = require("module"); + +const originalRequire = Module._load; +Module._load = (module, ...args) => { + if(module === "tc-native/connection") { + let build_type; + console.error(os.platform()); + if(os.platform() === "win32") { + build_type = "win32_x64"; + } else { + build_type = "linux_x64"; + } + + return originalRequire(path.join(__dirname, "..", "..", "..", "build", build_type, "teaclient_connection.node"), ...args); + } else { + return originalRequire(module, ...args); + } +}; + +export = {}; \ No newline at end of file diff --git a/native/serverconnection/test/js/flood.ts b/native/serverconnection/test/js/flood.ts index d3c3d87..85200ff 100644 --- a/native/serverconnection/test/js/flood.ts +++ b/native/serverconnection/test/js/flood.ts @@ -1,37 +1,38 @@ -/// +import "./RequireHandler"; -module.paths.push("../../build/linux_x64"); - -import * as fs from "fs"; -import * as handle from "teaclient_connection"; -import {NativeServerConnection} from "teaclient_connection"; +import * as handle from "tc-native/connection"; +import {NativeServerConnection} from "tc-native/connection"; //remote_host: "51.68.181.92", //remote_host: "94.130.236.135", //remote_host: "54.36.232.11", /* the beast */ //remote_host: "79.133.54.207", /* gommehd.net */ -const target_address = "51.68.181.92"; +const target_address = "127.0.0.1"; const { host, port } = { host: target_address.split(":")[0], port: target_address.split(":").length > 1 ? parseInt(target_address.split(":")[1]) : 9987 }; + class Bot { connection: NativeServerConnection; - channel_ids: number[] = []; + knwonChannelIds: number[] = []; client_id: number; initialized: boolean; - private _interval = []; + private switchInterval = []; private _timeouts = []; - connect() { - for(const interval of this._interval) + reset() { + this.connection = undefined; + for(const interval of this.switchInterval) clearInterval(interval); for(const timeouts of this._timeouts) clearInterval(timeouts); + } - this.channel_ids = []; + connect() { + this.knwonChannelIds = []; this.client_id = 0; this.initialized = false; @@ -69,6 +70,7 @@ class Bot { ], []); } else { console.log("Bot connect failed: %o (%s) ", error, this.connection.error_message(error)); + this.reset(); } }, @@ -77,12 +79,16 @@ class Bot { }); this.connection.callback_command = (command, args, switches) => this.handle_command(command, args); - this.connection.callback_disconnect = () => this.disconnect(); + this.connection.callback_disconnect = () => { + this.connection = undefined; + this.reset(); + } } async disconnect() { await new Promise(resolve => this.connection.disconnect("bb", resolve)); this.connection = undefined; + this.reset(); } private handle_command(command: string, args: any[]) { @@ -90,35 +96,51 @@ class Bot { this.client_id = parseInt(args[0]["aclid"]); } else if(command == "channellistfinished"){ this.initialized = true; - - this._interval.push(setInterval(() => this.switch_channel(), 1000)); + this.switchInterval.push(setInterval(() => this.switch_channel(), 30_000 + Math.random() * 10_000)); } else if(command == "channellist") { for(const element of args) { - this.channel_ids.push(parseInt(element["cid"])); + this.knwonChannelIds.push(parseInt(element["cid"])); } } else if(command == "notifychannelcreated") { - this.channel_ids.push(parseInt(args[0]["cid"])); + this.knwonChannelIds.push(parseInt(args[0]["cid"])); } else if(command == "notifychanneldeleted") { for(const arg of args) { const channel_id = parseInt(arg["cid"]); - const index = this.channel_ids.indexOf(channel_id); + const index = this.knwonChannelIds.indexOf(channel_id); if(index >= 0) - this.channel_ids.splice(index, 1); + this.knwonChannelIds.splice(index, 1); } } } private switch_channel() { - const target_channel = this.channel_ids[Math.floor((Math.random() * 100000) % this.channel_ids.length)]; + const target_channel = this.knwonChannelIds[Math.floor((Math.random() * 100000) % this.knwonChannelIds.length)]; console.log("Switching to channel %d", target_channel); this.connection.send_command("clientmove", [{clid: this.client_id, cid: target_channel}], []); } } -const bot_list = []; -for(let index = 0; index < 1; index++) { - const bot = new Bot(); - bot_list.push(bot); - bot.connect(); -} \ No newline at end of file +const bot_list: Bot[] = []; + +async function connectBots() { + for(let index = 0; index < 5; index++) { + const bot = new Bot(); + bot_list.push(bot); + bot.connect(); + + await new Promise(resolve => setTimeout(resolve, 10_000)); + } +} + +setInterval(() => { + bot_list.forEach(connection => { + if(connection.connection) { + connection.connection.send_voice_data(new Uint8Array([1, 2, 3]), 5, false); + } else { + connection.connect(); + } + }); +}, 5); + +connectBots().then(undefined); \ No newline at end of file diff --git a/native/serverconnection/test/js/main.ts b/native/serverconnection/test/js/main.ts index afee24a..8b7348b 100644 --- a/native/serverconnection/test/js/main.ts +++ b/native/serverconnection/test/js/main.ts @@ -1,18 +1,14 @@ -/// -console.log("HELLO WORLD"); -module.paths.push("../../build/linux_x64"); -module.paths.push("../../build/win32_x64"); +import "./RequireHandler"; -//LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libasan.so.5 -const os = require('os'); -//process.dlopen(module, '/usr/lib/x86_64-linux-gnu/libasan.so.5', -// os.constants.dlopen.RTLD_NOW); -import * as fs from "fs"; +const kPreloadAsan = false; +if(kPreloadAsan) { + //LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libasan.so.5 + const os = require('os'); + // @ts-ignore + process.dlopen(module, '/usr/lib/x86_64-linux-gnu/libasan.so.5', os.constants.dlopen.RTLD_NOW); +} -const original_require = require; -require = (module => original_require(__dirname + "/../../../build/win32_x64/" + module + ".node")) as any; -import * as handle from "teaclient_connection"; -require = original_require; +import * as handle from "tc-native/connection"; const connection_list = []; const connection = handle.spawn_server_connection(); @@ -132,38 +128,14 @@ const do_connect = (connection) => { console.log("Received error: %o", arguments1); return; } - console.log("Command %s: %o", command, arguments1); - if(command === "channellistfinished") { - //115 - //connection.send_command("clientgetvariables", [{ clid: 1 }], []); - //connection.send_command("channelsubscribeall", [], []); - connection.send_command("playlistsonglist", [{ playlist_id: '12' }], []); - /* - setInterval(() => { - connection.send_command("servergroupclientlist", [{ sgid: 2 }], []); - connection.send_command("servergrouppermlist", [{ sgid: 2 }], []); - }, 1000); - */ - } + console.log("Command %s: %o", command, arguments1); }; - connection._voice_connection.register_client(7); + //connection._voice_connection.register_client(2); }; do_connect(connection); -/* -let _connections = []; -let i = 0; -let ii = setInterval(() => { - if(i++ > 35) - clearInterval(ii); - const c = handle.spawn_server_connection(); - _connections.push(c); - do_connect(c); -}, 500); -*/ - connection.callback_voice_data = (buffer, client_id, codec_id, flag_head, packet_id) => { console.log("Received voice of length %d from client %d in codec %d (Head: %o | ID: %d)", buffer.byteLength, client_id, codec_id, flag_head, packet_id); connection.send_voice_data(buffer, codec_id, flag_head); @@ -179,7 +151,7 @@ setInterval(() => { /* keep the object alive */ setTimeout(() => { connection.connected(); - _connections.forEach(e => e.current_ping()); }, 1000); -connection_list.push(connection); \ No newline at end of file +connection_list.push(connection); +export default {}; \ No newline at end of file diff --git a/native/serverconnection/tsconfig.json b/native/serverconnection/tsconfig.json new file mode 100644 index 0000000..50c66ff --- /dev/null +++ b/native/serverconnection/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "esModuleInterop": true + }, + "include": [ + "exports/exports.d.ts", + "test/js/" + ] +} \ No newline at end of file diff --git a/native/updater/config.cpp b/native/updater/config.cpp index f96e8ac..f4f6cc6 100644 --- a/native/updater/config.cpp +++ b/native/updater/config.cpp @@ -56,6 +56,7 @@ bool config::load(std::string &error, const std::string &file) { config::locking_files.push_back(entry); } } + { json moves; get(moves, value, "moves"); @@ -68,6 +69,11 @@ bool config::load(std::string &error, const std::string &file) { config::moving_actions.push_back(entry); } } + + if(value.contains("permission-test-directory")) { + get(config::permission_test_directory, value, "permission-test-directory"); + } + logger::debug("Loaded %d locking actions and %d moving actions", config::locking_files.size(), config::moving_actions.size()); return true; } \ No newline at end of file diff --git a/native/updater/config.h b/native/updater/config.h index de4e7fe..e9a81d5 100644 --- a/native/updater/config.h +++ b/native/updater/config.h @@ -4,6 +4,7 @@ #include #include #include +#include namespace config { extern bool load(std::string& /* error */, const std::string& /* file */); @@ -33,6 +34,8 @@ namespace config { _extern std::string callback_argument_fail; _extern std::string callback_argument_success; + _extern std::optional permission_test_directory; + _extern std::deque> locking_files; _extern std::deque> moving_actions; } \ No newline at end of file diff --git a/native/updater/file.cpp b/native/updater/file.cpp index 327503f..75ff3fd 100644 --- a/native/updater/file.cpp +++ b/native/updater/file.cpp @@ -285,4 +285,53 @@ void file::commit() { } return true; } -#endif \ No newline at end of file +#endif + +#ifdef WIN32 +bool CanAccessFolder(LPCTSTR folderName, DWORD genericAccessRights) +{ + bool bRet = false; + DWORD length = 0; + if (!::GetFileSecurity(folderName, OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, nullptr, 0, &length) && ERROR_INSUFFICIENT_BUFFER == ::GetLastError()) { + auto security = static_cast(::malloc(length)); + if (security && ::GetFileSecurity(folderName, OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, security, length, &length )) { + HANDLE hToken = NULL; + if (::OpenProcessToken( ::GetCurrentProcess(), TOKEN_IMPERSONATE | TOKEN_QUERY | + TOKEN_DUPLICATE | STANDARD_RIGHTS_READ, &hToken )) { + HANDLE hImpersonatedToken = NULL; + if (::DuplicateToken( hToken, SecurityImpersonation, &hImpersonatedToken )) { + GENERIC_MAPPING mapping = { 0xFFFFFFFF }; + PRIVILEGE_SET privileges = { 0 }; + DWORD grantedAccess = 0, privilegesLength = sizeof( privileges ); + BOOL result = FALSE; + + mapping.GenericRead = FILE_GENERIC_READ; + mapping.GenericWrite = FILE_GENERIC_WRITE; + mapping.GenericExecute = FILE_GENERIC_EXECUTE; + mapping.GenericAll = FILE_ALL_ACCESS; + + ::MapGenericMask( &genericAccessRights, &mapping ); + if (::AccessCheck( security, hImpersonatedToken, genericAccessRights, + &mapping, &privileges, &privilegesLength, &grantedAccess, &result )) { + bRet = (result == TRUE); + } + ::CloseHandle( hImpersonatedToken ); + } + ::CloseHandle( hToken ); + } + ::free( security ); + } + } + + return bRet; +} +#endif + +bool file::directory_writeable(const std::string &path) { +#ifdef WIN32 + return CanAccessFolder(path.c_str(), GENERIC_WRITE); +#else + /* TODO: Check for file permissions? Is this method even needed? */ + return false; +#endif +} \ No newline at end of file diff --git a/native/updater/file.h b/native/updater/file.h index 07fb085..346306e 100644 --- a/native/updater/file.h +++ b/native/updater/file.h @@ -16,5 +16,10 @@ namespace file { extern void rollback(); extern void commit(); + /** + * @param path The target path to test + * @returns true if the target path is writeable or if it does not exists is createable. + */ + extern bool directory_writeable(const std::string &path /* file */); extern bool file_locked(const std::string& file); } \ No newline at end of file diff --git a/native/updater/main.cpp b/native/updater/main.cpp index 2fa8ea6..5b8dc75 100644 --- a/native/updater/main.cpp +++ b/native/updater/main.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include "./logger.h" #include "./config.h" @@ -82,6 +83,15 @@ static bool daemonize() { } #endif +bool requires_permission_elevation() { + if(!config::permission_test_directory.has_value()) { + /* Old clients don't provide that. We assume yes. */ + return true; + } + + return file::directory_writeable(*config::permission_test_directory); +} + std::string log_file_path; int main(int argc, char** argv) { srand((unsigned int) chrono::floor(chrono::system_clock::now().time_since_epoch()).count()); @@ -115,7 +125,7 @@ int main(int argc, char** argv) { #endif #ifdef WIN32 - { + if(requires_permission_elevation()) { auto admin = is_administrator(); logger::info("App executed as admin: %s", admin ? "yes" : "no"); if(!admin) { diff --git a/package-lock.json b/package-lock.json index f87cdf1..b20548e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "TeaClient", - "version": "1.4.10", + "version": "1.4.13", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -160,6 +160,14 @@ "defer-to-connect": "^1.0.1" } }, + "@types/ajv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/ajv/-/ajv-1.0.0.tgz", + "integrity": "sha1-T7JEB0Ly9sMOf7B5e4OfxvaWaCo=", + "requires": { + "ajv": "*" + } + }, "@types/bluebird": { "version": "3.5.30", "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.30.tgz", @@ -177,6 +185,14 @@ "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" }, + "@types/cross-spawn": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz", + "integrity": "sha512-KuwNhp3eza+Rhu8IFI5HUXRP0LIhqH5cAjubUvGXXthh4YYBuP2ntwEX+Cz8GJoZUHlKo247wPWOfA9LYEq4cw==", + "requires": { + "@types/node": "*" + } + }, "@types/ejs": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-2.7.0.tgz", @@ -225,6 +241,11 @@ "@types/sizzle": "*" } }, + "@types/json-stable-stringify": { + "version": "1.0.32", + "resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.0.32.tgz", + "integrity": "sha512-q9Q6+eUEGwQkv4Sbst3J4PNgDOvpuVuKj79Hl/qnmBMEIPzB5QoFRUtjcgcg2xNUZyYUGXBk5wYIBKHt0A+Mxw==" + }, "@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", @@ -1602,6 +1623,11 @@ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true }, + "deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" + }, "defaults": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", @@ -4126,11 +4152,34 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, + "json-stable-stringify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", + "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", + "requires": { + "jsonify": "~0.0.0" + } + }, "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, + "json5": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", + "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==", + "requires": { + "minimist": "^1.2.5" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + } + } + }, "jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -4139,6 +4188,11 @@ "graceful-fs": "^4.1.6" } }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=" + }, "jsprim": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.0.tgz", @@ -5250,8 +5304,7 @@ "path-parse": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", - "dev": true + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" }, "path-type": { "version": "1.1.0", @@ -5716,7 +5769,6 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.15.1.tgz", "integrity": "sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==", - "dev": true, "requires": { "path-parse": "^1.0.6" } @@ -6709,6 +6761,24 @@ "utf8-byte-length": "^1.0.1" } }, + "tsconfig-loader": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tsconfig-loader/-/tsconfig-loader-1.1.0.tgz", + "integrity": "sha512-KrFF45RYo/JHpoAp1Lf68NupYNyRmh7BwSh1AmAQ3fdCMl8laOyZSLO5iByQR2VTkVdt454HS3c5kfVeYWq7iQ==", + "requires": { + "deepmerge": "^4.2.2", + "json5": "^2.1.1", + "resolve": "^1.15.1", + "strip-bom": "^4.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==" + } + } + }, "tslib": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", @@ -6747,8 +6817,252 @@ "typescript": { "version": "3.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz", - "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==", - "dev": true + "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==" + }, + "typescript-json-schema": { + "version": "0.38.3", + "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.38.3.tgz", + "integrity": "sha512-+13qUoBUQwOXqxUoYQWtLA9PEM7ojfv8r+hYc2ebeqqVwVM4+yI5JSlsYRBlJKKewc9q1FHqrMR6L6d9TNX9Dw==", + "requires": { + "glob": "~7.1.4", + "json-stable-stringify": "^1.0.1", + "typescript": "^3.5.1", + "yargs": "^13.2.4" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "requires": { + "locate-path": "^3.0.0" + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } + }, + "typescript-json-validator": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/typescript-json-validator/-/typescript-json-validator-2.4.2.tgz", + "integrity": "sha512-4oliZJGo8jwRAWxssz1n7KiNo21AwN/XqXm8l66k1sH3emqrulR2EGjsNfLV95/JD07C1YIkFlvClOlNANghag==", + "requires": { + "@types/ajv": "^1.0.0", + "@types/cross-spawn": "^6.0.0", + "@types/glob": "^7.1.1", + "@types/json-stable-stringify": "^1.0.32", + "@types/minimatch": "^3.0.3", + "cross-spawn": "^6.0.5", + "glob": "^7.1.3", + "json-stable-stringify": "^1.0.1", + "minimatch": "^3.0.4", + "tsconfig-loader": "^1.1.0", + "typescript-json-schema": "^0.38.3", + "yargs": "^13.2.4" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "requires": { + "locate-path": "^3.0.0" + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + } + } }, "undefsafe": { "version": "2.0.2", diff --git a/package.json b/package.json index 323525f..04108a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "TeaClient", - "version": "1.4.13", + "version": "1.5.0", "description": "", "main": "main.js", "scripts": { @@ -13,8 +13,10 @@ "start-devel-download": "electron . --disable-hardware-acceleration --gdb --debug --updater-ui-loader_type=2 --updater-ui-ignore-version -t -u http://localhost:8081/", "start-s": "electron . --disable-hardware-acceleration --gdb --debug --updater-ui-loader_type=3 --updater-ui-ignore-version -t -u http://localhost:8081/", "dtest": "electron . dtest", + "sass": "sass", "compile-sass": "sass --update .:.", "compile-tsc": "tsc", + "compile-json-validator": "sh generate-json-validators.sh", "build-linux-64": "node installer/build.js linux", "package-linux-64": "node installer/package_linux.js", "build-windows-64": "node installer/build.js win32", @@ -84,6 +86,7 @@ "sshpk": "^1.16.1", "tar-stream": "^2.1.2", "tough-cookie": "^3.0.1", + "typescript-json-validator": "^2.4.2", "url-regex": "^5.0.0", "v8-callsites": "latest" },