diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 48078b00..d7336703 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build/release +name: Build on: push @@ -14,16 +14,22 @@ jobs: - name: Check out Git repository uses: actions/checkout@v1 - - name: Install Node.js, NPM and Yarn + - name: Set up Node uses: actions/setup-node@v1 with: - node-version: 14 + node-version: 16 - - name: Build/release Electron app - uses: samuelmeuli/action-electron-builder@v1 + - name: Set up Python + uses: actions/setup-python@v2 with: - github_token: ${{ secrets.github_token }} + python-version: 3.x - # If the commit is tagged with a version (e.g. "v1.0.0"), - # release the app after building - release: ${{ startsWith(github.ref, 'refs/tags/v') }} \ No newline at end of file + - name: Install Dependencies + run: npm ci + shell: bash + + - name: Build + env: + GH_TOKEN: ${{ secrets.github_token }} + run: npm run dist + shell: bash \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index da2d3988..19c7bdba 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -14 \ No newline at end of file +16 \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt index a7ce69e2..1455dbbb 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017-2021 Daniel D. Scalzi +Copyright (c) 2017-2022 Daniel D. Scalzi Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c8c4bd92..7e322311 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@
(formerly Electron Launcher)
-[

travis](https://travis-ci.org/dscalzi/HeliosLauncher) [downloads](https://github.com/dscalzi/HeliosLauncher/releases) stark

+[

gh actions](https://github.com/dscalzi/HeliosLauncher/actions) [downloads](https://github.com/dscalzi/HeliosLauncher/releases) winter-is-coming

Join modded servers without worrying about installing Java, Forge, or other mods. We'll handle that for you.

@@ -15,6 +15,7 @@ * 🔒 Full account management. * Add multiple accounts and easily switch between them. + * Microsoft (OAuth 2.0) + Mojang (Yggdrasil) authentication fully supported. * Credentials are never stored and transmitted directly to Mojang. * 📂 Efficient asset management. * Receive client updates as soon as we release them. @@ -54,7 +55,7 @@ If you download from the [Releases](https://github.com/dscalzi/HeliosLauncher/re | Platform | File | | -------- | ---- | | Windows x64 | `Helios-Launcher-setup-VERSION.exe` | -| macOS x64 | `Helios-Launcher-setup-VERSION.dmg` | +| macOS x64 | `Helios-Launcher-setup-VERSION-x64.dmg` | | macOS arm64 | `Helios-Launcher-setup-VERSION-arm64.dmg` | | Linux x64 | `Helios-Launcher-setup-VERSION.AppImage` | @@ -83,7 +84,7 @@ This section details the setup of a basic developmentment environment. **System Requirements** -* [Node.js][nodejs] v14 +* [Node.js][nodejs] v16 --- @@ -180,13 +181,15 @@ Note that you **cannot** open the DevTools window while using this debug configu Please give credit to the original author and provide a link to the original source. This is free software, please do at least this much. +For instructions on setting up Microsoft Authentication, see https://github.com/dscalzi/HeliosLauncher/blob/master/docs/MicrosoftAuth.md. + --- ## Resources * [Wiki][wiki] * [Nebula (Create Distribution.json)][nebula] -* [v2 Rewrite Branch (WIP)][v2branch] +* [v2 Rewrite Branch (Inactive)][v2branch] The best way to contact the developers is on Discord. diff --git a/app/app.ejs b/app/app.ejs index 499c10d5..e829fa14 100644 --- a/app/app.ejs +++ b/app/app.ejs @@ -31,6 +31,8 @@
<%- include('welcome') %> <%- include('login') %> + <%- include('waiting') %> + <%- include('loginOptions') %> <%- include('settings') %> <%- include('landing') %>
diff --git a/app/assets/css/launcher.css b/app/assets/css/launcher.css index 56816997..e67984e4 100644 --- a/app/assets/css/launcher.css +++ b/app/assets/css/launcher.css @@ -222,6 +222,7 @@ body, button { align-items: center; height: 100%; width: 100%; + background: rgba(0, 0, 0, 0.50); } #welcomeContent { @@ -872,6 +873,175 @@ body, button { } */ +/******************************************************************************* + * * + * Waiting View (waiting.ejs) * + * * + ******************************************************************************/ + +#waitingContainer { + position: relative; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + transition: filter 0.25s ease; + background: rgba(0, 0, 0, 0.50); +} + +#waitingContent { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 50%; + top: -10%; + position: relative; +} + +.waitingSpinner:before { + transform: rotateX(60deg) rotateY(45deg) rotateZ(45deg); + animation: 750ms rotateBefore infinite linear reverse; +} +.waitingSpinner:after { + transform: rotateX(240deg) rotateY(45deg) rotateZ(45deg); + animation: 750ms rotateAfter infinite linear; +} +.waitingSpinner:before, +.waitingSpinner:after { + box-sizing: border-box; + content: ''; + display: block; + position: fixed; + top: calc(50% - 5em); + /* left: 50%; */ + margin-top: -5em; + margin-left: -5em; + width: 10em; + height: 10em; + transform-style: preserve-3d; + transform-origin: 50%; + transform: rotateY(50%); + perspective-origin: 50% 50%; + perspective: 340px; + background-size: 10em 10em; + background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjI2NnB4IiBoZWlnaHQ9IjI5N3B4IiB2aWV3Qm94PSIwIDAgMjY2IDI5NyIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4bWxuczpza2V0Y2g9Imh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaC9ucyI+CiAgICA8dGl0bGU+c3Bpbm5lcjwvdGl0bGU+CiAgICA8ZGVzY3JpcHRpb24+Q3JlYXRlZCB3aXRoIFNrZXRjaCAoaHR0cDovL3d3dy5ib2hlbWlhbmNvZGluZy5jb20vc2tldGNoKTwvZGVzY3JpcHRpb24+CiAgICA8ZGVmcz48L2RlZnM+CiAgICA8ZyBpZD0iUGFnZS0xIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIiBza2V0Y2g6dHlwZT0iTVNQYWdlIj4KICAgICAgICA8cGF0aCBkPSJNMTcxLjUwNzgxMywzLjI1MDAwMDM4IEMyMjYuMjA4MTgzLDEyLjg1NzcxMTEgMjk3LjExMjcyMiw3MS40OTEyODIzIDI1MC44OTU1OTksMTA4LjQxMDE1NSBDMjE2LjU4MjAyNCwxMzUuODIwMzEgMTg2LjUyODQwNSw5Ny4wNjI0OTY0IDE1Ni44MDA3NzQsODUuNzczNDM0NiBDMTI3LjA3MzE0Myw3NC40ODQzNzIxIDc2Ljg4ODQ2MzIsODQuMjE2MTQ2MiA2MC4xMjg5MDY1LDEwOC40MTAxNTMgQy0xNS45ODA0Njg1LDIxOC4yODEyNDcgMTQ1LjI3NzM0NCwyOTYuNjY3OTY4IDE0NS4yNzczNDQsMjk2LjY2Nzk2OCBDMTQ1LjI3NzM0NCwyOTYuNjY3OTY4IC0yNS40NDkyMTg3LDI1Ny4yNDIxOTggMy4zOTg0Mzc1LDEwOC40MTAxNTUgQzE2LjMwNzA2NjEsNDEuODExNDE3NCA4NC43Mjc1ODI5LC0xMS45OTIyOTg1IDE3MS41MDc4MTMsMy4yNTAwMDAzOCBaIiBpZD0iUGF0aC0xIiBmaWxsPSIjZmZmZmZmIiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj48L3BhdGg+CiAgICA8L2c+Cjwvc3ZnPg==); +} + +#waitingTextContainer { + position: fixed; + top: 50%; +} + +@keyframes rotateBefore { + from { + transform: rotateX(60deg) rotateY(45deg) rotateZ(0deg); + } + to { + transform: rotateX(60deg) rotateY(45deg) rotateZ(-360deg); + } +} + +@keyframes rotateAfter { + from { + transform: rotateX(240deg) rotateY(45deg) rotateZ(0deg); + } + to { + transform: rotateX(240deg) rotateY(45deg) rotateZ(360deg); + } +} + +/******************************************************************************* + * * + * Login Options View (loginOptions.ejs) * + * * + ******************************************************************************/ + +#loginOptionsContainer { + position: relative; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + transition: filter 0.25s ease; + background: rgba(0, 0, 0, 0.50); +} + +#loginOptionsContent { + border-radius: 3px; + position: relative; + top: -5%; +} + +.loginOptionsMainContent { + display: flex; + flex-direction: column; + align-items: center; +} + +.loginOptionActions { + display: flex; + flex-direction: column; + row-gap: 10px; +} + +.loginOptionButtonContainer { + width: 16em; +} + +.loginOptionButton { + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(126, 126, 126, 0.57); + border-radius: 3px; + height: 50px; + width: 100%; + text-align: left; + padding: 0px 25px; + cursor: pointer; + outline: none; + transition: 0.25s ease; + display: flex; + align-items: center; + column-gap: 5px; +} +.loginOptionButton:hover, +.loginOptionButton:focus { + background: rgba(54, 54, 54, 0.25); + text-shadow: 0px 0px 20px white; +} + +#loginOptionCancelContainer { + position: absolute; + bottom: -100px; +} + +#loginOptionCancelButton { + background: none; + border: none; + padding: 2px 0px; + font-size: 16px; + font-weight: bold; + color: lightgrey; + cursor: pointer; + outline: none; + transition: 0.25s ease; +} +#loginOptionCancelButton:hover, +#loginOptionCancelButton:focus { + text-shadow: 0px 0px 20px lightgrey; +} +#loginOptionCancelButton:active { + text-shadow: 0px 0px 20px rgba(211, 211, 211, 0.75); + color: rgba(211, 211, 211, 0.75); +} +#loginOptionCancelButton:disabled { + color: rgba(211, 211, 211, 0.75); + pointer-events: none; +} + + /******************************************************************************* * * * Settings View (sttings.ejs) * @@ -1269,45 +1439,65 @@ input:checked + .toggleSwitchSlider:before { * Settings View (Account Tab) * * */ -/* Add account button styles. */ -#settingsAddAccount { - background: rgba(0, 0, 0, 0.25); - border: 1px solid rgba(126, 126, 126, 0.57); - border-radius: 3px; - height: 50px; +.settingsAuthAccountTypeContainer { + display: flex; width: 75%; + flex-direction: column; +} + +.settingsAuthAccountTypeHeader { + display: flex; + align-items: center; + width: 100%; + justify-content: space-between; + padding: 10px 0px; + border-bottom: 1px solid #ffffff85; + margin-bottom: 30px; +} + +.settingsAuthAccountTypeHeaderLeft { + display: flex; + column-gap: 5px; +} + +/* Settings add account button styles. */ +.settingsAddAuthAccount { + background: none; + border: none; text-align: left; - padding: 0px 50px; + padding: 2px 0px; + color: white; cursor: pointer; outline: none; transition: 0.25s ease; } -#settingsAddAccount:hover, -#settingsAddAccount:focus { - background: rgba(54, 54, 54, 0.25); - text-shadow: 0px 0px 20px white; +.settingsAddAuthAccount:hover, +.settingsAddAuthAccount:focus { + text-shadow: 0px 0px 20px white, 0px 0px 20px white, 0px 0px 20px white; } - -/* Settings auth accounts header. */ -#settingsCurrentAccountsHeader { - margin: 20px 0px; +.settingsAddAuthAccount:active { + text-shadow: 0px 0px 20px rgba(255, 255, 255, 0.75), 0px 0px 20px rgba(255, 255, 255, 0.75), 0px 0px 20px rgba(255, 255, 255, 0.75); + color: rgba(255, 255, 255, 0.75); +} +.settingsAddAuthAccount:disabled { + color: rgba(255, 255, 255, 0.75); + pointer-events: none; } /* Auth account list container styles. */ -#settingsCurrentAccounts { +.settingsCurrentAccounts { margin-bottom: 5%; } -#settingsCurrentAccounts > .settingsAuthAccount:not(:last-child) { +.settingsCurrentAccounts > .settingsAuthAccount:not(:last-child) { margin-bottom: 10px; } -#settingsCurrentAccounts > .settingsAuthAccount:not(:first-child) { +.settingsCurrentAccounts > .settingsAuthAccount:not(:first-child) { margin-top: 10px; } /* Auth account shared styles. */ .settingsAuthAccount { display: flex; - width: 75%; background: rgba(0, 0, 0, 0.25); border-radius: 3px; border: 1px solid rgba(126, 126, 126, 0.57); diff --git a/app/assets/images/icons/microsoft.svg b/app/assets/images/icons/microsoft.svg new file mode 100644 index 00000000..78a4ed94 --- /dev/null +++ b/app/assets/images/icons/microsoft.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/assets/images/icons/mojang.svg b/app/assets/images/icons/mojang.svg new file mode 100644 index 00000000..e1116b41 --- /dev/null +++ b/app/assets/images/icons/mojang.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/assets/js/assetguard.js b/app/assets/js/assetguard.js index 4d304335..75f6bc54 100644 --- a/app/assets/js/assetguard.js +++ b/app/assets/js/assetguard.js @@ -5,6 +5,7 @@ const child_process = require('child_process') const crypto = require('crypto') const EventEmitter = require('events') const fs = require('fs-extra') +const StreamZip = require('node-stream-zip') const path = require('path') const Registry = require('winreg') const request = require('request') @@ -15,13 +16,6 @@ const ConfigManager = require('./configmanager') const DistroManager = require('./distromanager') const isDev = require('./isdev') -// Constants -// const PLATFORM_MAP = { -// win32: '-windows-x64.tar.gz', -// darwin: '-macosx-x64.tar.gz', -// linux: '-linux-x64.tar.gz' -// } - // Classes /** Class representing a base asset. */ @@ -222,42 +216,6 @@ class JavaGuard extends EventEmitter { this.mcVersion = mcVersion } - // /** - // * @typedef OracleJREData - // * @property {string} uri The base uri of the JRE. - // * @property {{major: string, update: string, build: string}} version Object containing version information. - // */ - - // /** - // * Resolves the latest version of Oracle's JRE and parses its download link. - // * - // * @returns {Promise.} Promise which resolved to an object containing the JRE download data. - // */ - // static _latestJREOracle(){ - - // const url = 'https://www.oracle.com/technetwork/java/javase/downloads/jre8-downloads-2133155.html' - // const regex = /https:\/\/.+?(?=\/java)\/java\/jdk\/([0-9]+u[0-9]+)-(b[0-9]+)\/([a-f0-9]{32})?\/jre-\1/ - - // return new Promise((resolve, reject) => { - // request(url, (err, resp, body) => { - // if(!err){ - // const arr = body.match(regex) - // const verSplit = arr[1].split('u') - // resolve({ - // uri: arr[0], - // version: { - // major: verSplit[0], - // update: verSplit[1], - // build: arr[2] - // } - // }) - // } else { - // resolve(null) - // } - // }) - // }) - // } - /** * @typedef OpenJDKData * @property {string} uri The base uri of the JRE. @@ -281,30 +239,41 @@ class JavaGuard extends EventEmitter { if(process.platform === 'darwin') { return this._latestCorretto(major) } else { - return this._latestAdoptOpenJDK(major) + return this._latestAdoptium(major) } } - static _latestAdoptOpenJDK(major) { + static _latestAdoptium(major) { + const majorNum = Number(major) const sanitizedOS = process.platform === 'win32' ? 'windows' : (process.platform === 'darwin' ? 'mac' : process.platform) + const url = `https://api.adoptium.net/v3/assets/latest/${major}/hotspot?vendor=eclipse` - const url = `https://api.adoptopenjdk.net/v2/latestAssets/nightly/openjdk${major}?os=${sanitizedOS}&arch=x64&heap_size=normal&openjdk_impl=hotspot&type=jre` - return new Promise((resolve, reject) => { request({url, json: true}, (err, resp, body) => { if(!err && body.length > 0){ - resolve({ - uri: body[0].binary_link, - size: body[0].binary_size, - name: body[0].binary_name + + const targetBinary = body.find(entry => { + return entry.version.major === majorNum + && entry.binary.os === sanitizedOS + && entry.binary.image_type === 'jdk' + && entry.binary.architecture === 'x64' }) + + if(targetBinary != null) { + resolve({ + uri: targetBinary.binary.package.link, + size: targetBinary.binary.package.size, + name: targetBinary.binary.package.name + }) + } else { + resolve(null) + } } else { resolve(null) } }) }) - } static _latestCorretto(major) { @@ -839,6 +808,7 @@ class JavaGuard extends EventEmitter { pathSet1 = new Set([ ...pathSet1, ...(await JavaGuard._scanFileSystem('C:\\Program Files\\Java')), + ...(await JavaGuard._scanFileSystem('C:\\Program Files\\Eclipse Foundation')), ...(await JavaGuard._scanFileSystem('C:\\Program Files\\AdoptOpenJDK')) ]) } @@ -1583,21 +1553,7 @@ class AssetGuard extends EventEmitter { this.java = new DLTracker([jre], jre.size, (a, self) => { if(verData.name.endsWith('zip')){ - const zip = new AdmZip(a.to) - const pos = path.join(dataDir, zip.getEntries()[0].entryName) - zip.extractAllToAsync(dataDir, true, (err) => { - if(err){ - console.log(err) - self.emit('complete', 'java', JavaGuard.javaExecFromRoot(pos)) - } else { - fs.unlink(a.to, err => { - if(err){ - console.log(err) - } - self.emit('complete', 'java', JavaGuard.javaExecFromRoot(pos)) - }) - } - }) + this._extractJdkZip(a.to, dataDir, self) } else { // Tar.gz @@ -1638,67 +1594,31 @@ class AssetGuard extends EventEmitter { } - // _enqueueOracleJRE(dataDir){ - // return new Promise((resolve, reject) => { - // JavaGuard._latestJREOracle().then(verData => { - // if(verData != null){ + async _extractJdkZip(zipPath, runtimeDir, self) { + + const zip = new StreamZip.async({ + file: zipPath, + storeEntries: true + }) - // const combined = verData.uri + PLATFORM_MAP[process.platform] - - // const opts = { - // url: combined, - // headers: { - // 'Cookie': 'oraclelicense=accept-securebackup-cookie' - // } - // } - - // request.head(opts, (err, resp, body) => { - // if(err){ - // resolve(false) - // } else { - // dataDir = path.join(dataDir, 'runtime', 'x64') - // const name = combined.substring(combined.lastIndexOf('/')+1) - // const fDir = path.join(dataDir, name) - // const jre = new Asset(name, null, parseInt(resp.headers['content-length']), opts, fDir) - // this.java = new DLTracker([jre], jre.size, (a, self) => { - // let h = null - // fs.createReadStream(a.to) - // .on('error', err => console.log(err)) - // .pipe(zlib.createGunzip()) - // .on('error', err => console.log(err)) - // .pipe(tar.extract(dataDir, { - // map: (header) => { - // if(h == null){ - // h = header.name - // } - // } - // })) - // .on('error', err => console.log(err)) - // .on('finish', () => { - // fs.unlink(a.to, err => { - // if(err){ - // console.log(err) - // } - // if(h.indexOf('/') > -1){ - // h = h.substring(0, h.indexOf('/')) - // } - // const pos = path.join(dataDir, h) - // self.emit('complete', 'java', JavaGuard.javaExecFromRoot(pos)) - // }) - // }) - - // }) - // resolve(true) - // } - // }) + let pos = '' + try { + const entries = await zip.entries() + pos = path.join(runtimeDir, Object.keys(entries)[0]) - // } else { - // resolve(false) - // } - // }) - // }) + console.log('Extracting jdk..') + await zip.extract(null, runtimeDir) + console.log('Cleaning up..') + await fs.remove(zipPath) + console.log('Jdk extraction complete.') - // } + } catch(err) { + console.log(err) + } finally { + zip.close() + self.emit('complete', 'java', JavaGuard.javaExecFromRoot(pos)) + } + } // _enqueueMojangJRE(dir){ // return new Promise((resolve, reject) => { diff --git a/app/assets/js/authmanager.js b/app/assets/js/authmanager.js index 22b2fed9..cdc6905c 100644 --- a/app/assets/js/authmanager.js +++ b/app/assets/js/authmanager.js @@ -9,16 +9,19 @@ * @module authmanager */ // Requirements -const ConfigManager = require('./configmanager') -const LoggerUtil = require('./loggerutil') -const Mojang = require('./mojang') -const logger = LoggerUtil('%c[AuthManager]', 'color: #a02d2a; font-weight: bold') -const loggerSuccess = LoggerUtil('%c[AuthManager]', 'color: #209b07; font-weight: bold') +const ConfigManager = require('./configmanager') +const { LoggerUtil } = require('helios-core') +const { RestResponseStatus } = require('helios-core/common') +const { MojangRestAPI, mojangErrorDisplayable, MojangErrorCode } = require('helios-core/mojang') +const { MicrosoftAuth, microsoftErrorDisplayable, MicrosoftErrorCode } = require('helios-core/microsoft') +const { AZURE_CLIENT_ID } = require('./ipcconstants') + +const log = LoggerUtil.getLogger('AuthManager') // Functions /** - * Add an account. This will authenticate the given credentials with Mojang's + * Add a Mojang account. This will authenticate the given credentials with Mojang's * authserver. The resultant data will be stored as an auth account in the * configuration database. * @@ -26,40 +29,172 @@ const loggerSuccess = LoggerUtil('%c[AuthManager]', 'color: #209b07; font-weight * @param {string} password The account password. * @returns {Promise.} Promise which resolves the resolved authenticated account object. */ -exports.addAccount = async function(username, password){ +exports.addMojangAccount = async function(username, password) { try { - const session = await Mojang.authenticate(username, password, ConfigManager.getClientToken()) - if(session.selectedProfile != null){ - const ret = ConfigManager.addAuthAccount(session.selectedProfile.id, session.accessToken, username, session.selectedProfile.name) - if(ConfigManager.getClientToken() == null){ - ConfigManager.setClientToken(session.clientToken) + const response = await MojangRestAPI.authenticate(username, password, ConfigManager.getClientToken()) + console.log(response) + if(response.responseStatus === RestResponseStatus.SUCCESS) { + + const session = response.data + if(session.selectedProfile != null){ + const ret = ConfigManager.addMojangAuthAccount(session.selectedProfile.id, session.accessToken, username, session.selectedProfile.name) + if(ConfigManager.getClientToken() == null){ + ConfigManager.setClientToken(session.clientToken) + } + ConfigManager.save() + return ret + } else { + return Promise.reject(mojangErrorDisplayable(MojangErrorCode.ERROR_NOT_PAID)) } - ConfigManager.save() - return ret + } else { - throw new Error('NotPaidAccount') + return Promise.reject(mojangErrorDisplayable(response.mojangErrorCode)) } } catch (err){ - return Promise.reject(err) + log.error(err) + return Promise.reject(mojangErrorDisplayable(MojangErrorCode.UNKNOWN)) + } +} + +const AUTH_MODE = { FULL: 0, MS_REFRESH: 1, MC_REFRESH: 2 } + +/** + * Perform the full MS Auth flow in a given mode. + * + * AUTH_MODE.FULL = Full authorization for a new account. + * AUTH_MODE.MS_REFRESH = Full refresh authorization. + * AUTH_MODE.MC_REFRESH = Refresh of the MC token, reusing the MS token. + * + * @param {string} entryCode FULL-AuthCode. MS_REFRESH=refreshToken, MC_REFRESH=accessToken + * @param {*} authMode The auth mode. + * @returns An object with all auth data. AccessToken object will be null when mode is MC_REFRESH. + */ +async function fullMicrosoftAuthFlow(entryCode, authMode) { + try { + + let accessTokenRaw + let accessToken + if(authMode !== AUTH_MODE.MC_REFRESH) { + const accessTokenResponse = await MicrosoftAuth.getAccessToken(entryCode, authMode === AUTH_MODE.MS_REFRESH, AZURE_CLIENT_ID) + if(accessTokenResponse.responseStatus === RestResponseStatus.ERROR) { + return Promise.reject(microsoftErrorDisplayable(accessTokenResponse.microsoftErrorCode)) + } + accessToken = accessTokenResponse.data + accessTokenRaw = accessToken.access_token + } else { + accessTokenRaw = entryCode + } + + const xblResponse = await MicrosoftAuth.getXBLToken(accessTokenRaw) + if(xblResponse.responseStatus === RestResponseStatus.ERROR) { + return Promise.reject(microsoftErrorDisplayable(xblResponse.microsoftErrorCode)) + } + const xstsResonse = await MicrosoftAuth.getXSTSToken(xblResponse.data) + if(xstsResonse.responseStatus === RestResponseStatus.ERROR) { + return Promise.reject(microsoftErrorDisplayable(xstsResonse.microsoftErrorCode)) + } + const mcTokenResponse = await MicrosoftAuth.getMCAccessToken(xstsResonse.data) + if(mcTokenResponse.responseStatus === RestResponseStatus.ERROR) { + return Promise.reject(microsoftErrorDisplayable(mcTokenResponse.microsoftErrorCode)) + } + const mcProfileResponse = await MicrosoftAuth.getMCProfile(mcTokenResponse.data.access_token) + if(mcProfileResponse.responseStatus === RestResponseStatus.ERROR) { + return Promise.reject(microsoftErrorDisplayable(mcProfileResponse.microsoftErrorCode)) + } + return { + accessToken, + accessTokenRaw, + xbl: xblResponse.data, + xsts: xstsResonse.data, + mcToken: mcTokenResponse.data, + mcProfile: mcProfileResponse.data + } + } catch(err) { + log.error(err) + return Promise.reject(microsoftErrorDisplayable(MicrosoftErrorCode.UNKNOWN)) } } /** - * Remove an account. This will invalidate the access token associated + * Calculate the expiry date. Advance the expiry time by 10 seconds + * to reduce the liklihood of working with an expired token. + * + * @param {number} nowMs Current time milliseconds. + * @param {number} epiresInS Expires in (seconds) + * @returns + */ +function calculateExpiryDate(nowMs, epiresInS) { + return nowMs + ((epiresInS-10)*1000) +} + +/** + * Add a Microsoft account. This will pass the provided auth code to Mojang's OAuth2.0 flow. + * The resultant data will be stored as an auth account in the configuration database. + * + * @param {string} authCode The authCode obtained from microsoft. + * @returns {Promise.} Promise which resolves the resolved authenticated account object. + */ +exports.addMicrosoftAccount = async function(authCode) { + + const fullAuth = await fullMicrosoftAuthFlow(authCode, AUTH_MODE.FULL) + + // Advance expiry by 10 seconds to avoid close calls. + const now = new Date().getTime() + + const ret = ConfigManager.addMicrosoftAuthAccount( + fullAuth.mcProfile.id, + fullAuth.mcToken.access_token, + fullAuth.mcProfile.name, + calculateExpiryDate(now, fullAuth.mcToken.expires_in), + fullAuth.accessToken.access_token, + fullAuth.accessToken.refresh_token, + calculateExpiryDate(now, fullAuth.accessToken.expires_in) + ) + ConfigManager.save() + + return ret +} + +/** + * Remove a Mojang account. This will invalidate the access token associated * with the account and then remove it from the database. * * @param {string} uuid The UUID of the account to be removed. * @returns {Promise.} Promise which resolves to void when the action is complete. */ -exports.removeAccount = async function(uuid){ +exports.removeMojangAccount = async function(uuid){ try { const authAcc = ConfigManager.getAuthAccount(uuid) - await Mojang.invalidate(authAcc.accessToken, ConfigManager.getClientToken()) + const response = await MojangRestAPI.invalidate(authAcc.accessToken, ConfigManager.getClientToken()) + if(response.responseStatus === RestResponseStatus.SUCCESS) { + ConfigManager.removeAuthAccount(uuid) + ConfigManager.save() + return Promise.resolve() + } else { + log.error('Error while removing account', response.error) + return Promise.reject(response.error) + } + } catch (err){ + log.error('Error while removing account', err) + return Promise.reject(err) + } +} + +/** + * Remove a Microsoft account. It is expected that the caller will invoke the OAuth logout + * through the ipc renderer. + * + * @param {string} uuid The UUID of the account to be removed. + * @returns {Promise.} Promise which resolves to void when the action is complete. + */ +exports.removeMicrosoftAccount = async function(uuid){ + try { ConfigManager.removeAuthAccount(uuid) ConfigManager.save() return Promise.resolve() } catch (err){ + log.error('Error while removing account', err) return Promise.reject(err) } } @@ -69,31 +204,112 @@ exports.removeAccount = async function(uuid){ * we will attempt to refresh the access token and update that value. If that fails, a * new login will be required. * - * **Function is WIP** + * @returns {Promise.} Promise which resolves to true if the access token is valid, + * otherwise false. + */ +async function validateSelectedMojangAccount(){ + const current = ConfigManager.getSelectedAccount() + const response = await MojangRestAPI.validate(current.accessToken, ConfigManager.getClientToken()) + + if(response.responseStatus === RestResponseStatus.SUCCESS) { + const isValid = response.data + if(!isValid){ + const refreshResponse = await MojangRestAPI.refresh(current.accessToken, ConfigManager.getClientToken()) + if(refreshResponse.responseStatus === RestResponseStatus.SUCCESS) { + const session = refreshResponse.data + ConfigManager.updateMojangAuthAccount(current.uuid, session.accessToken) + ConfigManager.save() + } else { + log.error('Error while validating selected profile:', refreshResponse.error) + log.info('Account access token is invalid.') + return false + } + log.info('Account access token validated.') + return true + } else { + log.info('Account access token validated.') + return true + } + } + +} + +/** + * Validate the selected account with Microsoft's authserver. If the account is not valid, + * we will attempt to refresh the access token and update that value. If that fails, a + * new login will be required. + * + * @returns {Promise.} Promise which resolves to true if the access token is valid, + * otherwise false. + */ +async function validateSelectedMicrosoftAccount(){ + const current = ConfigManager.getSelectedAccount() + const now = new Date().getTime() + const mcExpiresAt = Date.parse(current.expiresAt) + const mcExpired = now >= mcExpiresAt + + if(!mcExpired) { + return true + } + + // MC token expired. Check MS token. + + const msExpiresAt = Date.parse(current.microsoft.expires_at) + const msExpired = now >= msExpiresAt + + if(msExpired) { + // MS expired, do full refresh. + try { + const res = await fullMicrosoftAuthFlow(current.microsoft.refresh_token, AUTH_MODE.MS_REFRESH) + + ConfigManager.updateMicrosoftAuthAccount( + current.uuid, + res.mcToken.access_token, + res.accessToken.access_token, + res.accessToken.refresh_token, + calculateExpiryDate(now, res.accessToken.expires_in), + calculateExpiryDate(now, res.mcToken.expires_in) + ) + ConfigManager.save() + return true + } catch(err) { + return false + } + } else { + // Only MC expired, use existing MS token. + try { + const res = await fullMicrosoftAuthFlow(current.microsoft.access_token, AUTH_MODE.MC_REFRESH) + + ConfigManager.updateMicrosoftAuthAccount( + current.uuid, + res.mcToken.access_token, + current.microsoft.access_token, + current.microsoft.refresh_token, + current.microsoft.expires_at, + calculateExpiryDate(now, res.mcToken.expires_in) + ) + ConfigManager.save() + return true + } + catch(err) { + return false + } + } +} + +/** + * Validate the selected auth account. * * @returns {Promise.} Promise which resolves to true if the access token is valid, * otherwise false. */ exports.validateSelected = async function(){ const current = ConfigManager.getSelectedAccount() - const isValid = await Mojang.validate(current.accessToken, ConfigManager.getClientToken()) - if(!isValid){ - try { - const session = await Mojang.refresh(current.accessToken, ConfigManager.getClientToken()) - ConfigManager.updateAuthAccount(current.uuid, session.accessToken) - ConfigManager.save() - } catch(err) { - logger.debug('Error while validating selected profile:', err) - if(err && err.error === 'ForbiddenOperationException'){ - // What do we do? - } - logger.log('Account access token is invalid.') - return false - } - loggerSuccess.log('Account access token validated.') - return true + + if(current.type === 'microsoft') { + return await validateSelectedMicrosoftAccount() } else { - loggerSuccess.log('Account access token validated.') - return true + return await validateSelectedMojangAccount() } + } \ No newline at end of file diff --git a/app/assets/js/configmanager.js b/app/assets/js/configmanager.js index 2c0bb53c..3dff9502 100644 --- a/app/assets/js/configmanager.js +++ b/app/assets/js/configmanager.js @@ -318,20 +318,21 @@ exports.getAuthAccount = function(uuid){ } /** - * Update the access token of an authenticated account. + * Update the access token of an authenticated mojang account. * * @param {string} uuid The uuid of the authenticated account. * @param {string} accessToken The new Access Token. * * @returns {Object} The authenticated account object created by this action. */ -exports.updateAuthAccount = function(uuid, accessToken){ +exports.updateMojangAuthAccount = function(uuid, accessToken){ config.authenticationDatabase[uuid].accessToken = accessToken + config.authenticationDatabase[uuid].type = 'mojang' // For gradual conversion. return config.authenticationDatabase[uuid] } /** - * Adds an authenticated account to the database to be stored. + * Adds an authenticated mojang account to the database to be stored. * * @param {string} uuid The uuid of the authenticated account. * @param {string} accessToken The accessToken of the authenticated account. @@ -340,9 +341,10 @@ exports.updateAuthAccount = function(uuid, accessToken){ * * @returns {Object} The authenticated account object created by this action. */ -exports.addAuthAccount = function(uuid, accessToken, username, displayName){ +exports.addMojangAuthAccount = function(uuid, accessToken, username, displayName){ config.selectedAccount = uuid config.authenticationDatabase[uuid] = { + type: 'mojang', accessToken, username: username.trim(), uuid: uuid.trim(), @@ -351,6 +353,58 @@ exports.addAuthAccount = function(uuid, accessToken, username, displayName){ return config.authenticationDatabase[uuid] } +/** + * Update the tokens of an authenticated microsoft account. + * + * @param {string} uuid The uuid of the authenticated account. + * @param {string} accessToken The new Access Token. + * @param {string} msAccessToken The new Microsoft Access Token + * @param {string} msRefreshToken The new Microsoft Refresh Token + * @param {date} msExpires The date when the microsoft access token expires + * @param {date} mcExpires The date when the mojang access token expires + * + * @returns {Object} The authenticated account object created by this action. + */ +exports.updateMicrosoftAuthAccount = function(uuid, accessToken, msAccessToken, msRefreshToken, msExpires, mcExpires) { + config.authenticationDatabase[uuid].accessToken = accessToken + config.authenticationDatabase[uuid].expiresAt = mcExpires + config.authenticationDatabase[uuid].microsoft.access_token = msAccessToken + config.authenticationDatabase[uuid].microsoft.refresh_token = msRefreshToken + config.authenticationDatabase[uuid].microsoft.expires_at = msExpires + return config.authenticationDatabase[uuid] +} + +/** + * Adds an authenticated microsoft account to the database to be stored. + * + * @param {string} uuid The uuid of the authenticated account. + * @param {string} accessToken The accessToken of the authenticated account. + * @param {string} name The in game name of the authenticated account. + * @param {date} mcExpires The date when the mojang access token expires + * @param {string} msAccessToken The microsoft access token + * @param {string} msRefreshToken The microsoft refresh token + * @param {date} msExpires The date when the microsoft access token expires + * + * @returns {Object} The authenticated account object created by this action. + */ +exports.addMicrosoftAuthAccount = function(uuid, accessToken, name, mcExpires, msAccessToken, msRefreshToken, msExpires) { + config.selectedAccount = uuid + config.authenticationDatabase[uuid] = { + type: 'microsoft', + accessToken, + username: name.trim(), + uuid: uuid.trim(), + displayName: name.trim(), + expiresAt: mcExpires, + microsoft: { + access_token: msAccessToken, + refresh_token: msRefreshToken, + expires_at: msExpires + } + } + return config.authenticationDatabase[uuid] +} + /** * Remove an authenticated account from the database. If the account * was also the selected account, a new one will be selected. If there diff --git a/app/assets/js/discordwrapper.js b/app/assets/js/discordwrapper.js index 529f17be..7b0ef03e 100644 --- a/app/assets/js/discordwrapper.js +++ b/app/assets/js/discordwrapper.js @@ -1,7 +1,7 @@ // Work in progress const logger = require('./loggerutil')('%c[DiscordWrapper]', 'color: #7289da; font-weight: bold') -const {Client} = require('discord-rpc') +const {Client} = require('discord-rpc-patch') let client let activity diff --git a/app/assets/js/dropinmodutil.js b/app/assets/js/dropinmodutil.js index 84ad4fa6..f816a20f 100644 --- a/app/assets/js/dropinmodutil.js +++ b/app/assets/js/dropinmodutil.js @@ -1,6 +1,7 @@ const fs = require('fs-extra') const path = require('path') -const { shell } = require('electron') +const { ipcRenderer, shell } = require('electron') +const { SHELL_OPCODE } = require('./ipcconstants') // Group #1: File Name (without .disabled, if any) // Group #2: File Extension (jar, zip, or litemod) @@ -95,14 +96,16 @@ exports.addDropinMods = function(files, modsdir) { * @returns {Promise.} True if the mod was deleted, otherwise false. */ exports.deleteDropinMod = async function(modsDir, fullName){ - try { - await shell.trashItem(path.join(modsDir, fullName)) - return true - } catch(error) { + + const res = await ipcRenderer.invoke(SHELL_OPCODE.TRASH_ITEM, path.join(modsDir, fullName)) + + if(!res.result) { shell.beep() - console.error('Error deleting drop-in mod.', error) + console.error('Error deleting drop-in mod.', res.error) return false } + + return true } /** diff --git a/app/assets/js/ipcconstants.js b/app/assets/js/ipcconstants.js new file mode 100644 index 00000000..a1cd6385 --- /dev/null +++ b/app/assets/js/ipcconstants.js @@ -0,0 +1,28 @@ +// NOTE FOR THIRD-PARTY +// REPLACE THIS CLIENT ID WITH YOUR APPLICATION ID. +// SEE https://github.com/dscalzi/HeliosLauncher/blob/master/docs/MicrosoftAuth.md +exports.AZURE_CLIENT_ID = '1ce6e35a-126f-48fd-97fb-54d143ac6d45' +// SEE NOTE ABOVE. + + +// Opcodes +exports.MSFT_OPCODE = { + OPEN_LOGIN: 'MSFT_AUTH_OPEN_LOGIN', + OPEN_LOGOUT: 'MSFT_AUTH_OPEN_LOGOUT', + REPLY_LOGIN: 'MSFT_AUTH_REPLY_LOGIN', + REPLY_LOGOUT: 'MSFT_AUTH_REPLY_LOGOUT' +} +// Reply types for REPLY opcode. +exports.MSFT_REPLY_TYPE = { + SUCCESS: 'MSFT_AUTH_REPLY_SUCCESS', + ERROR: 'MSFT_AUTH_REPLY_ERROR' +} +// Error types for ERROR reply. +exports.MSFT_ERROR = { + ALREADY_OPEN: 'MSFT_AUTH_ERR_ALREADY_OPEN', + NOT_FINISHED: 'MSFT_AUTH_ERR_NOT_FINISHED' +} + +exports.SHELL_OPCODE = { + TRASH_ITEM: 'TRASH_ITEM' +} \ No newline at end of file diff --git a/app/assets/js/mojang.js b/app/assets/js/mojang.js deleted file mode 100644 index 2882949c..00000000 --- a/app/assets/js/mojang.js +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Mojang - * - * This module serves as a minimal wrapper for Mojang's REST api. - * - * @module mojang - */ -// Requirements -const request = require('request') -const logger = require('./loggerutil')('%c[Mojang]', 'color: #a02d2a; font-weight: bold') - -// Constants -const minecraftAgent = { - name: 'Minecraft', - version: 1 -} -const authpath = 'https://authserver.mojang.com' -const statuses = [ - { - service: 'session.minecraft.net', - status: 'grey', - name: 'Multiplayer Session Service', - essential: true - }, - { - service: 'authserver.mojang.com', - status: 'grey', - name: 'Authentication Service', - essential: true - }, - { - service: 'textures.minecraft.net', - status: 'grey', - name: 'Minecraft Skins', - essential: false - }, - { - service: 'api.mojang.com', - status: 'grey', - name: 'Public API', - essential: false - }, - { - service: 'minecraft.net', - status: 'grey', - name: 'Minecraft.net', - essential: false - }, - { - service: 'account.mojang.com', - status: 'grey', - name: 'Mojang Accounts Website', - essential: false - } -] - -// Functions - -/** - * Converts a Mojang status color to a hex value. Valid statuses - * are 'green', 'yellow', 'red', and 'grey'. Grey is a custom status - * to our project which represents an unknown status. - * - * @param {string} status A valid status code. - * @returns {string} The hex color of the status code. - */ -exports.statusToHex = function(status){ - switch(status.toLowerCase()){ - case 'green': - return '#a5c325' - case 'yellow': - return '#eac918' - case 'red': - return '#c32625' - case 'grey': - default: - return '#848484' - } -} - -/** - * Retrieves the status of Mojang's services. - * The response is condensed into a single object. Each service is - * a key, where the value is an object containing a status and name - * property. - * - * @see http://wiki.vg/Mojang_API#API_Status - */ -exports.status = function(){ - return new Promise((resolve, reject) => { - request.get('https://status.mojang.com/check', - { - json: true, - timeout: 2500 - }, - function(error, response, body){ - - if(error || response.statusCode !== 200){ - logger.warn('Unable to retrieve Mojang status.') - logger.debug('Error while retrieving Mojang statuses:', error) - //reject(error || response.statusCode) - for(let i=0; i { - - const body = { - agent, - username, - password, - requestUser - } - if(clientToken != null){ - body.clientToken = clientToken - } - - request.post(authpath + '/authenticate', - { - json: true, - body - }, - function(error, response, body){ - if(error){ - logger.error('Error during authentication.', error) - reject(error) - } else { - if(response.statusCode === 200){ - resolve(body) - } else { - reject(body || {code: 'ENOTFOUND'}) - } - } - }) - }) -} - -/** - * Validate an access token. This should always be done before launching. - * The client token should match the one used to create the access token. - * - * @param {string} accessToken The access token to validate. - * @param {string} clientToken The launcher's client token. - * - * @see http://wiki.vg/Authentication#Validate - */ -exports.validate = function(accessToken, clientToken){ - return new Promise((resolve, reject) => { - request.post(authpath + '/validate', - { - json: true, - body: { - accessToken, - clientToken - } - }, - function(error, response, body){ - if(error){ - logger.error('Error during validation.', error) - reject(error) - } else { - if(response.statusCode === 403){ - resolve(false) - } else { - // 204 if valid - resolve(true) - } - } - }) - }) -} - -/** - * Invalidates an access token. The clientToken must match the - * token used to create the provided accessToken. - * - * @param {string} accessToken The access token to invalidate. - * @param {string} clientToken The launcher's client token. - * - * @see http://wiki.vg/Authentication#Invalidate - */ -exports.invalidate = function(accessToken, clientToken){ - return new Promise((resolve, reject) => { - request.post(authpath + '/invalidate', - { - json: true, - body: { - accessToken, - clientToken - } - }, - function(error, response, body){ - if(error){ - logger.error('Error during invalidation.', error) - reject(error) - } else { - if(response.statusCode === 204){ - resolve() - } else { - reject(body) - } - } - }) - }) -} - -/** - * Refresh a user's authentication. This should be used to keep a user logged - * in without asking them for their credentials again. A new access token will - * be generated using a recent invalid access token. See Wiki for more info. - * - * @param {string} accessToken The old access token. - * @param {string} clientToken The launcher's client token. - * @param {boolean} requestUser Optional. Adds user object to the reponse. - * - * @see http://wiki.vg/Authentication#Refresh - */ -exports.refresh = function(accessToken, clientToken, requestUser = true){ - return new Promise((resolve, reject) => { - request.post(authpath + '/refresh', - { - json: true, - body: { - accessToken, - clientToken, - requestUser - } - }, - function(error, response, body){ - if(error){ - logger.error('Error during refresh.', error) - reject(error) - } else { - if(response.statusCode === 200){ - resolve(body) - } else { - reject(body) - } - } - }) - }) -} \ No newline at end of file diff --git a/app/assets/js/scripts/landing.js b/app/assets/js/scripts/landing.js index 81b01bab..c15896c8 100644 --- a/app/assets/js/scripts/landing.js +++ b/app/assets/js/scripts/landing.js @@ -4,13 +4,13 @@ // Requirements const cp = require('child_process') const crypto = require('crypto') -const {URL} = require('url') +const { URL } = require('url') +const { MojangRestAPI, getServerStatus } = require('helios-core/mojang') // Internal Requirements const DiscordWrapper = require('./assets/js/discordwrapper') -const Mojang = require('./assets/js/mojang') const ProcessBuilder = require('./assets/js/processbuilder') -const ServerStatus = require('./assets/js/serverstatus') +const { RestResponseStatus, isDisplayableError } = require('helios-core/common') // Launch Elements const launch_content = document.getElementById('launch_content') @@ -21,7 +21,7 @@ const launch_details_text = document.getElementById('launch_details_text') const server_selection_button = document.getElementById('server_selection_button') const user_text = document.getElementById('user_text') -const loggerLanding = LoggerUtil('%c[Landing]', 'color: #000668; font-weight: bold') +const loggerLanding = LoggerUtil1('%c[Landing]', 'color: #000668; font-weight: bold') /* Launch Progress Wrapper Functions */ @@ -165,55 +165,57 @@ const refreshMojangStatuses = async function(){ let tooltipEssentialHTML = '' let tooltipNonEssentialHTML = '' - try { - const statuses = await Mojang.status() - greenCount = 0 - greyCount = 0 - - for(let i=0; i - - ${service.name} - ` - } else { - tooltipNonEssentialHTML += `
- - ${service.name} -
` - } - - if(service.status === 'yellow' && status !== 'red'){ - status = 'yellow' - } else if(service.status === 'red'){ - status = 'red' - } else { - if(service.status === 'grey'){ - ++greyCount - } - ++greenCount - } - - } - - if(greenCount === statuses.length){ - if(greyCount === statuses.length){ - status = 'grey' - } else { - status = 'green' - } - } - - } catch (err) { + const response = await MojangRestAPI.status() + let statuses + if(response.responseStatus === RestResponseStatus.SUCCESS) { + statuses = response.data + } else { loggerLanding.warn('Unable to refresh Mojang service status.') - loggerLanding.debug(err) + statuses = MojangRestAPI.getDefaultStatuses() + } + + greenCount = 0 + greyCount = 0 + + for(let i=0; i + + ${service.name} + ` + } else { + tooltipNonEssentialHTML += `
+ + ${service.name} +
` + } + + if(service.status === 'yellow' && status !== 'red'){ + status = 'yellow' + } else if(service.status === 'red'){ + status = 'red' + } else { + if(service.status === 'grey'){ + ++greyCount + } + ++greenCount + } + + } + + if(greenCount === statuses.length){ + if(greyCount === statuses.length){ + status = 'grey' + } else { + status = 'green' + } } document.getElementById('mojangStatusEssentialContainer').innerHTML = tooltipEssentialHTML document.getElementById('mojangStatusNonEssentialContainer').innerHTML = tooltipNonEssentialHTML - document.getElementById('mojang_status_icon').style.color = Mojang.statusToHex(status) + document.getElementById('mojang_status_icon').style.color = MojangRestAPI.statusToHex(status) } const refreshServerStatus = async function(fade = false){ @@ -225,11 +227,11 @@ const refreshServerStatus = async function(fade = false){ try { const serverURL = new URL('my://' + serv.getAddress()) - const servStat = await ServerStatus.getStatus(serverURL.hostname, serverURL.port) - if(servStat.online){ - pLabel = 'PLAYERS' - pVal = servStat.onlinePlayers + '/' + servStat.maxPlayers - } + + const servStat = await getServerStatus(47, serverURL.hostname, Number(serverURL.port)) + console.log(servStat) + pLabel = 'PLAYERS' + pVal = servStat.players.online + '/' + servStat.players.max } catch (err) { loggerLanding.warn('Unable to refresh server status, assuming offline.') @@ -291,7 +293,7 @@ function asyncSystemScan(mcVersion, launchAfter = true){ toggleLaunchArea(true) setLaunchPercentage(0, 100) - const loggerSysAEx = LoggerUtil('%c[SysAEx]', 'color: #353232; font-weight: bold') + const loggerSysAEx = LoggerUtil1('%c[SysAEx]', 'color: #353232; font-weight: bold') const forkEnv = JSON.parse(JSON.stringify(process.env)) forkEnv.CONFIG_DIRECT_PATH = ConfigManager.getLauncherDirectory() @@ -323,7 +325,7 @@ function asyncSystemScan(mcVersion, launchAfter = true){ // Show this information to the user. setOverlayContent( 'No Compatible
Java Installation Found', - 'In order to join WesterosCraft, you need a 64-bit installation of Java 8. Would you like us to install a copy? By installing, you accept Oracle\'s license agreement.', + 'In order to join WesterosCraft, you need a 64-bit installation of Java 8. Would you like us to install a copy?', 'Install Java', 'Install Manually' ) @@ -493,8 +495,8 @@ function dlAsync(login = true){ toggleLaunchArea(true) setLaunchPercentage(0, 100) - const loggerAEx = LoggerUtil('%c[AEx]', 'color: #353232; font-weight: bold') - const loggerLaunchSuite = LoggerUtil('%c[LaunchSuite]', 'color: #000668; font-weight: bold') + const loggerAEx = LoggerUtil1('%c[AEx]', 'color: #353232; font-weight: bold') + const loggerLaunchSuite = LoggerUtil1('%c[LaunchSuite]', 'color: #000668; font-weight: bold') const forkEnv = JSON.parse(JSON.stringify(process.env)) forkEnv.CONFIG_DIRECT_PATH = ConfigManager.getLauncherDirectory() diff --git a/app/assets/js/scripts/login.js b/app/assets/js/scripts/login.js index 34078bd1..724f09c4 100644 --- a/app/assets/js/scripts/login.js +++ b/app/assets/js/scripts/login.js @@ -21,7 +21,7 @@ const loginForm = document.getElementById('loginForm') // Control variables. let lu = false, lp = false -const loggerLogin = LoggerUtil('%c[Login]', 'color: #000668; font-weight: bold') +const loggerLogin = LoggerUtil1('%c[Login]', 'color: #000668; font-weight: bold') /** @@ -154,79 +154,6 @@ function formDisabled(v){ loginRememberOption.disabled = v } -/** - * Parses an error and returns a user-friendly title and description - * for our error overlay. - * - * @param {Error | {cause: string, error: string, errorMessage: string}} err A Node.js - * error or Mojang error response. - */ -function resolveError(err){ - // Mojang Response => err.cause | err.error | err.errorMessage - // Node error => err.code | err.message - if(err.cause != null && err.cause === 'UserMigratedException') { - return { - title: Lang.queryJS('login.error.userMigrated.title'), - desc: Lang.queryJS('login.error.userMigrated.desc') - } - } else { - if(err.error != null){ - if(err.error === 'ForbiddenOperationException'){ - if(err.errorMessage != null){ - if(err.errorMessage === 'Invalid credentials. Invalid username or password.'){ - return { - title: Lang.queryJS('login.error.invalidCredentials.title'), - desc: Lang.queryJS('login.error.invalidCredentials.desc') - } - } else if(err.errorMessage === 'Invalid credentials.'){ - return { - title: Lang.queryJS('login.error.rateLimit.title'), - desc: Lang.queryJS('login.error.rateLimit.desc') - } - } - } - } - } else { - // Request errors (from Node). - if(err.code != null){ - if(err.code === 'ENOENT'){ - // No Internet. - return { - title: Lang.queryJS('login.error.noInternet.title'), - desc: Lang.queryJS('login.error.noInternet.desc') - } - } else if(err.code === 'ENOTFOUND'){ - // Could not reach server. - return { - title: Lang.queryJS('login.error.authDown.title'), - desc: Lang.queryJS('login.error.authDown.desc') - } - } - } - } - } - if(err.message != null){ - if(err.message === 'NotPaidAccount'){ - return { - title: Lang.queryJS('login.error.notPaid.title'), - desc: Lang.queryJS('login.error.notPaid.desc') - } - } else { - // Unknown error with request. - return { - title: Lang.queryJS('login.error.unknown.title'), - desc: err.message - } - } - } else { - // Unknown Mojang error. - return { - title: err.error, - desc: err.errorMessage - } - } -} - let loginViewOnSuccess = VIEWS.landing let loginViewOnCancel = VIEWS.settings let loginViewCancelHandler @@ -262,7 +189,7 @@ loginButton.addEventListener('click', () => { // Show loading stuff. loginLoading(true) - AuthManager.addAccount(loginUsername.value, loginPassword.value).then((value) => { + AuthManager.addMojangAccount(loginUsername.value, loginPassword.value).then((value) => { updateSelectedAccount(value) loginButton.innerHTML = loginButton.innerHTML.replace(Lang.queryJS('login.loggingIn'), Lang.queryJS('login.success')) $('.circle-loader').toggleClass('load-complete') @@ -285,16 +212,28 @@ loginButton.addEventListener('click', () => { formDisabled(false) }) }, 1000) - }).catch((err) => { + }).catch((displayableError) => { loginLoading(false) - const errF = resolveError(err) - setOverlayContent(errF.title, errF.desc, Lang.queryJS('login.tryAgain')) + + let actualDisplayableError + if(isDisplayableError(displayableError)) { + msftLoginLogger.error('Error while logging in.', displayableError) + actualDisplayableError = displayableError + } else { + // Uh oh. + msftLoginLogger.error('Unhandled error during login.', displayableError) + actualDisplayableError = { + title: 'Unknown Error During Login', + desc: 'An unknown error has occurred. Please see the console for details.' + } + } + + setOverlayContent(actualDisplayableError.title, actualDisplayableError.desc, Lang.queryJS('login.tryAgain')) setOverlayHandler(() => { formDisabled(false) toggleOverlay(false) }) toggleOverlay(true) - loggerLogin.log('Error while logging in.', err) }) }) \ No newline at end of file diff --git a/app/assets/js/scripts/loginOptions.js b/app/assets/js/scripts/loginOptions.js new file mode 100644 index 00000000..cdb1bc8e --- /dev/null +++ b/app/assets/js/scripts/loginOptions.js @@ -0,0 +1,50 @@ +const loginOptionsCancelContainer = document.getElementById('loginOptionCancelContainer') +const loginOptionMicrosoft = document.getElementById('loginOptionMicrosoft') +const loginOptionMojang = document.getElementById('loginOptionMojang') +const loginOptionsCancelButton = document.getElementById('loginOptionCancelButton') + +let loginOptionsCancellable = false + +let loginOptionsViewOnLoginSuccess +let loginOptionsViewOnLoginCancel +let loginOptionsViewOnCancel +let loginOptionsViewCancelHandler + +function loginOptionsCancelEnabled(val){ + if(val){ + $(loginOptionsCancelContainer).show() + } else { + $(loginOptionsCancelContainer).hide() + } +} + +loginOptionMicrosoft.onclick = (e) => { + switchView(getCurrentView(), VIEWS.waiting, 500, 500, () => { + ipcRenderer.send( + MSFT_OPCODE.OPEN_LOGIN, + loginOptionsViewOnLoginSuccess, + loginOptionsViewOnLoginCancel + ) + }) +} + +loginOptionMojang.onclick = (e) => { + switchView(getCurrentView(), VIEWS.login, 500, 500, () => { + loginViewOnSuccess = loginOptionsViewOnLoginSuccess + loginViewOnCancel = loginOptionsViewOnLoginCancel + loginCancelEnabled(true) + }) +} + +loginOptionsCancelButton.onclick = (e) => { + switchView(getCurrentView(), loginOptionsViewOnCancel, 500, 500, () => { + // Clear login values (Mojang login) + // No cleanup needed for Microsoft. + loginUsername.value = '' + loginPassword.value = '' + if(loginOptionsViewCancelHandler != null){ + loginOptionsViewCancelHandler() + loginOptionsViewCancelHandler = null + } + }) +} \ No newline at end of file diff --git a/app/assets/js/scripts/overlay.js b/app/assets/js/scripts/overlay.js index 22d81d62..cf2c5c98 100644 --- a/app/assets/js/scripts/overlay.js +++ b/app/assets/js/scripts/overlay.js @@ -197,6 +197,9 @@ document.getElementById('accountSelectConfirm').addEventListener('click', () => const authAcc = ConfigManager.setSelectedAccount(listings[i].getAttribute('uuid')) ConfigManager.save() updateSelectedAccount(authAcc) + if(getCurrentView() === VIEWS.settings) { + prepareSettings() + } toggleOverlay(false) validateSelectedAccount() return @@ -207,6 +210,9 @@ document.getElementById('accountSelectConfirm').addEventListener('click', () => const authAcc = ConfigManager.setSelectedAccount(listings[0].getAttribute('uuid')) ConfigManager.save() updateSelectedAccount(authAcc) + if(getCurrentView() === VIEWS.settings) { + prepareSettings() + } toggleOverlay(false) validateSelectedAccount() } diff --git a/app/assets/js/scripts/settings.js b/app/assets/js/scripts/settings.js index 4a1997fe..d673cf48 100644 --- a/app/assets/js/scripts/settings.js +++ b/app/assets/js/scripts/settings.js @@ -4,6 +4,7 @@ const semver = require('semver') const { JavaGuard } = require('./assets/js/assetguard') const DropinModUtil = require('./assets/js/dropinmodutil') +const { MSFT_OPCODE, MSFT_REPLY_TYPE, MSFT_ERROR } = require('./assets/js/ipcconstants') const settingsState = { invalid: new Set() @@ -85,7 +86,7 @@ bindFileSelectors() /** * Bind value validators to the settings UI elements. These will * validate against the criteria defined in the ConfigManager (if - * and). If the value is invalid, the UI will reflect this and saving + * any). If the value is invalid, the UI will reflect this and saving * will be disabled until the value is corrected. This is an automated * process. More complex UI may need to be bound separately. */ @@ -314,8 +315,11 @@ settingsNavDone.onclick = () => { * Account Management Tab */ -// Bind the add account button. -document.getElementById('settingsAddAccount').onclick = (e) => { +const msftLoginLogger = LoggerUtil.getLogger('Microsoft Login') +const msftLogoutLogger = LoggerUtil.getLogger('Microsoft Logout') + +// Bind the add mojang account button. +document.getElementById('settingsAddMojangAccount').onclick = (e) => { switchView(getCurrentView(), VIEWS.login, 500, 500, () => { loginViewOnCancel = VIEWS.settings loginViewOnSuccess = VIEWS.settings @@ -323,6 +327,102 @@ document.getElementById('settingsAddAccount').onclick = (e) => { }) } +// Bind the add microsoft account button. +document.getElementById('settingsAddMicrosoftAccount').onclick = (e) => { + switchView(getCurrentView(), VIEWS.waiting, 500, 500, () => { + ipcRenderer.send(MSFT_OPCODE.OPEN_LOGIN, VIEWS.settings, VIEWS.settings) + }) +} + +// Bind reply for Microsoft Login. +ipcRenderer.on(MSFT_OPCODE.REPLY_LOGIN, (_, ...arguments_) => { + if (arguments_[0] === MSFT_REPLY_TYPE.ERROR) { + + const viewOnClose = arguments_[2] + console.log(arguments_) + switchView(getCurrentView(), viewOnClose, 500, 500, () => { + + if(arguments_[1] === MSFT_ERROR.NOT_FINISHED) { + // User cancelled. + msftLoginLogger.info('Login cancelled by user.') + return + } + + // Unexpected error. + setOverlayContent( + 'Something Went Wrong', + 'Microsoft authentication failed. Please try again.', + 'OK' + ) + setOverlayHandler(() => { + toggleOverlay(false) + }) + toggleOverlay(true) + }) + } else if(arguments_[0] === MSFT_REPLY_TYPE.SUCCESS) { + const queryMap = arguments_[1] + const viewOnClose = arguments_[2] + + // Error from request to Microsoft. + if (Object.prototype.hasOwnProperty.call(queryMap, 'error')) { + switchView(getCurrentView(), viewOnClose, 500, 500, () => { + // TODO Dont know what these errors are. Just show them I guess. + // This is probably if you messed up the app registration with Azure. + console.log('Error getting authCode, is Azure application registered correctly?') + console.log(error) + console.log(error_description) + console.log('Full query map', queryMap) + let error = queryMap.error // Error might be 'access_denied' ? + let errorDesc = queryMap.error_description + setOverlayContent( + error, + errorDesc, + 'OK' + ) + setOverlayHandler(() => { + toggleOverlay(false) + }) + toggleOverlay(true) + + }) + } else { + + msftLoginLogger.info('Acquired authCode, proceeding with authentication.') + + const authCode = queryMap.code + AuthManager.addMicrosoftAccount(authCode).then(value => { + updateSelectedAccount(value) + switchView(getCurrentView(), viewOnClose, 500, 500, () => { + prepareSettings() + }) + }) + .catch((displayableError) => { + + let actualDisplayableError + if(isDisplayableError(displayableError)) { + msftLoginLogger.error('Error while logging in.', displayableError) + actualDisplayableError = displayableError + } else { + // Uh oh. + msftLoginLogger.error('Unhandled error during login.', displayableError) + actualDisplayableError = { + title: 'Unknown Error During Login', + desc: 'An unknown error has occurred. Please see the console for details.' + } + } + + switchView(getCurrentView(), viewOnClose, 500, 500, () => { + setOverlayContent(actualDisplayableError.title, actualDisplayableError.desc, Lang.queryJS('login.tryAgain')) + setOverlayHandler(() => { + toggleOverlay(false) + }) + toggleOverlay(true) + }) + }) + } + } +}) + /** * Bind functionality for the account selection buttons. If another account * is selected, the UI of the previously selected account will be updated. @@ -367,7 +467,6 @@ function bindAuthAccountLogOut(){ setOverlayHandler(() => { processLogOut(val, isLastAccount) toggleOverlay(false) - switchView(getCurrentView(), VIEWS.login) }) setDismissHandler(() => { toggleOverlay(false) @@ -381,6 +480,7 @@ function bindAuthAccountLogOut(){ }) } +let msAccDomElementCache /** * Process a log out. * @@ -391,19 +491,91 @@ function processLogOut(val, isLastAccount){ const parent = val.closest('.settingsAuthAccount') const uuid = parent.getAttribute('uuid') const prevSelAcc = ConfigManager.getSelectedAccount() - AuthManager.removeAccount(uuid).then(() => { - if(!isLastAccount && uuid === prevSelAcc.uuid){ - const selAcc = ConfigManager.getSelectedAccount() - refreshAuthAccountSelected(selAcc.uuid) - updateSelectedAccount(selAcc) - validateSelectedAccount() - } - }) - $(parent).fadeOut(250, () => { - parent.remove() - }) + const targetAcc = ConfigManager.getAuthAccount(uuid) + if(targetAcc.type === 'microsoft') { + msAccDomElementCache = parent + switchView(getCurrentView(), VIEWS.waiting, 500, 500, () => { + ipcRenderer.send(MSFT_OPCODE.OPEN_LOGOUT, uuid, isLastAccount) + }) + } else { + AuthManager.removeMojangAccount(uuid).then(() => { + if(!isLastAccount && uuid === prevSelAcc.uuid){ + const selAcc = ConfigManager.getSelectedAccount() + refreshAuthAccountSelected(selAcc.uuid) + updateSelectedAccount(selAcc) + validateSelectedAccount() + } + if(isLastAccount) { + loginOptionsCancelEnabled(false) + loginOptionsViewOnLoginSuccess = VIEWS.settings + loginOptionsViewOnLoginCancel = VIEWS.loginOptions + switchView(getCurrentView(), VIEWS.loginOptions) + } + }) + $(parent).fadeOut(250, () => { + parent.remove() + }) + } } +// Bind reply for Microsoft Logout. +ipcRenderer.on(MSFT_OPCODE.REPLY_LOGOUT, (_, ...arguments_) => { + if (arguments_[0] === MSFT_REPLY_TYPE.ERROR) { + switchView(getCurrentView(), VIEWS.settings, 500, 500, () => { + + if(arguments_.length > 1 && arguments_[1] === MSFT_ERROR.NOT_FINISHED) { + // User cancelled. + msftLogoutLogger.info('Logout cancelled by user.') + return + } + + // Unexpected error. + setOverlayContent( + 'Something Went Wrong', + 'Microsoft logout failed. Please try again.', + 'OK' + ) + setOverlayHandler(() => { + toggleOverlay(false) + }) + toggleOverlay(true) + }) + } else if(arguments_[0] === MSFT_REPLY_TYPE.SUCCESS) { + + const uuid = arguments_[1] + const isLastAccount = arguments_[2] + const prevSelAcc = ConfigManager.getSelectedAccount() + + msftLogoutLogger.info('Logout Successful. uuid:', uuid) + + AuthManager.removeMicrosoftAccount(uuid) + .then(() => { + if(!isLastAccount && uuid === prevSelAcc.uuid){ + const selAcc = ConfigManager.getSelectedAccount() + refreshAuthAccountSelected(selAcc.uuid) + updateSelectedAccount(selAcc) + validateSelectedAccount() + } + if(isLastAccount) { + loginOptionsCancelEnabled(false) + loginOptionsViewOnLoginSuccess = VIEWS.settings + loginOptionsViewOnLoginCancel = VIEWS.loginOptions + switchView(getCurrentView(), VIEWS.loginOptions) + } + if(msAccDomElementCache) { + msAccDomElementCache.remove() + msAccDomElementCache = null + } + }) + .finally(() => { + if(!isLastAccount) { + switchView(getCurrentView(), VIEWS.settings, 500, 500) + } + }) + + } +}) + /** * Refreshes the status of the selected account on the auth account * elements. @@ -425,7 +597,8 @@ function refreshAuthAccountSelected(uuid){ }) } -const settingsCurrentAccounts = document.getElementById('settingsCurrentAccounts') +const settingsCurrentMicrosoftAccounts = document.getElementById('settingsCurrentMicrosoftAccounts') +const settingsCurrentMojangAccounts = document.getElementById('settingsCurrentMojangAccounts') /** * Add auth account elements for each one stored in the authentication database. @@ -438,11 +611,13 @@ function populateAuthAccounts(){ } const selectedUUID = ConfigManager.getSelectedAccount().uuid - let authAccountStr = '' + let microsoftAuthAccountStr = '' + let mojangAuthAccountStr = '' - authKeys.map((val) => { + authKeys.forEach((val) => { const acc = authAccounts[val] - authAccountStr += `
+ + const accHtml = `
${acc.displayName}
@@ -465,9 +640,17 @@ function populateAuthAccounts(){
` + + if(acc.type === 'microsoft') { + microsoftAuthAccountStr += accHtml + } else { + mojangAuthAccountStr += accHtml + } + }) - settingsCurrentAccounts.innerHTML = authAccountStr + settingsCurrentMicrosoftAccounts.innerHTML = microsoftAuthAccountStr + settingsCurrentMojangAccounts.innerHTML = mojangAuthAccountStr } /** diff --git a/app/assets/js/scripts/uibinder.js b/app/assets/js/scripts/uibinder.js index 0b080d1b..d3385140 100644 --- a/app/assets/js/scripts/uibinder.js +++ b/app/assets/js/scripts/uibinder.js @@ -16,9 +16,11 @@ let fatalStartupError = false // Mapping of each view to their container IDs. const VIEWS = { landing: '#landingContainer', + loginOptions: '#loginOptionsContainer', login: '#loginContainer', settings: '#settingsContainer', - welcome: '#welcomeContainer' + welcome: '#welcomeContainer', + waiting: '#waitingContainer' } // The currently shown view container. @@ -86,8 +88,11 @@ function showMainUI(data){ currentView = VIEWS.landing $(VIEWS.landing).fadeIn(1000) } else { - currentView = VIEWS.login - $(VIEWS.login).fadeIn(1000) + loginOptionsCancelEnabled(false) + loginOptionsViewOnLoginSuccess = VIEWS.landing + loginOptionsViewOnLoginCancel = VIEWS.loginOptions + currentView = VIEWS.loginOptions + $(VIEWS.loginOptions).fadeIn(1000) } } @@ -329,20 +334,46 @@ async function validateSelectedAccount(){ 'Select Another Account' ) setOverlayHandler(() => { - document.getElementById('loginUsername').value = selectedAcc.username - validateEmail(selectedAcc.username) - loginViewOnSuccess = getCurrentView() - loginViewOnCancel = getCurrentView() - if(accLen > 0){ - loginViewCancelHandler = () => { - ConfigManager.addAuthAccount(selectedAcc.uuid, selectedAcc.accessToken, selectedAcc.username, selectedAcc.displayName) + + const isMicrosoft = selectedAcc.type === 'microsoft' + + if(isMicrosoft) { + // Empty for now + } else { + // Mojang + // For convenience, pre-populate the username of the account. + document.getElementById('loginUsername').value = selectedAcc.username + validateEmail(selectedAcc.username) + } + + loginOptionsViewOnLoginSuccess = getCurrentView() + loginOptionsViewOnLoginCancel = VIEWS.loginOptions + + if(accLen > 0) { + loginOptionsViewOnCancel = getCurrentView() + loginOptionsViewCancelHandler = () => { + if(isMicrosoft) { + ConfigManager.addMicrosoftAuthAccount( + selectedAcc.uuid, + selectedAcc.accessToken, + selectedAcc.username, + selectedAcc.expiresAt, + selectedAcc.microsoft.access_token, + selectedAcc.microsoft.refresh_token, + selectedAcc.microsoft.expires_at + ) + } else { + ConfigManager.addMojangAuthAccount(selectedAcc.uuid, selectedAcc.accessToken, selectedAcc.username, selectedAcc.displayName) + } ConfigManager.save() validateSelectedAccount() } - loginCancelEnabled(true) + loginOptionsCancelEnabled(true) + } else { + loginOptionsCancelEnabled(false) } toggleOverlay(false) - switchView(getCurrentView(), VIEWS.login) + switchView(getCurrentView(), VIEWS.loginOptions) }) setDismissHandler(() => { if(accLen > 1){ diff --git a/app/assets/js/scripts/uicore.js b/app/assets/js/scripts/uicore.js index 7d3cddbd..71a5aa19 100644 --- a/app/assets/js/scripts/uicore.js +++ b/app/assets/js/scripts/uicore.js @@ -9,11 +9,12 @@ const $ = require('jquery') const {ipcRenderer, shell, webFrame} = require('electron') const remote = require('@electron/remote') const isDev = require('./assets/js/isdev') -const LoggerUtil = require('./assets/js/loggerutil') +const { LoggerUtil } = require('helios-core') +const LoggerUtil1 = require('./assets/js/loggerutil') -const loggerUICore = LoggerUtil('%c[UICore]', 'color: #000668; font-weight: bold') -const loggerAutoUpdater = LoggerUtil('%c[AutoUpdater]', 'color: #000668; font-weight: bold') -const loggerAutoUpdaterSuccess = LoggerUtil('%c[AutoUpdater]', 'color: #209b07; font-weight: bold') +const loggerUICore = LoggerUtil1('%c[UICore]', 'color: #000668; font-weight: bold') +const loggerAutoUpdater = LoggerUtil1('%c[AutoUpdater]', 'color: #000668; font-weight: bold') +const loggerAutoUpdaterSuccess = LoggerUtil1('%c[AutoUpdater]', 'color: #209b07; font-weight: bold') // Log deprecation and process warnings. process.traceProcessWarnings = true @@ -49,7 +50,7 @@ if(!isDev){ loggerAutoUpdaterSuccess.log('New update available', info.version) if(process.platform === 'darwin'){ - info.darwindownload = `https://github.com/dscalzi/HeliosLauncher/releases/download/v${info.version}/helioslauncher-setup-${info.version}${process.arch === 'arm64' ? '-arm64' : ''}.dmg` + info.darwindownload = `https://github.com/dscalzi/HeliosLauncher/releases/download/v${info.version}/Helios-Launcher-setup-${info.version}${process.arch === 'arm64' ? '-arm64' : '-x64'}.dmg` showUpdateUI(info) } diff --git a/app/assets/js/scripts/welcome.js b/app/assets/js/scripts/welcome.js index e6ff6297..ed0399c3 100644 --- a/app/assets/js/scripts/welcome.js +++ b/app/assets/js/scripts/welcome.js @@ -2,5 +2,8 @@ * Script for welcome.ejs */ document.getElementById('welcomeButton').addEventListener('click', e => { - switchView(VIEWS.welcome, VIEWS.login) + loginOptionsCancelEnabled(false) // False by default, be explicit. + loginOptionsViewOnLoginSuccess = VIEWS.landing + loginOptionsViewOnLoginCancel = VIEWS.loginOptions + switchView(VIEWS.welcome, VIEWS.loginOptions) }) \ No newline at end of file diff --git a/app/loginOptions.ejs b/app/loginOptions.ejs new file mode 100644 index 00000000..36af37e0 --- /dev/null +++ b/app/loginOptions.ejs @@ -0,0 +1,34 @@ + \ No newline at end of file diff --git a/app/settings.ejs b/app/settings.ejs index f5505cfe..65a1796d 100644 --- a/app/settings.ejs +++ b/app/settings.ejs @@ -28,16 +28,45 @@ Account Settings Add new accounts or manage existing ones. -
- +
+
+
+ + + + + + + Microsoft +
+
+ +
+
+ +
+ +
-
- Current Accounts -
-
- + +
+
+
+ + + + + + Mojang +
+
+ +
+
+ +
+ +