diff --git a/app/app.ejs b/app/app.ejs index 499c10d5..087b6622 100644 --- a/app/app.ejs +++ b/app/app.ejs @@ -31,6 +31,7 @@
<%- include('welcome') %> <%- include('login') %> + <%- include('waiting') %> <%- include('settings') %> <%- include('landing') %>
diff --git a/app/assets/css/launcher.css b/app/assets/css/launcher.css index 5f7ede5a..efdd202f 100644 --- a/app/assets/css/launcher.css +++ b/app/assets/css/launcher.css @@ -872,6 +872,85 @@ 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); + } +} + /******************************************************************************* * * * Settings View (sttings.ejs) * diff --git a/app/assets/js/authmanager.js b/app/assets/js/authmanager.js index 5befb727..4aa1b783 100644 --- a/app/assets/js/authmanager.js +++ b/app/assets/js/authmanager.js @@ -9,17 +9,19 @@ * @module authmanager */ // Requirements -const ConfigManager = require('./configmanager') -const { LoggerUtil } = require('helios-core') -const { MojangRestAPI, mojangErrorDisplayable, MojangErrorCode } = require('helios-core/mojang') +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 } = 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. * @@ -27,7 +29,7 @@ const log = LoggerUtil.getLogger('AuthManager') * @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 response = await MojangRestAPI.authenticate(username, password, ConfigManager.getClientToken()) console.log(response) @@ -35,7 +37,7 @@ exports.addAccount = async function(username, password){ const session = response.data if(session.selectedProfile != null){ - const ret = ConfigManager.addAuthAccount(session.selectedProfile.id, session.accessToken, username, session.selectedProfile.name) + const ret = ConfigManager.addMojangAuthAccount(session.selectedProfile.id, session.accessToken, username, session.selectedProfile.name) if(ConfigManager.getClientToken() == null){ ConfigManager.setClientToken(session.clientToken) } @@ -55,14 +57,113 @@ exports.addAccount = async function(username, password){ } } +const AUTH_MODE = { FULL: 0, MS_REFRESH: 1, MC_REFRESH: 2 } + /** - * Remove an account. This will invalidate the access token associated + * 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) { + + 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) { + // TODO Fail. + return // TODO + } + accessToken = accessTokenResponse.data + accessTokenRaw = accessToken.access_token + } else { + accessTokenRaw = entryCode + } + + const xblResponse = await MicrosoftAuth.getXBLToken(accessTokenRaw) + if(xblResponse.responseStatus === RestResponseStatus.ERROR) { + // TODO Fail. + return // TODO + } + const xstsResonse = await MicrosoftAuth.getXSTSToken(xblResponse.data) + if(xstsResonse.responseStatus === RestResponseStatus.ERROR) { + // TODO Fail. + return // TODO + } + const mcTokenResponse = await MicrosoftAuth.getMCAccessToken(xstsResonse.data) + if(mcTokenResponse.responseStatus === RestResponseStatus.ERROR) { + // TODO Fail. + return // TODO + } + const mcProfileResponse = await MicrosoftAuth.getMCProfile(mcTokenResponse.data.access_token) + if(mcProfileResponse.responseStatus === RestResponseStatus.ERROR) { + // TODO Fail. + return // TODO + } + return { + accessToken, + accessTokenRaw, + xbl: xblResponse.data, + xsts: xstsResonse.data, + mcToken: mcTokenResponse.data, + mcProfile: mcProfileResponse.data + } +} + +/** + * 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 + */ +async 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) const response = await MojangRestAPI.invalidate(authAcc.accessToken, ConfigManager.getClientToken()) @@ -80,17 +181,33 @@ exports.removeAccount = async function(uuid){ } } +/** + * 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) + } +} + /** * Validate the selected account with Mojang'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. * - * **Function is WIP** - * * @returns {Promise.} Promise which resolves to true if the access token is valid, * otherwise false. */ -exports.validateSelected = async function(){ +async function validateSelectedMojangAccount(){ const current = ConfigManager.getSelectedAccount() const response = await MojangRestAPI.validate(current.accessToken, ConfigManager.getClientToken()) @@ -100,7 +217,7 @@ exports.validateSelected = async function(){ const refreshResponse = await MojangRestAPI.refresh(current.accessToken, ConfigManager.getClientToken()) if(refreshResponse.responseStatus === RestResponseStatus.SUCCESS) { const session = refreshResponse.data - ConfigManager.updateAuthAccount(current.uuid, session.accessToken) + ConfigManager.updateMojangAuthAccount(current.uuid, session.accessToken) ConfigManager.save() } else { log.error('Error while validating selected profile:', refreshResponse.error) @@ -115,4 +232,84 @@ exports.validateSelected = async function(){ } } +} + +/** + * 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() + + if(current.type === 'microsoft') { + return await validateSelectedMicrosoftAccount() + } else { + 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/ipcconstants.js b/app/assets/js/ipcconstants.js new file mode 100644 index 00000000..dcd4d946 --- /dev/null +++ b/app/assets/js/ipcconstants.js @@ -0,0 +1,15 @@ +exports.AZURE_CLIENT_ID = 'FILL-IN' +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' +} +exports.MSFT_REPLY_TYPE = { + SUCCESS: 'MSFT_AUTH_REPLY_SUCCESS', + ERROR: 'MSFT_AUTH_REPLY_ERROR' +} +exports.MSFT_ERROR = { + ALREADY_OPEN: 'MSFT_AUTH_ERR_ALREADY_OPEN', + NOT_FINISHED: 'MSFT_AUTH_ERR_NOT_FINISHED' +} \ No newline at end of file diff --git a/app/assets/js/scripts/login.js b/app/assets/js/scripts/login.js index 5cae2fb7..6c83c17f 100644 --- a/app/assets/js/scripts/login.js +++ b/app/assets/js/scripts/login.js @@ -189,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') diff --git a/app/assets/js/scripts/settings.js b/app/assets/js/scripts/settings.js index 90b478ff..8b8698d6 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() @@ -314,7 +315,7 @@ settingsNavDone.onclick = () => { * Account Management Tab */ -// Bind the add account button. +// Bind the add mojang account button. document.getElementById('settingsAddMojangAccount').onclick = (e) => { switchView(getCurrentView(), VIEWS.login, 500, 500, () => { loginViewOnCancel = VIEWS.settings @@ -323,6 +324,74 @@ document.getElementById('settingsAddMojangAccount').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) + }) +} + +// Bind reply for Microsoft Login. +ipcRenderer.on(MSFT_OPCODE.REPLY_LOGIN, (_, ...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. + // TODO Get logger from LoggerUtil + console.log('Login Cancelled') + 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] + + // Error from request to Microsoft. + if (Object.prototype.hasOwnProperty.call(queryMap, 'error')) { + let error = queryMap.error + let errorDesc = queryMap.error_description + title = error + description = errorDesc + if (error === 'access_denied') { + // TODO Write custom error messages. + title = error + description = errorDesc + } + setOverlayContent( + title, + description, + 'OK' + ) + setOverlayHandler(() => { + toggleOverlay(false) + }) + toggleOverlay(true) + } else { + + // TODO Update logging message + console.log('Acquired authCode') + + const authCode = queryMap.code + AuthManager.addMicrosoftAccount(authCode).then(value => { + updateSelectedAccount(value) + switchView(getCurrentView(), VIEWS.settings, 500, 500, () => { + }) + }) + } + } +}) + /** * Bind functionality for the account selection buttons. If another account * is selected, the UI of the previously selected account will be updated. @@ -391,19 +460,75 @@ 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') { + switchView(getCurrentView(), VIEWS.waiting, 500, 500, () => { + ipcRenderer.send(MSFT_OPCODE.OPEN_LOGOUT, uuid, isLastAccount) + }) + // TODO ADD LOGIC FOR LAST ACCOUNT - SAME AS SOLUTION FOR FIRST TIME LOGIN! + } else { + AuthManager.removeMojangAccount(uuid).then(() => { + if(!isLastAccount && uuid === prevSelAcc.uuid){ + const selAcc = ConfigManager.getSelectedAccount() + refreshAuthAccountSelected(selAcc.uuid) + updateSelectedAccount(selAcc) + validateSelectedAccount() + } + }) + $(parent).fadeOut(250, () => { + parent.remove() + }) + } } +// Bind reply for Microsoft Logout. +ipcRenderer.on(MSFT_OPCODE.REPLY_LOGOUT, (_, ...arguments_) => { + console.log('on 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. + // TODO Get logger from LoggerUtil + console.log('Logout Cancelled') + 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() + + console.log('Logout Successful. uuid:', uuid) + + AuthManager.removeMicrosoftAccount(uuid) + .then(() => { + if(!isLastAccount && uuid === prevSelAcc.uuid){ + const selAcc = ConfigManager.getSelectedAccount() + refreshAuthAccountSelected(selAcc.uuid) + updateSelectedAccount(selAcc) + validateSelectedAccount() + } + }) + .finally(() => { + switchView(getCurrentView(), VIEWS.settings, 500, 500) + }) + + } +}) + /** * Refreshes the status of the selected account on the auth account * elements. diff --git a/app/assets/js/scripts/uibinder.js b/app/assets/js/scripts/uibinder.js index 0b080d1b..d6eb792e 100644 --- a/app/assets/js/scripts/uibinder.js +++ b/app/assets/js/scripts/uibinder.js @@ -18,7 +18,8 @@ const VIEWS = { landing: '#landingContainer', login: '#loginContainer', settings: '#settingsContainer', - welcome: '#welcomeContainer' + welcome: '#welcomeContainer', + waiting: '#waitingContainer' } // The currently shown view container. @@ -335,7 +336,7 @@ async function validateSelectedAccount(){ loginViewOnCancel = getCurrentView() if(accLen > 0){ loginViewCancelHandler = () => { - ConfigManager.addAuthAccount(selectedAcc.uuid, selectedAcc.accessToken, selectedAcc.username, selectedAcc.displayName) + ConfigManager.addMojangAuthAccount(selectedAcc.uuid, selectedAcc.accessToken, selectedAcc.username, selectedAcc.displayName) ConfigManager.save() validateSelectedAccount() } diff --git a/app/waiting.ejs b/app/waiting.ejs new file mode 100644 index 00000000..11c7e4d2 --- /dev/null +++ b/app/waiting.ejs @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/index.js b/index.js index 08d6be35..4a8e48a4 100644 --- a/index.js +++ b/index.js @@ -3,13 +3,14 @@ remoteMain.initialize() // Requirements const { app, BrowserWindow, ipcMain, Menu } = require('electron') -const autoUpdater = require('electron-updater').autoUpdater -const ejse = require('ejs-electron') -const fs = require('fs') -const isDev = require('./app/assets/js/isdev') -const path = require('path') -const semver = require('semver') -const { pathToFileURL } = require('url') +const autoUpdater = require('electron-updater').autoUpdater +const ejse = require('ejs-electron') +const fs = require('fs') +const isDev = require('./app/assets/js/isdev') +const path = require('path') +const semver = require('semver') +const { pathToFileURL } = require('url') +const { AZURE_CLIENT_ID, MSFT_OPCODE, MSFT_REPLY_TYPE, MSFT_ERROR } = require('./app/assets/js/ipcconstants') // Setup auto updater. function initAutoUpdater(event, data) { @@ -88,6 +89,106 @@ ipcMain.on('distributionIndexDone', (event, res) => { // https://electronjs.org/docs/tutorial/offscreen-rendering app.disableHardwareAcceleration() + +const REDIRECT_URI_PREFIX = 'https://login.microsoftonline.com/common/oauth2/nativeclient?' + +// Microsoft Auth Login +let msftAuthWindow +let msftAuthSuccess +ipcMain.on(MSFT_OPCODE.OPEN_LOGIN, (ipcEvent) => { + if (msftAuthWindow) { + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGIN, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.ALREADY_OPEN) + return + } + msftAuthSuccess = false + msftAuthWindow = new BrowserWindow({ + title: 'Microsoft Login', + backgroundColor: '#222222', + width: 520, + height: 600, + frame: true, + icon: getPlatformIcon('SealCircle') + }) + + msftAuthWindow.on('closed', () => { + msftAuthWindow = undefined + }) + + msftAuthWindow.on('close', () => { + if(!msftAuthSuccess) { + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGIN, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.NOT_FINISHED) + } + }) + + msftAuthWindow.webContents.on('did-navigate', (_, uri) => { + if (uri.startsWith(REDIRECT_URI_PREFIX)) { + let queries = uri.substring(REDIRECT_URI_PREFIX.length).split('#', 1).toString().split('&') + let queryMap = {} + + queries.forEach(query => { + const [name, value] = query.split('=') + queryMap[name] = decodeURI(value) + }) + + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGIN, MSFT_REPLY_TYPE.SUCCESS, queryMap) + + msftAuthSuccess = true + msftAuthWindow.close() + msftAuthWindow = null + } + }) + + msftAuthWindow.removeMenu() + msftAuthWindow.loadURL(`https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?prompt=select_account&client_id=${AZURE_CLIENT_ID}&response_type=code&scope=XboxLive.signin%20offline_access&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient`) +}) + +// Microsoft Auth Logout +let msftLogoutWindow +let msftLogoutSuccess +ipcMain.on(MSFT_OPCODE.OPEN_LOGOUT, (ipcEvent, uuid, isLastAccount) => { + if (msftLogoutWindow) { + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGOUT, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.ALREADY_OPEN) + return + } + + msftLogoutSuccess = false + msftLogoutWindow = new BrowserWindow({ + title: 'Microsoft Logout', + backgroundColor: '#222222', + width: 520, + height: 600, + frame: true, + icon: getPlatformIcon('SealCircle') + }) + + msftLogoutWindow.on('closed', () => { + msftLogoutWindow = undefined + }) + + msftLogoutWindow.on('close', () => { + if(!msftLogoutSuccess) { + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGOUT, MSFT_REPLY_TYPE.ERROR, MSFT_ERROR.NOT_FINISHED) + } + }) + + msftLogoutWindow.webContents.on('did-navigate', () => { + setTimeout(() => { + if(msftLogoutWindow) { + ipcEvent.reply(MSFT_OPCODE.REPLY_LOGOUT, MSFT_REPLY_TYPE.SUCCESS, uuid, isLastAccount) + msftLogoutSuccess = true + + if(msftLogoutWindow) { + msftLogoutWindow.close() + msftLogoutWindow = null + } + } + }, 5000) + }) + + msftLogoutWindow.removeMenu() + msftLogoutWindow.loadURL('https://login.microsoftonline.com/common/oauth2/v2.0/logout') +}) + // 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. let win