From 467d228e23e4efe78c8f675f2f983f7e84866b82 Mon Sep 17 00:00:00 2001 From: WolverinDEV Date: Wed, 2 Dec 2020 18:08:49 +0100 Subject: [PATCH] Updates for 1.5.0 --- generate-json-validators.sh | 6 + github | 2 +- installer/build.ts | 78 +- installer/deploy/index.ts | 8 +- installer/package.ts | 2 +- installer/package_linux.ts | 4 +- installer/package_windows.ts | 14 +- jenkins/create_build.sh | 18 +- main.ts | 3 +- modules/core/AppInstance.ts | 12 +- modules/core/app-updater/AppInfoFile.ts | 21 + .../core/app-updater/AppInfoFile.validator.ts | 74 ++ modules/core/app-updater/UpdateConfigFile.ts | 6 + .../app-updater/UpdateConfigFile.validator.ts | 41 + modules/core/app-updater/changelog/index.ts | 53 +- modules/core/app-updater/index.ts | 869 ++++++++---------- modules/core/main-window/index.ts | 233 ++--- modules/core/main.ts | 25 +- modules/core/render-backend/index.ts | 5 +- modules/core/ui-loader/Cache.ts | 97 ++ modules/core/ui-loader/CacheFile.ts | 35 + modules/core/ui-loader/CacheFile.validator.ts | 145 +++ modules/core/ui-loader/Loader.ts | 322 +++++++ modules/core/ui-loader/Remote.ts | 125 +++ modules/core/ui-loader/RemoteData.ts | 0 modules/core/ui-loader/Shipped.ts | 52 ++ modules/core/ui-loader/ShippedFileInfo.ts | 10 + .../ui-loader/ShippedFileInfo.validator.ts | 57 ++ modules/core/ui-loader/graphical.ts | 164 ---- modules/core/ui-loader/index.ts | 2 - modules/core/ui-loader/loader.ts | 620 ------------- modules/core/ui-loader/local_ui_cache.ts | 138 --- modules/core/ui-loader/ui/img/logo.svg | 1 - modules/core/ui-loader/ui/loader.ts | 22 - modules/core/ui-loader/ui/loading_screen.html | 111 --- modules/core/ui-loader/ui/preload_page.html | 25 - modules/core/url-preview/html/inject.ts | 6 +- .../app-loader/controller/AppLoader.ts | 114 +++ .../windows/app-loader/renderer/img/logo.svg | 36 + .../app-loader/renderer}/img/smoke.png | Bin .../windows/app-loader/renderer/index.html | 23 + .../windows/app-loader/renderer/index.scss | 88 ++ .../core/windows/app-loader/renderer/index.ts | 32 + .../client-updater/controller/ClientUpdate.ts | 223 +++++ .../client-updater/renderer/index.html | 103 +++ .../client-updater/renderer/index.scss | 407 ++++++++ .../windows/client-updater/renderer/index.ts | 271 ++++++ .../windows/client-updater/renderer/logo.png | Bin 0 -> 50741 bytes .../client-updater/renderer/unavailable.svg | 6 + .../client-updater/renderer/up2date.svg | 6 + .../client-updater/renderer/update.svg | 8 + .../main-window/controller/MainWindow.ts | 111 +++ .../main-window/renderer/PreloadScript.ts} | 2 +- modules/crash_handler/index.ts | 56 +- modules/crash_handler/ui/index.html | 4 +- modules/crash_handler/ui/index.ts | 2 +- modules/renderer/dns/dns_resolver.ts | 15 +- modules/shared/proxy/Client.ts | 2 +- modules/shared/proxy/Definitions.ts | 2 +- modules/shared/proxy/Server.ts | 2 +- modules/shared/version/index.ts | 28 +- modules/shared/window.ts | 2 +- native/serverconnection/CMakeLists.txt | 2 +- .../src/audio/codec/OpusConverter.cpp | 2 +- .../src/audio/js/AudioConsumer.cpp | 4 +- .../src/audio/sounds/SoundPlayer.cpp | 2 +- native/serverconnection/src/bindings.cpp | 5 +- .../src/connection/ServerConnection.cpp | 10 +- .../src/connection/audio/VoiceConnection.cpp | 9 +- .../test/js/RequireHandler.ts | 23 + native/serverconnection/test/js/flood.ts | 74 +- native/serverconnection/test/js/main.ts | 54 +- native/serverconnection/tsconfig.json | 11 + native/updater/config.cpp | 6 + native/updater/config.h | 3 + native/updater/file.cpp | 51 +- native/updater/file.h | 5 + native/updater/main.cpp | 12 +- package-lock.json | 326 ++++++- package.json | 5 +- 80 files changed, 3615 insertions(+), 1938 deletions(-) create mode 100644 generate-json-validators.sh create mode 100644 modules/core/app-updater/AppInfoFile.ts create mode 100644 modules/core/app-updater/AppInfoFile.validator.ts create mode 100644 modules/core/app-updater/UpdateConfigFile.ts create mode 100644 modules/core/app-updater/UpdateConfigFile.validator.ts create mode 100644 modules/core/ui-loader/Cache.ts create mode 100644 modules/core/ui-loader/CacheFile.ts create mode 100644 modules/core/ui-loader/CacheFile.validator.ts create mode 100644 modules/core/ui-loader/Loader.ts create mode 100644 modules/core/ui-loader/Remote.ts create mode 100644 modules/core/ui-loader/RemoteData.ts create mode 100644 modules/core/ui-loader/Shipped.ts create mode 100644 modules/core/ui-loader/ShippedFileInfo.ts create mode 100644 modules/core/ui-loader/ShippedFileInfo.validator.ts delete mode 100644 modules/core/ui-loader/graphical.ts delete mode 100644 modules/core/ui-loader/index.ts delete mode 100644 modules/core/ui-loader/loader.ts delete mode 100644 modules/core/ui-loader/local_ui_cache.ts delete mode 100644 modules/core/ui-loader/ui/img/logo.svg delete mode 100644 modules/core/ui-loader/ui/loader.ts delete mode 100644 modules/core/ui-loader/ui/loading_screen.html delete mode 100644 modules/core/ui-loader/ui/preload_page.html create mode 100644 modules/core/windows/app-loader/controller/AppLoader.ts create mode 100644 modules/core/windows/app-loader/renderer/img/logo.svg rename modules/core/{ui-loader/ui => windows/app-loader/renderer}/img/smoke.png (100%) create mode 100644 modules/core/windows/app-loader/renderer/index.html create mode 100644 modules/core/windows/app-loader/renderer/index.scss create mode 100644 modules/core/windows/app-loader/renderer/index.ts create mode 100644 modules/core/windows/client-updater/controller/ClientUpdate.ts create mode 100644 modules/core/windows/client-updater/renderer/index.html create mode 100644 modules/core/windows/client-updater/renderer/index.scss create mode 100644 modules/core/windows/client-updater/renderer/index.ts create mode 100644 modules/core/windows/client-updater/renderer/logo.png create mode 100644 modules/core/windows/client-updater/renderer/unavailable.svg create mode 100644 modules/core/windows/client-updater/renderer/up2date.svg create mode 100644 modules/core/windows/client-updater/renderer/update.svg create mode 100644 modules/core/windows/main-window/controller/MainWindow.ts rename modules/core/{main-window/preload.ts => windows/main-window/renderer/PreloadScript.ts} (79%) create mode 100644 native/serverconnection/test/js/RequireHandler.ts create mode 100644 native/serverconnection/tsconfig.json 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 0000000000000000000000000000000000000000..3be59fb08594b2ab7de8fb744169c7312d7be00d GIT binary patch literal 50741 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 + + + \ 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" },