import { VIEWS } from './views.js' import * as Lang from './langrenderer.js' /* * Core UI functions are initialized in this file. This prevents * unexpected errors from breaking the core features. Specifically, * actions in this file should not require the usage of any internal * modules, excluding dependencies. */ const isDev = await window.api.app.isDev() const platform = await window.api.process.platform() const arch = await window.api.process.arch() const autoUpdatePort = await window.api.AutoUpdater.port2() // const { LoggerUtil } = require('helios-core') // const loggerUICore = LoggerUtil.getLogger('UICore') // const loggerAutoUpdater = LoggerUtil.getLogger('AutoUpdater') // Disable eval function. // eslint-disable-next-line window.eval = function () { throw new Error('Sorry, this app does not support window.eval().') } // Initialize auto updates in production environments. // eslint-disable-next-line no-unused-vars let updateCheckListener if(!isDev){ autoUpdatePort.on('message', async (event) => { const channel = event[0] if(channel === 'autoUpdateNotification') { const command = event[1] const info = event[2] switch(command){ case 'checking-for-update': // loggerAutoUpdater.info('Checking for update..') settingsUpdateButtonStatus(Lang.queryJS('uicore.autoUpdate.checkingForUpdateButton'), true) break case 'update-available': // loggerAutoUpdater.info('New update available', info.version) if(platform === 'darwin'){ info.darwindownload = `https://github.com/dscalzi/HeliosLauncher/releases/download/v${info.version}/Helios-Launcher-setup-${info.version}${arch === 'arm64' ? '-arm64' : '-x64'}.dmg` showUpdateUI(info) } await populateSettingsUpdateInformation(info) break case 'update-downloaded': // loggerAutoUpdater.info('Update ' + info.version + ' ready to be installed.') settingsUpdateButtonStatus(Lang.queryJS('uicore.autoUpdate.installNowButton'), false, () => { if(!isDev){ autoUpdatePort.postMessage(['autoUpdateAction', 'installUpdateNow']) } }) showUpdateUI(info) break case 'update-not-available': // loggerAutoUpdater.info('No new update found.') settingsUpdateButtonStatus(Lang.queryJS('uicore.autoUpdate.checkForUpdatesButton')) break case 'ready': updateCheckListener = setInterval(() => { autoUpdatePort.postMessage(['autoUpdateAction', 'checkForUpdate']) }, 1800000) autoUpdatePort.postMessage(['autoUpdateAction', 'checkForUpdate']) break case 'realerror': if(info != null && info.code != null){ if(info.code === 'ERR_UPDATER_INVALID_RELEASE_FEED'){ // loggerAutoUpdater.info('No suitable releases found.') } else if(info.code === 'ERR_XML_MISSED_ELEMENT'){ // loggerAutoUpdater.info('No releases found.') } else { // loggerAutoUpdater.error('Error during update check..', info) // loggerAutoUpdater.debug('Error Code:', info.code) } } break default: // loggerAutoUpdater.info('Unknown argument', arg) break } } }) } /** * Send a notification to the main process changing the value of * allowPrerelease. If we are running a prerelease version, then * this will always be set to true, regardless of the current value * of val. * * @param {boolean} val The new allow prerelease value. */ function changeAllowPrerelease(val){ autoUpdatePort.postMessage(['autoUpdateAction', 'allowPrereleaseChange', val]) } function showUpdateUI(info){ //TODO Make this message a bit more informative `${info.version}` document.getElementById('image_seal_container').setAttribute('update', true) document.getElementById('image_seal_container').onclick = () => { /*setOverlayContent('Update Available', 'A new update for the launcher is available. Would you like to install now?', 'Install', 'Later') setOverlayHandler(() => { if(!isDev){ autoUpdatePort.postMessage(['autoUpdateAction', 'installUpdateNow']) } else { console.error('Cannot install updates in development environment.') toggleOverlay(false) } }) setDismissHandler(() => { toggleOverlay(false) }) toggleOverlay(true, true)*/ switchView(getCurrentView(), VIEWS.settings, 500, 500, () => { settingsNavItemListener(document.getElementById('settingsNavUpdate'), false) }) } } /* jQuery Example $(function(){ loggerUICore.info('UICore Initialized'); })*/ // loggerUICore.info('UICore Initializing..') // Bind close button. Array.from(document.getElementsByClassName('fCb')).map((val) => { val.addEventListener('click', async e => { await window.api.xwindow.close() }) }) // Bind restore down button. Array.from(document.getElementsByClassName('fRb')).map((val) => { val.addEventListener('click', async e => { if(await window.api.xwindow.isMaximized()){ await window.api.xwindow.unmaximize() } else { await window.api.xwindow.maximize() } document.activeElement.blur() }) }) // Bind minimize button. Array.from(document.getElementsByClassName('fMb')).map((val) => { val.addEventListener('click', async e => { console.log('hi') await window.api.xwindow.minimize() document.activeElement.blur() }) }) // Remove focus from social media buttons once they're clicked. Array.from(document.getElementsByClassName('mediaURL')).map(val => { val.addEventListener('click', e => { document.activeElement.blur() }) }) //266.01 //170.8 //53.21 // Bind progress bar length to length of bot wrapper //const targetWidth = document.getElementById("launch_content").getBoundingClientRect().width //const targetWidth2 = document.getElementById("server_selection").getBoundingClientRect().width //const targetWidth3 = document.getElementById("launch_button").getBoundingClientRect().width document.getElementById('launch_details').style.maxWidth = 266.01 document.getElementById('launch_progress').style.width = 170.8 document.getElementById('launch_details_right').style.maxWidth = 170.8 document.getElementById('launch_progress_label').style.width = 53.21 /** * Open web links in the user's default browser. */ $(document).on('click', 'a[href^="http"]', async (event) => { event.preventDefault() await window.api.shell.openExternal(this.href) }) /** * Opens DevTools window if you hold (ctrl + shift + i). * This will crash the program if you are using multiple * DevTools, for example the chrome debugger in VS Code. */ document.addEventListener('keydown', async (e) => { if((e.key === 'I' || e.key === 'i') && e.ctrlKey && e.shiftKey){ await window.api.xwindow.toggleDevTools() } }) /** * UIBINDER */ let rscShouldLoad = false let fatalStartupError = false // The currently shown view container. let currentView /** * Switch launcher views. * * @param {string} current The ID of the current view container. * @param {*} next The ID of the next view container. * @param {*} currentFadeTime Optional. The fade out time for the current view. * @param {*} nextFadeTime Optional. The fade in time for the next view. * @param {*} onCurrentFade Optional. Callback function to execute when the current * view fades out. * @param {*} onNextFade Optional. Callback function to execute when the next view * fades in. */ function switchView(current, next, currentFadeTime = 500, nextFadeTime = 500, onCurrentFade = () => {}, onNextFade = () => {}){ currentView = next $(`${current}`).fadeOut(currentFadeTime, async () => { await onCurrentFade() $(`${next}`).fadeIn(nextFadeTime, async () => { await onNextFade() }) }) } /** * Get the currently shown view container. * * @returns {string} The currently shown view container. */ function getCurrentView(){ return currentView } async function showMainUI(data){ if(!isDev){ loggerAutoUpdater.info('Initializing..') autoUpdatePort.postMessage(['autoUpdateAction', 'initAutoUpdater', ConfigManager.getAllowPrerelease()]) } await prepareSettings(true) updateSelectedServer(data.getServerById(ConfigManager.getSelectedServer())) refreshServerStatus() setTimeout(() => { document.getElementById('frameBar').style.backgroundColor = 'rgba(0, 0, 0, 0.5)' document.body.style.backgroundImage = `url('assets/images/backgrounds/${document.body.getAttribute('bkid')}.jpg')` $('#main').show() const isLoggedIn = Object.keys(ConfigManager.getAuthAccounts()).length > 0 // If this is enabled in a development environment we'll get ratelimited. // The relaunch frequency is usually far too high. if(!isDev && isLoggedIn){ validateSelectedAccount() } if(ConfigManager.isFirstLaunch()){ currentView = VIEWS.welcome $(VIEWS.welcome).fadeIn(1000) } else { if(isLoggedIn){ currentView = VIEWS.landing $(VIEWS.landing).fadeIn(1000) } else { loginOptionsCancelEnabled(false) loginOptionsViewOnLoginSuccess = VIEWS.landing loginOptionsViewOnLoginCancel = VIEWS.loginOptions currentView = VIEWS.loginOptions $(VIEWS.loginOptions).fadeIn(1000) } } setTimeout(() => { $('#loadingContainer').fadeOut(500, () => { $('#loadSpinnerImage').removeClass('rotating') }) }, 250) }, 750) // Disable tabbing to the news container. initNews().then(() => { $('#newsContainer *').attr('tabindex', '-1') }) } function showFatalStartupError(){ setTimeout(() => { $('#loadingContainer').fadeOut(250, () => { document.getElementById('overlayContainer').style.background = 'none' setOverlayContent( Lang.queryJS('uibinder.startup.fatalErrorTitle'), Lang.queryJS('uibinder.startup.fatalErrorMessage'), Lang.queryJS('uibinder.startup.closeButton') ) setOverlayHandler(async () => { await window.api.xwindow.close() }) toggleOverlay(true) }) }, 750) } /** * Common functions to perform after refreshing the distro index. * * @param {Object} data The distro index object. */ function onDistroRefresh(data){ updateSelectedServer(data.getServerById(ConfigManager.getSelectedServer())) refreshServerStatus() initNews() syncModConfigurations(data) ensureJavaSettings(data) } /** * Sync the mod configurations with the distro index. * * @param {Object} data The distro index object. */ async function syncModConfigurations(data){ const syncedCfgs = [] const Type = await hc.type for(let serv of data.servers){ const id = serv.rawServer.id const mdls = serv.modules const cfg = ConfigManager.getModConfiguration(id) if(cfg != null){ const modsOld = cfg.mods const mods = {} for(let mdl of mdls){ const type = mdl.rawModule.type if(type === Type.ForgeMod || type === Type.LiteMod || type === Type.LiteLoader){ if(!mdl.getRequired().value){ const mdlID = mdl.getVersionlessMavenIdentifier() if(modsOld[mdlID] == null){ mods[mdlID] = scanOptionalSubModules(mdl.subModules, mdl) } else { mods[mdlID] = mergeModConfiguration(modsOld[mdlID], scanOptionalSubModules(mdl.subModules, mdl), false) } } else { if(mdl.subModules.length > 0){ const mdlID = mdl.getVersionlessMavenIdentifier() const v = scanOptionalSubModules(mdl.subModules, mdl) if(typeof v === 'object'){ if(modsOld[mdlID] == null){ mods[mdlID] = v } else { mods[mdlID] = mergeModConfiguration(modsOld[mdlID], v, true) } } } } } } syncedCfgs.push({ id, mods }) } else { const mods = {} for(let mdl of mdls){ const type = mdl.rawModule.type if(type === Type.ForgeMod || type === Type.LiteMod || type === Type.LiteLoader){ if(!mdl.getRequired().value){ mods[mdl.getVersionlessMavenIdentifier()] = scanOptionalSubModules(mdl.subModules, mdl) } else { if(mdl.subModules.length > 0){ const v = scanOptionalSubModules(mdl.subModules, mdl) if(typeof v === 'object'){ mods[mdl.getVersionlessMavenIdentifier()] = v } } } } } syncedCfgs.push({ id, mods }) } } ConfigManager.setModConfigurations(syncedCfgs) ConfigManager.save() } /** * Ensure java configurations are present for the available servers. * * @param {Object} data The distro index object. */ function ensureJavaSettings(data) { // Nothing too fancy for now. for(const serv of data.servers){ ConfigManager.ensureJavaConfig(serv.rawServer.id, serv.effectiveJavaOptions, serv.rawServer.javaOptions?.ram) } ConfigManager.save() } /** * Recursively scan for optional sub modules. If none are found, * this function returns a boolean. If optional sub modules do exist, * a recursive configuration object is returned. * * @returns {boolean | Object} The resolved mod configuration. */ function scanOptionalSubModules(mdls, origin){ if(mdls != null){ const mods = {} for(let mdl of mdls){ const type = mdl.rawModule.type // Optional types. if(type === Type.ForgeMod || type === Type.LiteMod || type === Type.LiteLoader){ // It is optional. if(!mdl.getRequired().value){ mods[mdl.getVersionlessMavenIdentifier()] = scanOptionalSubModules(mdl.subModules, mdl) } else { if(mdl.hasSubModules()){ const v = scanOptionalSubModules(mdl.subModules, mdl) if(typeof v === 'object'){ mods[mdl.getVersionlessMavenIdentifier()] = v } } } } } if(Object.keys(mods).length > 0){ const ret = { mods } if(!origin.getRequired().value){ ret.value = origin.getRequired().def } return ret } } return origin.getRequired().def } /** * Recursively merge an old configuration into a new configuration. * * @param {boolean | Object} o The old configuration value. * @param {boolean | Object} n The new configuration value. * @param {boolean} nReq If the new value is a required mod. * * @returns {boolean | Object} The merged configuration. */ function mergeModConfiguration(o, n, nReq = false){ if(typeof o === 'boolean'){ if(typeof n === 'boolean') return o else if(typeof n === 'object'){ if(!nReq){ n.value = o } return n } } else if(typeof o === 'object'){ if(typeof n === 'boolean') return typeof o.value !== 'undefined' ? o.value : true else if(typeof n === 'object'){ if(!nReq){ n.value = typeof o.value !== 'undefined' ? o.value : true } const newMods = Object.keys(n.mods) for(let i=0; i 0 ? Lang.queryJS('uibinder.validateAccount.failedMessage', { 'account': selectedAcc.displayName }) : Lang.queryJS('uibinder.validateAccount.failedMessageSelectAnotherAccount', { 'account': selectedAcc.displayName }), Lang.queryJS('uibinder.validateAccount.loginButton'), Lang.queryJS('uibinder.validateAccount.selectAnotherAccountButton') ) setOverlayHandler(() => { 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() } loginOptionsCancelEnabled(true) } else { loginOptionsCancelEnabled(false) } toggleOverlay(false) switchView(getCurrentView(), VIEWS.loginOptions) }) setDismissHandler(() => { if(accLen > 1){ prepareAccountSelectionList() $('#overlayContent').fadeOut(250, () => { bindOverlayKeys(true, 'accountSelectContent', true) $('#accountSelectContent').fadeIn(250) }) } else { const accountsObj = ConfigManager.getAuthAccounts() const accounts = Array.from(Object.keys(accountsObj), v => accountsObj[v]) // This function validates the account switch. setSelectedAccount(accounts[0].uuid) toggleOverlay(false) } }) toggleOverlay(true, accLen > 0) } else { return true } } else { return true } } /** * Temporary function to update the selected account along * with the relevent UI elements. * * @param {string} uuid The UUID of the account. */ function setSelectedAccount(uuid){ const authAcc = ConfigManager.setSelectedAccount(uuid) ConfigManager.save() updateSelectedAccount(authAcc) validateSelectedAccount() } // Synchronous Listener document.addEventListener('readystatechange', async () => { if (document.readyState === 'interactive' || document.readyState === 'complete'){ if(rscShouldLoad){ rscShouldLoad = false if(!fatalStartupError){ const data = await DistroAPI.getDistribution() await showMainUI(data) } else { showFatalStartupError() } } } }, false) // Actions that must be performed after the distribution index is downloaded. ipcRenderer.on('distributionIndexDone', async (event, res) => { if(res) { const data = await DistroAPI.getDistribution() await syncModConfigurations(data) ensureJavaSettings(data) if(document.readyState === 'interactive' || document.readyState === 'complete'){ await showMainUI(data) } else { rscShouldLoad = true } } else { fatalStartupError = true if(document.readyState === 'interactive' || document.readyState === 'complete'){ showFatalStartupError() } else { rscShouldLoad = true } } }) // Util for development async function devModeToggle() { DistroAPI.toggleDevMode(true) const data = await DistroAPI.refreshDistributionOrFallback() ensureJavaSettings(data) updateSelectedServer(data.servers[0]) await syncModConfigurations(data) } // OVERLAY.EJS /** * Script for overlay.ejs */ /* Overlay Wrapper Functions */ /** * Check to see if the overlay is visible. * * @returns {boolean} Whether or not the overlay is visible. */ function isOverlayVisible(){ return document.getElementById('main').hasAttribute('overlay') } let overlayHandlerContent /** * Overlay keydown handler for a non-dismissable overlay. * * @param {KeyboardEvent} e The keydown event. */ function overlayKeyHandler (e){ if(e.key === 'Enter' || e.key === 'Escape'){ document.getElementById(overlayHandlerContent).getElementsByClassName('overlayKeybindEnter')[0].click() } } /** * Overlay keydown handler for a dismissable overlay. * * @param {KeyboardEvent} e The keydown event. */ function overlayKeyDismissableHandler (e){ if(e.key === 'Enter'){ document.getElementById(overlayHandlerContent).getElementsByClassName('overlayKeybindEnter')[0].click() } else if(e.key === 'Escape'){ document.getElementById(overlayHandlerContent).getElementsByClassName('overlayKeybindEsc')[0].click() } } /** * Bind overlay keydown listeners for escape and exit. * * @param {boolean} state Whether or not to add new event listeners. * @param {string} content The overlay content which will be shown. * @param {boolean} dismissable Whether or not the overlay is dismissable */ function bindOverlayKeys(state, content, dismissable){ overlayHandlerContent = content document.removeEventListener('keydown', overlayKeyHandler) document.removeEventListener('keydown', overlayKeyDismissableHandler) if(state){ if(dismissable){ document.addEventListener('keydown', overlayKeyDismissableHandler) } else { document.addEventListener('keydown', overlayKeyHandler) } } } /** * Toggle the visibility of the overlay. * * @param {boolean} toggleState True to display, false to hide. * @param {boolean} dismissable Optional. True to show the dismiss option, otherwise false. * @param {string} content Optional. The content div to be shown. */ function toggleOverlay(toggleState, dismissable = false, content = 'overlayContent'){ if(toggleState == null){ toggleState = !document.getElementById('main').hasAttribute('overlay') } if(typeof dismissable === 'string'){ content = dismissable dismissable = false } bindOverlayKeys(toggleState, content, dismissable) if(toggleState){ document.getElementById('main').setAttribute('overlay', true) // Make things untabbable. $('#main *').attr('tabindex', '-1') $('#' + content).parent().children().hide() $('#' + content).show() if(dismissable){ $('#overlayDismiss').show() } else { $('#overlayDismiss').hide() } $('#overlayContainer').fadeIn({ duration: 250, start: () => { if(getCurrentView() === VIEWS.settings){ document.getElementById('settingsContainer').style.backgroundColor = 'transparent' } } }) } else { document.getElementById('main').removeAttribute('overlay') // Make things tabbable. $('#main *').removeAttr('tabindex') $('#overlayContainer').fadeOut({ duration: 250, start: () => { if(getCurrentView() === VIEWS.settings){ document.getElementById('settingsContainer').style.backgroundColor = 'rgba(0, 0, 0, 0.50)' } }, complete: () => { $('#' + content).parent().children().hide() $('#' + content).show() if(dismissable){ $('#overlayDismiss').show() } else { $('#overlayDismiss').hide() } } }) } } async function toggleServerSelection(toggleState){ await prepareServerSelectionList() toggleOverlay(toggleState, true, 'serverSelectContent') } /** * Set the content of the overlay. * * @param {string} title Overlay title text. * @param {string} description Overlay description text. * @param {string} acknowledge Acknowledge button text. * @param {string} dismiss Dismiss button text. */ function setOverlayContent(title, description, acknowledge, dismiss = 'Dismiss'){ document.getElementById('overlayTitle').innerHTML = title document.getElementById('overlayDesc').innerHTML = description document.getElementById('overlayAcknowledge').innerHTML = acknowledge document.getElementById('overlayDismiss').innerHTML = dismiss } /** * Set the onclick handler of the overlay acknowledge button. * If the handler is null, a default handler will be added. * * @param {function} handler */ function setOverlayHandler(handler){ if(handler == null){ document.getElementById('overlayAcknowledge').onclick = () => { toggleOverlay(false) } } else { document.getElementById('overlayAcknowledge').onclick = handler } } /** * Set the onclick handler of the overlay dismiss button. * If the handler is null, a default handler will be added. * * @param {function} handler */ function setDismissHandler(handler){ if(handler == null){ document.getElementById('overlayDismiss').onclick = () => { toggleOverlay(false) } } else { document.getElementById('overlayDismiss').onclick = handler } } /* Server Select View */ document.getElementById('serverSelectConfirm').addEventListener('click', async () => { const listings = document.getElementsByClassName('serverListing') for(let i=0; i 0){ const serv = (await DistroAPI.getDistribution()).getServerById(listings[i].getAttribute('servid')) updateSelectedServer(serv) toggleOverlay(false) } }) document.getElementById('accountSelectConfirm').addEventListener('click', async () => { const listings = document.getElementsByClassName('accountListing') for(let i=0; i 0){ const authAcc = ConfigManager.setSelectedAccount(listings[0].getAttribute('uuid')) ConfigManager.save() updateSelectedAccount(authAcc) if(getCurrentView() === VIEWS.settings) { await prepareSettings() } toggleOverlay(false) validateSelectedAccount() } }) // Bind server select cancel button. document.getElementById('serverSelectCancel').addEventListener('click', () => { toggleOverlay(false) }) document.getElementById('accountSelectCancel').addEventListener('click', () => { $('#accountSelectContent').fadeOut(250, () => { $('#overlayContent').fadeIn(250) }) }) function setServerListingHandlers(){ const listings = Array.from(document.getElementsByClassName('serverListing')) listings.map((val) => { val.onclick = e => { if(val.hasAttribute('selected')){ return } const cListings = document.getElementsByClassName('serverListing') for(let i=0; i { val.onclick = e => { if(val.hasAttribute('selected')){ return } const cListings = document.getElementsByClassName('accountListing') for(let i=0; i
${serv.rawServer.name} ${serv.rawServer.description}
${serv.rawServer.minecraftVersion}
${serv.rawServer.version}
${serv.rawServer.mainServer ? `
${Lang.queryJS('settings.serverListing.mainServer')}
` : ''}
` } document.getElementById('serverSelectListScrollable').innerHTML = htmlString } function populateAccountListings(){ const accountsObj = ConfigManager.getAuthAccounts() const accounts = Array.from(Object.keys(accountsObj), v=>accountsObj[v]) let htmlString = '' for(let i=0; i
${accounts[i].displayName}
` } document.getElementById('accountSelectListScrollable').innerHTML = htmlString } async function prepareServerSelectionList(){ await populateServerListings() setServerListingHandlers() } function prepareAccountSelectionList(){ populateAccountListings() setAccountListingHandlers() } // WELCOME.EJS /** * Script for welcome.ejs */ document.getElementById('welcomeButton').addEventListener('click', e => { loginOptionsCancelEnabled(false) // False by default, be explicit. loginOptionsViewOnLoginSuccess = VIEWS.landing loginOptionsViewOnLoginCancel = VIEWS.loginOptions switchView(VIEWS.welcome, VIEWS.loginOptions) }) // LOGIN.EJS /** * Script for login.ejs */ // Validation Regexes. const validUsername = /^[a-zA-Z0-9_]{1,16}$/ const basicEmail = /^\S+@\S+\.\S+$/ //const validEmail = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i // Login Elements const loginCancelContainer = document.getElementById('loginCancelContainer') const loginCancelButton = document.getElementById('loginCancelButton') const loginEmailError = document.getElementById('loginEmailError') const loginUsername = document.getElementById('loginUsername') const loginPasswordError = document.getElementById('loginPasswordError') const loginPassword = document.getElementById('loginPassword') const checkmarkContainer = document.getElementById('checkmarkContainer') const loginRememberOption = document.getElementById('loginRememberOption') const loginButton = document.getElementById('loginButton') const loginForm = document.getElementById('loginForm') // Control variables. let lu = false, lp = false /** * Show a login error. * * @param {HTMLElement} element The element on which to display the error. * @param {string} value The error text. */ function showError(element, value){ element.innerHTML = value element.style.opacity = 1 } /** * Shake a login error to add emphasis. * * @param {HTMLElement} element The element to shake. */ function shakeError(element){ if(element.style.opacity == 1){ element.classList.remove('shake') void element.offsetWidth element.classList.add('shake') } } /** * Validate that an email field is neither empty nor invalid. * * @param {string} value The email value. */ function validateEmail(value){ if(value){ if(!basicEmail.test(value) && !validUsername.test(value)){ showError(loginEmailError, Lang.queryJS('login.error.invalidValue')) loginDisabled(true) lu = false } else { loginEmailError.style.opacity = 0 lu = true if(lp){ loginDisabled(false) } } } else { lu = false showError(loginEmailError, Lang.queryJS('login.error.requiredValue')) loginDisabled(true) } } /** * Validate that the password field is not empty. * * @param {string} value The password value. */ function validatePassword(value){ if(value){ loginPasswordError.style.opacity = 0 lp = true if(lu){ loginDisabled(false) } } else { lp = false showError(loginPasswordError, Lang.queryJS('login.error.invalidValue')) loginDisabled(true) } } // Emphasize errors with shake when focus is lost. loginUsername.addEventListener('focusout', (e) => { validateEmail(e.target.value) shakeError(loginEmailError) }) loginPassword.addEventListener('focusout', (e) => { validatePassword(e.target.value) shakeError(loginPasswordError) }) // Validate input for each field. loginUsername.addEventListener('input', (e) => { validateEmail(e.target.value) }) loginPassword.addEventListener('input', (e) => { validatePassword(e.target.value) }) /** * Enable or disable the login button. * * @param {boolean} v True to enable, false to disable. */ function loginDisabled(v){ if(loginButton.disabled !== v){ loginButton.disabled = v } } /** * Enable or disable loading elements. * * @param {boolean} v True to enable, false to disable. */ function loginLoading(v){ if(v){ loginButton.setAttribute('loading', v) loginButton.innerHTML = loginButton.innerHTML.replace(Lang.queryJS('login.login'), Lang.queryJS('login.loggingIn')) } else { loginButton.removeAttribute('loading') loginButton.innerHTML = loginButton.innerHTML.replace(Lang.queryJS('login.loggingIn'), Lang.queryJS('login.login')) } } /** * Enable or disable login form. * * @param {boolean} v True to enable, false to disable. */ function formDisabled(v){ loginDisabled(v) loginCancelButton.disabled = v loginUsername.disabled = v loginPassword.disabled = v if(v){ checkmarkContainer.setAttribute('disabled', v) } else { checkmarkContainer.removeAttribute('disabled') } loginRememberOption.disabled = v } let loginViewOnSuccess = VIEWS.landing let loginViewOnCancel = VIEWS.settings let loginViewCancelHandler function loginCancelEnabled(val){ if(val){ $(loginCancelContainer).show() } else { $(loginCancelContainer).hide() } } loginCancelButton.onclick = (e) => { switchView(getCurrentView(), loginViewOnCancel, 500, 500, () => { loginUsername.value = '' loginPassword.value = '' loginCancelEnabled(false) if(loginViewCancelHandler != null){ loginViewCancelHandler() loginViewCancelHandler = null } }) } // Disable default form behavior. loginForm.onsubmit = () => { return false } // Bind login button behavior. loginButton.addEventListener('click', () => { // Disable form. formDisabled(true) // Show loading stuff. loginLoading(true) window.api.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') $('.checkmark').toggle() setTimeout(() => { switchView(VIEWS.login, loginViewOnSuccess, 500, 500, async () => { // Temporary workaround if(loginViewOnSuccess === VIEWS.settings){ await prepareSettings() } loginViewOnSuccess = VIEWS.landing // Reset this for good measure. loginCancelEnabled(false) // Reset this for good measure. loginViewCancelHandler = null // Reset this for good measure. loginUsername.value = '' loginPassword.value = '' $('.circle-loader').toggleClass('load-complete') $('.checkmark').toggle() loginLoading(false) loginButton.innerHTML = loginButton.innerHTML.replace(Lang.queryJS('login.success'), Lang.queryJS('login.login')) formDisabled(false) }) }, 1000) }).catch((displayableError) => { loginLoading(false) 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 = Lang.queryJS('login.error.unknown') } setOverlayContent(actualDisplayableError.title, actualDisplayableError.desc, Lang.queryJS('login.tryAgain')) setOverlayHandler(() => { formDisabled(false) toggleOverlay(false) }) toggleOverlay(true) }) }) // LOGIN OPTIONS const loginOptionsCancelContainer = document.getElementById('loginOptionCancelContainer') const loginOptionMicrosoft = document.getElementById('loginOptionMicrosoft') const loginOptionMojang = document.getElementById('loginOptionMojang') const loginOptionsCancelButton = document.getElementById('loginOptionCancelButton') 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 } }) } // SETTINGS.EJS const settingsState = { invalid: new Set() } function bindSettingsSelect(){ for(let ele of document.getElementsByClassName('settingsSelectContainer')) { const selectedDiv = ele.getElementsByClassName('settingsSelectSelected')[0] selectedDiv.onclick = (e) => { e.stopPropagation() closeSettingsSelect(e.target) e.target.nextElementSibling.toggleAttribute('hidden') e.target.classList.toggle('select-arrow-active') } } } function closeSettingsSelect(el){ for(let ele of document.getElementsByClassName('settingsSelectContainer')) { const selectedDiv = ele.getElementsByClassName('settingsSelectSelected')[0] const optionsDiv = ele.getElementsByClassName('settingsSelectOptions')[0] if(!(selectedDiv === el)) { selectedDiv.classList.remove('select-arrow-active') optionsDiv.setAttribute('hidden', '') } } } /* If the user clicks anywhere outside the select box, then close all select boxes: */ document.addEventListener('click', closeSettingsSelect) bindSettingsSelect() function bindFileSelectors(){ for(let ele of document.getElementsByClassName('settingsFileSelButton')){ ele.onclick = async e => { const isJavaExecSel = ele.id === 'settingsJavaExecSel' const directoryDialog = ele.hasAttribute('dialogDirectory') && ele.getAttribute('dialogDirectory') == 'true' const properties = directoryDialog ? ['openDirectory', 'createDirectory'] : ['openFile'] const options = { properties } if(ele.hasAttribute('dialogTitle')) { options.title = ele.getAttribute('dialogTitle') } if(isJavaExecSel && platform === 'win32') { options.filters = [ { name: Lang.queryJS('settings.fileSelectors.executables'), extensions: ['exe'] }, { name: Lang.queryJS('settings.fileSelectors.allFiles'), extensions: ['*'] } ] } // TODO FIXME // const res = await remote.dialog.showOpenDialog(remote.getCurrentWindow(), options) // if(!res.canceled) { // ele.previousElementSibling.value = res.filePaths[0] // if(isJavaExecSel) { // await populateJavaExecDetails(ele.previousElementSibling.value) // } // } } } } bindFileSelectors() /** * General Settings Functions */ /** * Bind value validators to the settings UI elements. These will * validate against the criteria defined in the ConfigManager (if * 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. */ function initSettingsValidators(){ const sEls = document.getElementById('settingsContainer').querySelectorAll('[cValue]') Array.from(sEls).map((v, index, arr) => { const vFn = ConfigManager['validate' + v.getAttribute('cValue')] if(typeof vFn === 'function'){ if(v.tagName === 'INPUT'){ if(v.type === 'number' || v.type === 'text'){ v.addEventListener('keyup', (e) => { const v = e.target if(!vFn(v.value)){ settingsState.invalid.add(v.id) v.setAttribute('error', '') settingsSaveDisabled(true) } else { if(v.hasAttribute('error')){ v.removeAttribute('error') settingsState.invalid.delete(v.id) if(settingsState.invalid.size === 0){ settingsSaveDisabled(false) } } } }) } } } }) } /** * Load configuration values onto the UI. This is an automated process. */ async function initSettingsValues(){ const sEls = document.getElementById('settingsContainer').querySelectorAll('[cValue]') for(const v of sEls) { const cVal = v.getAttribute('cValue') const serverDependent = v.hasAttribute('serverDependent') // Means the first argument is the server id. const gFn = ConfigManager['get' + cVal] const gFnOpts = [] if(serverDependent) { gFnOpts.push(ConfigManager.getSelectedServer()) } if(typeof gFn === 'function'){ if(v.tagName === 'INPUT'){ if(v.type === 'number' || v.type === 'text'){ // Special Conditions if(cVal === 'JavaExecutable'){ v.value = gFn.apply(null, gFnOpts) await populateJavaExecDetails(v.value) } else if (cVal === 'DataDirectory'){ v.value = gFn.apply(null, gFnOpts) } else if(cVal === 'JVMOptions'){ v.value = gFn.apply(null, gFnOpts).join(' ') } else { v.value = gFn.apply(null, gFnOpts) } } else if(v.type === 'checkbox'){ v.checked = gFn.apply(null, gFnOpts) } } else if(v.tagName === 'DIV'){ if(v.classList.contains('rangeSlider')){ // Special Conditions if(cVal === 'MinRAM' || cVal === 'MaxRAM'){ let val = gFn.apply(null, gFnOpts) if(val.endsWith('M')){ val = Number(val.substring(0, val.length-1))/1024 } else { val = Number.parseFloat(val) } v.setAttribute('value', val) } else { v.setAttribute('value', Number.parseFloat(gFn.apply(null, gFnOpts))) } } } } } } /** * Save the settings values. */ function saveSettingsValues(){ const sEls = document.getElementById('settingsContainer').querySelectorAll('[cValue]') Array.from(sEls).map((v, index, arr) => { const cVal = v.getAttribute('cValue') const serverDependent = v.hasAttribute('serverDependent') // Means the first argument is the server id. const sFn = ConfigManager['set' + cVal] const sFnOpts = [] if(serverDependent) { sFnOpts.push(ConfigManager.getSelectedServer()) } if(typeof sFn === 'function'){ if(v.tagName === 'INPUT'){ if(v.type === 'number' || v.type === 'text'){ // Special Conditions if(cVal === 'JVMOptions'){ if(!v.value.trim()) { sFnOpts.push([]) sFn.apply(null, sFnOpts) } else { sFnOpts.push(v.value.trim().split(/\s+/)) sFn.apply(null, sFnOpts) } } else { sFnOpts.push(v.value) sFn.apply(null, sFnOpts) } } else if(v.type === 'checkbox'){ sFnOpts.push(v.checked) sFn.apply(null, sFnOpts) // Special Conditions if(cVal === 'AllowPrerelease'){ changeAllowPrerelease(v.checked) } } } else if(v.tagName === 'DIV'){ if(v.classList.contains('rangeSlider')){ // Special Conditions if(cVal === 'MinRAM' || cVal === 'MaxRAM'){ let val = Number(v.getAttribute('value')) if(val%1 > 0){ val = val*1024 + 'M' } else { val = val + 'G' } sFnOpts.push(val) sFn.apply(null, sFnOpts) } else { sFnOpts.push(v.getAttribute('value')) sFn.apply(null, sFnOpts) } } } } }) } let selectedSettingsTab = 'settingsTabAccount' /** * Modify the settings container UI when the scroll threshold reaches * a certain poin. * * @param {UIEvent} e The scroll event. */ function settingsTabScrollListener(e){ if(e.target.scrollTop > Number.parseFloat(getComputedStyle(e.target.firstElementChild).marginTop)){ document.getElementById('settingsContainer').setAttribute('scrolled', '') } else { document.getElementById('settingsContainer').removeAttribute('scrolled') } } /** * Bind functionality for the settings navigation items. */ function setupSettingsTabs(){ Array.from(document.getElementsByClassName('settingsNavItem')).map((val) => { if(val.hasAttribute('rSc')){ val.onclick = () => { settingsNavItemListener(val) } } }) } /** * Settings nav item onclick lisener. Function is exposed so that * other UI elements can quickly toggle to a certain tab from other views. * * @param {Element} ele The nav item which has been clicked. * @param {boolean} fade Optional. True to fade transition. */ function settingsNavItemListener(ele, fade = true){ if(ele.hasAttribute('selected')){ return } const navItems = document.getElementsByClassName('settingsNavItem') for(let i=0; i { $(`#${selectedSettingsTab}`).fadeIn({ duration: 250, start: () => { settingsTabScrollListener({ target: document.getElementById(selectedSettingsTab) }) } }) }) } else { $(`#${prevTab}`).hide(0, () => { $(`#${selectedSettingsTab}`).show({ duration: 0, start: () => { settingsTabScrollListener({ target: document.getElementById(selectedSettingsTab) }) } }) }) } } const settingsNavDone = document.getElementById('settingsNavDone') /** * Set if the settings save (done) button is disabled. * * @param {boolean} v True to disable, false to enable. */ function settingsSaveDisabled(v){ settingsNavDone.disabled = v } function fullSettingsSave() { saveSettingsValues() saveModConfiguration() ConfigManager.save() saveDropinModConfiguration() saveShaderpackSettings() } /* Closes the settings view and saves all data. */ settingsNavDone.onclick = () => { fullSettingsSave() switchView(getCurrentView(), VIEWS.landing) } /** * Account Management Tab */ 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 loginCancelEnabled(true) }) } // 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( Lang.queryJS('settings.msftLogin.errorTitle'), Lang.queryJS('settings.msftLogin.errorMessage'), Lang.queryJS('settings.msftLogin.okButton') ) 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. let error = queryMap.error // Error might be 'access_denied' ? let errorDesc = queryMap.error_description console.log('Error getting authCode, is Azure application registered correctly?') console.log(error) console.log(errorDesc) console.log('Full query map: ', queryMap) setOverlayContent( error, errorDesc, Lang.queryJS('settings.msftLogin.okButton') ) setOverlayHandler(() => { toggleOverlay(false) }) toggleOverlay(true) }) } else { msftLoginLogger.info('Acquired authCode, proceeding with authentication.') const authCode = queryMap.code window.api.AuthManager.addMicrosoftAccount(authCode).then(value => { updateSelectedAccount(value) switchView(getCurrentView(), viewOnClose, 500, 500, async () => { await 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 = Lang.queryJS('login.error.unknown') } 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. */ function bindAuthAccountSelect(){ Array.from(document.getElementsByClassName('settingsAuthAccountSelect')).map((val) => { val.onclick = (e) => { if(val.hasAttribute('selected')){ return } const selectBtns = document.getElementsByClassName('settingsAuthAccountSelect') for(let i=0; i { val.onclick = (e) => { let isLastAccount = false if(Object.keys(ConfigManager.getAuthAccounts()).length === 1){ isLastAccount = true setOverlayContent( Lang.queryJS('settings.authAccountLogout.lastAccountWarningTitle'), Lang.queryJS('settings.authAccountLogout.lastAccountWarningMessage'), Lang.queryJS('settings.authAccountLogout.confirmButton'), Lang.queryJS('settings.authAccountLogout.cancelButton') ) setOverlayHandler(() => { processLogOut(val, isLastAccount) toggleOverlay(false) }) setDismissHandler(() => { toggleOverlay(false) }) toggleOverlay(true, true) } else { processLogOut(val, isLastAccount) } } }) } let msAccDomElementCache /** * Process a log out. * * @param {Element} val The log out button element. * @param {boolean} isLastAccount If this logout is on the last added account. */ function processLogOut(val, isLastAccount){ const parent = val.closest('.settingsAuthAccount') const uuid = parent.getAttribute('uuid') const prevSelAcc = ConfigManager.getSelectedAccount() 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 { window.api.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( Lang.queryJS('settings.msftLogout.errorTitle'), Lang.queryJS('settings.msftLogout.errorMessage'), Lang.queryJS('settings.msftLogout.okButton') ) 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) window.api.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. * * @param {string} uuid The UUID of the new selected account. */ function refreshAuthAccountSelected(uuid){ Array.from(document.getElementsByClassName('settingsAuthAccount')).map((val) => { const selBtn = val.getElementsByClassName('settingsAuthAccountSelect')[0] if(uuid === val.getAttribute('uuid')){ selBtn.setAttribute('selected', '') selBtn.innerHTML = Lang.queryJS('settings.authAccountSelect.selectedButton') } else { if(selBtn.hasAttribute('selected')){ selBtn.removeAttribute('selected') } selBtn.innerHTML = Lang.queryJS('settings.authAccountSelect.selectButton') } }) } const settingsCurrentMicrosoftAccounts = document.getElementById('settingsCurrentMicrosoftAccounts') const settingsCurrentMojangAccounts = document.getElementById('settingsCurrentMojangAccounts') /** * Add auth account elements for each one stored in the authentication database. */ function populateAuthAccounts(){ const authAccounts = ConfigManager.getAuthAccounts() const authKeys = Object.keys(authAccounts) if(authKeys.length === 0){ return } const selectedUUID = ConfigManager.getSelectedAccount().uuid let microsoftAuthAccountStr = '' let mojangAuthAccountStr = '' authKeys.forEach((val) => { const acc = authAccounts[val] const accHtml = `
${acc.displayName}
${Lang.queryJS('settings.authAccountPopulate.username')}
${acc.displayName}
${Lang.queryJS('settings.authAccountPopulate.uuid')}
${acc.uuid}
` if(acc.type === 'microsoft') { microsoftAuthAccountStr += accHtml } else { mojangAuthAccountStr += accHtml } }) settingsCurrentMicrosoftAccounts.innerHTML = microsoftAuthAccountStr settingsCurrentMojangAccounts.innerHTML = mojangAuthAccountStr } /** * Prepare the accounts tab for display. */ function prepareAccountsTab() { populateAuthAccounts() bindAuthAccountSelect() bindAuthAccountLogOut() } /** * Minecraft Tab */ /** * Disable decimals, negative signs, and scientific notation. */ document.getElementById('settingsGameWidth').addEventListener('keydown', (e) => { if(/^[-.eE]$/.test(e.key)){ e.preventDefault() } }) document.getElementById('settingsGameHeight').addEventListener('keydown', (e) => { if(/^[-.eE]$/.test(e.key)){ e.preventDefault() } }) /** * Mods Tab */ const settingsModsContainer = document.getElementById('settingsModsContainer') /** * Resolve and update the mods on the UI. */ async function resolveModsForUI(){ const serv = ConfigManager.getSelectedServer() const distro = await DistroAPI.getDistribution() const servConf = ConfigManager.getModConfiguration(serv) const modStr = await parseModulesForUI(distro.getServerById(serv).modules, false, servConf.mods) document.getElementById('settingsReqModsContent').innerHTML = modStr.reqMods document.getElementById('settingsOptModsContent').innerHTML = modStr.optMods } /** * Recursively build the mod UI elements. * * @param {Object[]} mdls An array of modules to parse. * @param {boolean} submodules Whether or not we are parsing submodules. * @param {Object} servConf The server configuration object for this module level. */ async function parseModulesForUI(mdls, submodules, servConf){ let reqMods = '' let optMods = '' const Type = await hc.type for(const mdl of mdls){ if(mdl.rawModule.type === Type.ForgeMod || mdl.rawModule.type === Type.LiteMod || mdl.rawModule.type === Type.LiteLoader){ if(mdl.getRequired().value){ reqMods += `
${mdl.rawModule.name} v${mdl.mavenComponents.version}
${mdl.subModules.length > 0 ? `
${Object.values(await parseModulesForUI(mdl.subModules, true, servConf[mdl.getVersionlessMavenIdentifier()])).join('')}
` : ''}
` } else { const conf = servConf[mdl.getVersionlessMavenIdentifier()] const val = typeof conf === 'object' ? conf.value : conf optMods += `
${mdl.rawModule.name} v${mdl.mavenComponents.version}
${mdl.subModules.length > 0 ? `
${Object.values(await parseModulesForUI(mdl.subModules, true, conf.mods)).join('')}
` : ''}
` } } } return { reqMods, optMods } } /** * Bind functionality to mod config toggle switches. Switching the value * will also switch the status color on the left of the mod UI. */ function bindModsToggleSwitch(){ const sEls = settingsModsContainer.querySelectorAll('[formod]') Array.from(sEls).map((v, index, arr) => { v.onchange = () => { if(v.checked) { document.getElementById(v.getAttribute('formod')).setAttribute('enabled', '') } else { document.getElementById(v.getAttribute('formod')).removeAttribute('enabled') } } }) } /** * Save the mod configuration based on the UI values. */ function saveModConfiguration(){ const serv = ConfigManager.getSelectedServer() const modConf = ConfigManager.getModConfiguration(serv) modConf.mods = _saveModConfiguration(modConf.mods) ConfigManager.setModConfiguration(serv, modConf) } /** * Recursively save mod config with submods. * * @param {Object} modConf Mod config object to save. */ function _saveModConfiguration(modConf){ for(let m of Object.entries(modConf)){ const tSwitch = settingsModsContainer.querySelectorAll(`[formod='${m[0]}']`) if(!tSwitch[0].hasAttribute('dropin')){ if(typeof m[1] === 'boolean'){ modConf[m[0]] = tSwitch[0].checked } else { if(m[1] != null){ if(tSwitch.length > 0){ modConf[m[0]].value = tSwitch[0].checked } modConf[m[0]].mods = _saveModConfiguration(modConf[m[0]].mods) } } } } return modConf } // Drop-in mod elements. let CACHE_SETTINGS_MODS_DIR let CACHE_DROPIN_MODS /** * Resolve any located drop-in mods for this server and * populate the results onto the UI. */ async function resolveDropinModsForUI(){ const serv = (await DistroAPI.getDistribution()).getServerById(ConfigManager.getSelectedServer()) CACHE_SETTINGS_MODS_DIR = await window.api.path.join(ConfigManager.getInstanceDirectory(), serv.rawServer.id, 'mods') CACHE_DROPIN_MODS = DropinModUtil.scanForDropinMods(CACHE_SETTINGS_MODS_DIR, serv.rawServer.minecraftVersion) let dropinMods = '' for(dropin of CACHE_DROPIN_MODS){ dropinMods += `
${dropin.name}
` } document.getElementById('settingsDropinModsContent').innerHTML = dropinMods } /** * Bind the remove button for each loaded drop-in mod. */ function bindDropinModsRemoveButton(){ const sEls = settingsModsContainer.querySelectorAll('[remmod]') Array.from(sEls).map((v, index, arr) => { v.onclick = async () => { const fullName = v.getAttribute('remmod') const res = await DropinModUtil.deleteDropinMod(CACHE_SETTINGS_MODS_DIR, fullName) if(res){ document.getElementById(fullName).remove() } else { setOverlayContent( Lang.queryJS('settings.dropinMods.deleteFailedTitle', { fullName }), Lang.queryJS('settings.dropinMods.deleteFailedMessage'), Lang.queryJS('settings.okButton') ) setOverlayHandler(null) toggleOverlay(true) } } }) } /** * Bind functionality to the file system button for the selected * server configuration. */ function bindDropinModFileSystemButton(){ const fsBtn = document.getElementById('settingsDropinFileSystemButton') fsBtn.onclick = async () => { DropinModUtil.validateDir(CACHE_SETTINGS_MODS_DIR) await window.api.shell.openPath(CACHE_SETTINGS_MODS_DIR) } fsBtn.ondragenter = e => { e.dataTransfer.dropEffect = 'move' fsBtn.setAttribute('drag', '') e.preventDefault() } fsBtn.ondragover = e => { e.preventDefault() } fsBtn.ondragleave = e => { fsBtn.removeAttribute('drag') } fsBtn.ondrop = async e => { fsBtn.removeAttribute('drag') e.preventDefault() DropinModUtil.addDropinMods(e.dataTransfer.files, CACHE_SETTINGS_MODS_DIR) await reloadDropinMods() } } /** * Save drop-in mod states. Enabling and disabling is just a matter * of adding/removing the .disabled extension. */ function saveDropinModConfiguration(){ for(dropin of CACHE_DROPIN_MODS){ const dropinUI = document.getElementById(dropin.fullName) if(dropinUI != null){ const dropinUIEnabled = dropinUI.hasAttribute('enabled') if(DropinModUtil.isDropinModEnabled(dropin.fullName) != dropinUIEnabled){ DropinModUtil.toggleDropinMod(CACHE_SETTINGS_MODS_DIR, dropin.fullName, dropinUIEnabled).catch(err => { if(!isOverlayVisible()){ setOverlayContent( Lang.queryJS('settings.dropinMods.failedToggleTitle'), err.message, Lang.queryJS('settings.okButton') ) setOverlayHandler(null) toggleOverlay(true) } }) } } } } // Refresh the drop-in mods when F5 is pressed. // Only active on the mods tab. document.addEventListener('keydown', async (e) => { if(getCurrentView() === VIEWS.settings && selectedSettingsTab === 'settingsTabMods'){ if(e.key === 'F5'){ await reloadDropinMods() saveShaderpackSettings() await resolveShaderpacksForUI() } } }) async function reloadDropinMods(){ await resolveDropinModsForUI() bindDropinModsRemoveButton() bindDropinModFileSystemButton() bindModsToggleSwitch() } // Shaderpack let CACHE_SETTINGS_INSTANCE_DIR let CACHE_SHADERPACKS let CACHE_SELECTED_SHADERPACK /** * Load shaderpack information. */ async function resolveShaderpacksForUI(){ const serv = (await DistroAPI.getDistribution()).getServerById(ConfigManager.getSelectedServer()) CACHE_SETTINGS_INSTANCE_DIR = await window.api.path.join(ConfigManager.getInstanceDirectory(), serv.rawServer.id) CACHE_SHADERPACKS = DropinModUtil.scanForShaderpacks(CACHE_SETTINGS_INSTANCE_DIR) CACHE_SELECTED_SHADERPACK = DropinModUtil.getEnabledShaderpack(CACHE_SETTINGS_INSTANCE_DIR) setShadersOptions(CACHE_SHADERPACKS, CACHE_SELECTED_SHADERPACK) } function setShadersOptions(arr, selected){ const cont = document.getElementById('settingsShadersOptions') cont.innerHTML = '' for(let opt of arr) { const d = document.createElement('DIV') d.innerHTML = opt.name d.setAttribute('value', opt.fullName) if(opt.fullName === selected) { d.setAttribute('selected', '') document.getElementById('settingsShadersSelected').innerHTML = opt.name } d.addEventListener('click', function(e) { this.parentNode.previousElementSibling.innerHTML = this.innerHTML for(let sib of this.parentNode.children){ sib.removeAttribute('selected') } this.setAttribute('selected', '') closeSettingsSelect() }) cont.appendChild(d) } } function saveShaderpackSettings(){ let sel = 'OFF' for(let opt of document.getElementById('settingsShadersOptions').childNodes){ if(opt.hasAttribute('selected')){ sel = opt.getAttribute('value') } } DropinModUtil.setEnabledShaderpack(CACHE_SETTINGS_INSTANCE_DIR, sel) } function bindShaderpackButton() { const spBtn = document.getElementById('settingsShaderpackButton') spBtn.onclick = async () => { const p = await window.api.path.join(CACHE_SETTINGS_INSTANCE_DIR, 'shaderpacks') DropinModUtil.validateDir(p) await window.api.shell.openPath(p) } spBtn.ondragenter = e => { e.dataTransfer.dropEffect = 'move' spBtn.setAttribute('drag', '') e.preventDefault() } spBtn.ondragover = e => { e.preventDefault() } spBtn.ondragleave = e => { spBtn.removeAttribute('drag') } spBtn.ondrop = async e => { spBtn.removeAttribute('drag') e.preventDefault() DropinModUtil.addShaderpacks(e.dataTransfer.files, CACHE_SETTINGS_INSTANCE_DIR) saveShaderpackSettings() await resolveShaderpacksForUI() } } // Server status bar functions. /** * Load the currently selected server information onto the mods tab. */ async function loadSelectedServerOnModsTab(){ const serv = (await DistroAPI.getDistribution()).getServerById(ConfigManager.getSelectedServer()) for(const el of document.getElementsByClassName('settingsSelServContent')) { el.innerHTML = `
${serv.rawServer.name} ${serv.rawServer.description}
${serv.rawServer.minecraftVersion}
${serv.rawServer.version}
${serv.rawServer.mainServer ? `
${Lang.queryJS('settings.serverListing.mainServer')}
` : ''}
` } } // Bind functionality to the server switch button. Array.from(document.getElementsByClassName('settingsSwitchServerButton')).forEach(el => { el.addEventListener('click', async e => { e.target.blur() await toggleServerSelection(true) }) }) /** * Save mod configuration for the current selected server. */ function saveAllModConfigurations(){ saveModConfiguration() ConfigManager.save() saveDropinModConfiguration() } /** * Function to refresh the current tab whenever the selected * server is changed. */ function animateSettingsTabRefresh(){ $(`#${selectedSettingsTab}`).fadeOut(500, async () => { await prepareSettings() $(`#${selectedSettingsTab}`).fadeIn(500) }) } /** * Prepare the Mods tab for display. */ async function prepareModsTab(first){ await resolveModsForUI() await resolveDropinModsForUI() await resolveShaderpacksForUI() bindDropinModsRemoveButton() bindDropinModFileSystemButton() bindShaderpackButton() bindModsToggleSwitch() await loadSelectedServerOnModsTab() } /** * Java Tab */ // DOM Cache const settingsMaxRAMRange = document.getElementById('settingsMaxRAMRange') const settingsMinRAMRange = document.getElementById('settingsMinRAMRange') const settingsMaxRAMLabel = document.getElementById('settingsMaxRAMLabel') const settingsMinRAMLabel = document.getElementById('settingsMinRAMLabel') const settingsMemoryTotal = document.getElementById('settingsMemoryTotal') const settingsMemoryAvail = document.getElementById('settingsMemoryAvail') const settingsJavaExecDetails = document.getElementById('settingsJavaExecDetails') const settingsJavaReqDesc = document.getElementById('settingsJavaReqDesc') const settingsJvmOptsLink = document.getElementById('settingsJvmOptsLink') const totalmem = await window.api.os.totalmem() // Bind on change event for min memory container. settingsMinRAMRange.onchange = (e) => { // Current range values const sMaxV = Number(settingsMaxRAMRange.getAttribute('value')) const sMinV = Number(settingsMinRAMRange.getAttribute('value')) // Get reference to range bar. const bar = e.target.getElementsByClassName('rangeSliderBar')[0] // Calculate effective total memory. const max = totalmem/1073741824 // Change range bar color based on the selected value. if(sMinV >= max/2){ bar.style.background = '#e86060' } else if(sMinV >= max/4) { bar.style.background = '#e8e18b' } else { bar.style.background = null } // Increase maximum memory if the minimum exceeds its value. if(sMaxV < sMinV){ const sliderMeta = calculateRangeSliderMeta(settingsMaxRAMRange) updateRangedSlider(settingsMaxRAMRange, sMinV, ((sMinV-sliderMeta.min)/sliderMeta.step)*sliderMeta.inc) settingsMaxRAMLabel.innerHTML = sMinV.toFixed(1) + 'G' } // Update label settingsMinRAMLabel.innerHTML = sMinV.toFixed(1) + 'G' } // Bind on change event for max memory container. settingsMaxRAMRange.onchange = (e) => { // Current range values const sMaxV = Number(settingsMaxRAMRange.getAttribute('value')) const sMinV = Number(settingsMinRAMRange.getAttribute('value')) // Get reference to range bar. const bar = e.target.getElementsByClassName('rangeSliderBar')[0] // Calculate effective total memory. const max = totalmem/1073741824 // Change range bar color based on the selected value. if(sMaxV >= max/2){ bar.style.background = '#e86060' } else if(sMaxV >= max/4) { bar.style.background = '#e8e18b' } else { bar.style.background = null } // Decrease the minimum memory if the maximum value is less. if(sMaxV < sMinV){ const sliderMeta = calculateRangeSliderMeta(settingsMaxRAMRange) updateRangedSlider(settingsMinRAMRange, sMaxV, ((sMaxV-sliderMeta.min)/sliderMeta.step)*sliderMeta.inc) settingsMinRAMLabel.innerHTML = sMaxV.toFixed(1) + 'G' } settingsMaxRAMLabel.innerHTML = sMaxV.toFixed(1) + 'G' } /** * Calculate common values for a ranged slider. * * @param {Element} v The range slider to calculate against. * @returns {Object} An object with meta values for the provided ranged slider. */ function calculateRangeSliderMeta(v){ const val = { max: Number(v.getAttribute('max')), min: Number(v.getAttribute('min')), step: Number(v.getAttribute('step')), } val.ticks = (val.max-val.min)/val.step val.inc = 100/val.ticks return val } /** * Binds functionality to the ranged sliders. They're more than * just divs now :'). */ function bindRangeSlider(){ Array.from(document.getElementsByClassName('rangeSlider')).map((v) => { // Reference the track (thumb). const track = v.getElementsByClassName('rangeSliderTrack')[0] // Set the initial slider value. const value = v.getAttribute('value') const sliderMeta = calculateRangeSliderMeta(v) updateRangedSlider(v, value, ((value-sliderMeta.min)/sliderMeta.step)*sliderMeta.inc) // The magic happens when we click on the track. track.onmousedown = (e) => { // Stop moving the track on mouse up. document.onmouseup = (e) => { document.onmousemove = null document.onmouseup = null } // Move slider according to the mouse position. document.onmousemove = (e) => { // Distance from the beginning of the bar in pixels. const diff = e.pageX - v.offsetLeft - track.offsetWidth/2 // Don't move the track off the bar. if(diff >= 0 && diff <= v.offsetWidth-track.offsetWidth/2){ // Convert the difference to a percentage. const perc = (diff/v.offsetWidth)*100 // Calculate the percentage of the closest notch. const notch = Number(perc/sliderMeta.inc).toFixed(0)*sliderMeta.inc // If we're close to that notch, stick to it. if(Math.abs(perc-notch) < sliderMeta.inc/2){ updateRangedSlider(v, sliderMeta.min+(sliderMeta.step*(notch/sliderMeta.inc)), notch) } } } } }) } /** * Update a ranged slider's value and position. * * @param {Element} element The ranged slider to update. * @param {string | number} value The new value for the ranged slider. * @param {number} notch The notch that the slider should now be at. */ function updateRangedSlider(element, value, notch){ const oldVal = element.getAttribute('value') const bar = element.getElementsByClassName('rangeSliderBar')[0] const track = element.getElementsByClassName('rangeSliderTrack')[0] element.setAttribute('value', value) if(notch < 0){ notch = 0 } else if(notch > 100) { notch = 100 } const event = new MouseEvent('change', { target: element, type: 'change', bubbles: false, cancelable: true }) let cancelled = !element.dispatchEvent(event) if(!cancelled){ track.style.left = notch + '%' bar.style.width = notch + '%' } else { element.setAttribute('value', oldVal) } } /** * Display the total and available RAM. */ async function populateMemoryStatus(){ settingsMemoryTotal.innerHTML = Number((await window.api.os.totalmem()-1073741824)/1073741824).toFixed(1) + 'G' settingsMemoryAvail.innerHTML = Number(await window.api.os.freemem()/1073741824).toFixed(1) + 'G' } /** * Validate the provided executable path and display the data on * the UI. * * @param {string} execPath The executable path to populate against. */ async function populateJavaExecDetails(execPath){ const server = (await DistroAPI.getDistribution()).getServerById(ConfigManager.getSelectedServer()) const details = await validateSelectedJvm(ensureJavaDirIsRoot(execPath), server.effectiveJavaOptions.supported) if(details != null) { settingsJavaExecDetails.innerHTML = Lang.queryJS('settings.java.selectedJava', { version: details.semverStr, vendor: details.vendor }) } else { settingsJavaExecDetails.innerHTML = Lang.queryJS('settings.java.invalidSelection') } } function populateJavaReqDesc(server) { settingsJavaReqDesc.innerHTML = Lang.queryJS('settings.java.requiresJava', { major: server.effectiveJavaOptions.suggestedMajor }) } function populateJvmOptsLink(server) { const major = server.effectiveJavaOptions.suggestedMajor settingsJvmOptsLink.innerHTML = Lang.queryJS('settings.java.availableOptions', { major: major }) if(major >= 12) { settingsJvmOptsLink.href = `https://docs.oracle.com/en/java/javase/${major}/docs/specs/man/java.html#extra-options-for-java` } else if(major >= 11) { settingsJvmOptsLink.href = 'https://docs.oracle.com/en/java/javase/11/tools/java.html#GUID-3B1CE181-CD30-4178-9602-230B800D4FAE' } else if(major >= 9) { settingsJvmOptsLink.href = `https://docs.oracle.com/javase/${major}/tools/java.htm` } else { settingsJvmOptsLink.href = `https://docs.oracle.com/javase/${major}/docs/technotes/tools/${platform === 'win32' ? 'windows' : 'unix'}/java.html` } } function bindMinMaxRam(server) { // Store maximum memory values. const SETTINGS_MAX_MEMORY = ConfigManager.getAbsoluteMaxRAM(server.rawServer.javaOptions?.ram) const SETTINGS_MIN_MEMORY = ConfigManager.getAbsoluteMinRAM(server.rawServer.javaOptions?.ram) // Set the max and min values for the ranged sliders. settingsMaxRAMRange.setAttribute('max', SETTINGS_MAX_MEMORY) settingsMaxRAMRange.setAttribute('min', SETTINGS_MIN_MEMORY) settingsMinRAMRange.setAttribute('max', SETTINGS_MAX_MEMORY) settingsMinRAMRange.setAttribute('min', SETTINGS_MIN_MEMORY) } /** * Prepare the Java tab for display. */ async function prepareJavaTab(){ const server = (await DistroAPI.getDistribution()).getServerById(ConfigManager.getSelectedServer()) bindMinMaxRam(server) bindRangeSlider(server) await populateMemoryStatus() populateJavaReqDesc(server) populateJvmOptsLink(server) } /** * About Tab */ const settingsTabAbout = document.getElementById('settingsTabAbout') const settingsAboutChangelogTitle = settingsTabAbout.getElementsByClassName('settingsChangelogTitle')[0] const settingsAboutChangelogText = settingsTabAbout.getElementsByClassName('settingsChangelogText')[0] const settingsAboutChangelogButton = settingsTabAbout.getElementsByClassName('settingsChangelogButton')[0] // Bind the devtools toggle button. document.getElementById('settingsAboutDevToolsButton').onclick = async (e) => { await window.api.xwindow.toggleDevTools() } /** * Return whether or not the provided version is a prerelease. * * @param {string} version The semver version to test. * @returns {boolean} True if the version is a prerelease, otherwise false. */ async function isPrerelease(version){ const preRelComp = await window.api.semver.prerelease(version) return preRelComp != null && preRelComp.length > 0 } /** * Utility method to display version information on the * About and Update settings tabs. * * @param {string} version The semver version to display. * @param {Element} valueElement The value element. * @param {Element} titleElement The title element. * @param {Element} checkElement The check mark element. */ async function populateVersionInformation(version, valueElement, titleElement, checkElement){ valueElement.innerHTML = version if(await isPrerelease(version)){ titleElement.innerHTML = Lang.queryJS('settings.about.preReleaseTitle') titleElement.style.color = '#ff886d' checkElement.style.background = '#ff886d' } else { titleElement.innerHTML = Lang.queryJS('settings.about.stableReleaseTitle') titleElement.style.color = null checkElement.style.background = null } } /** * Retrieve the version information and display it on the UI. */ async function populateAboutVersionInformation(ver){ await populateVersionInformation(ver, document.getElementById('settingsAboutCurrentVersionValue'), document.getElementById('settingsAboutCurrentVersionTitle'), document.getElementById('settingsAboutCurrentVersionCheck')) } /** * Fetches the GitHub atom release feed and parses it for the release notes * of the current version. This value is displayed on the UI. */ function populateReleaseNotes(ver){ const version = 'v' + ver $.ajax({ url: 'https://github.com/dscalzi/HeliosLauncher/releases.atom', success: (data) => { const entries = $(data).find('entry') for(let i=0; i { settingsAboutChangelogText.innerHTML = Lang.queryJS('settings.about.releaseNotesFailed') }) } /** * Prepare account tab for display. */ async function prepareAboutTab() { const ver = await window.api.app.getVersion() await populateAboutVersionInformation(ver) populateReleaseNotes(ver) } /** * Update Tab */ const settingsTabUpdate = document.getElementById('settingsTabUpdate') const settingsUpdateTitle = document.getElementById('settingsUpdateTitle') const settingsUpdateVersionCheck = document.getElementById('settingsUpdateVersionCheck') const settingsUpdateVersionTitle = document.getElementById('settingsUpdateVersionTitle') const settingsUpdateVersionValue = document.getElementById('settingsUpdateVersionValue') const settingsUpdateChangelogTitle = settingsTabUpdate.getElementsByClassName('settingsChangelogTitle')[0] const settingsUpdateChangelogText = settingsTabUpdate.getElementsByClassName('settingsChangelogText')[0] const settingsUpdateChangelogCont = settingsTabUpdate.getElementsByClassName('settingsChangelogContainer')[0] const settingsUpdateActionButton = document.getElementById('settingsUpdateActionButton') /** * Update the properties of the update action button. * * @param {string} text The new button text. * @param {boolean} disabled Optional. Disable or enable the button * @param {function} handler Optional. New button event handler. */ function settingsUpdateButtonStatus(text, disabled = false, handler = null){ settingsUpdateActionButton.innerHTML = text settingsUpdateActionButton.disabled = disabled if(handler != null){ settingsUpdateActionButton.onclick = handler } } /** * Populate the update tab with relevant information. * * @param {Object} data The update data. */ async function populateSettingsUpdateInformation(data){ if(data != null){ settingsUpdateTitle.innerHTML = await isPrerelease(data.version) ? Lang.queryJS('settings.updates.newPreReleaseTitle') : Lang.queryJS('settings.updates.newReleaseTitle') settingsUpdateChangelogCont.style.display = null settingsUpdateChangelogTitle.innerHTML = data.releaseName settingsUpdateChangelogText.innerHTML = data.releaseNotes await populateVersionInformation(data.version, settingsUpdateVersionValue, settingsUpdateVersionTitle, settingsUpdateVersionCheck) if(platform === 'darwin'){ settingsUpdateButtonStatus(Lang.queryJS('settings.updates.downloadButton'), false, async () => { await window.api.shell.openExternal(data.darwindownload) }) } else { settingsUpdateButtonStatus(Lang.queryJS('settings.updates.downloadingButton'), true) } } else { settingsUpdateTitle.innerHTML = Lang.queryJS('settings.updates.latestVersionTitle') settingsUpdateChangelogCont.style.display = 'none' await populateVersionInformation(await window.api.app.getVersion(), settingsUpdateVersionValue, settingsUpdateVersionTitle, settingsUpdateVersionCheck) settingsUpdateButtonStatus(Lang.queryJS('settings.updates.checkForUpdatesButton'), false, () => { if(!isDev){ autoUpdatePort.postMessage(['autoUpdateAction', 'checkForUpdate']) settingsUpdateButtonStatus(Lang.queryJS('settings.updates.checkingForUpdatesButton'), true) } }) } } /** * Prepare update tab for display. * * @param {Object} data The update data. */ async function prepareUpdateTab(data = null){ await populateSettingsUpdateInformation(data) } /** * Settings preparation functions. */ /** * Prepare the entire settings UI. * * @param {boolean} first Whether or not it is the first load. */ async function prepareSettings(first = false) { if(first){ setupSettingsTabs() initSettingsValidators() await prepareUpdateTab() } else { await prepareModsTab() } await initSettingsValues() prepareAccountsTab() await prepareJavaTab() await prepareAboutTab() } // Prepare the settings UI on startup. //prepareSettings(true) // LANDING.EJS /** * Script for landing.ejs */ // Requirements const { MojangRestAPI, getServerStatus } = require('helios-core/mojang') const { RestResponseStatus, isDisplayableError, validateLocalFile } = require('helios-core/common') const { FullRepair, DistributionIndexProcessor, MojangIndexProcessor, downloadFile } = require('helios-core/dl') const { validateSelectedJvm, ensureJavaDirIsRoot, javaExecFromRoot, discoverBestJvmInstallation, latestOpenJDK, extractJdk } = require('helios-core/java') // Internal Requirements const DiscordWrapper = require('./assets/js/discordwrapper') const ProcessBuilder = require('./assets/js/processbuilder') // Launch Elements const launch_content = document.getElementById('launch_content') const launch_details = document.getElementById('launch_details') const launch_progress = document.getElementById('launch_progress') const launch_progress_label = document.getElementById('launch_progress_label') 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.getLogger('Landing') /* Launch Progress Wrapper Functions */ /** * Show/hide the loading area. * * @param {boolean} loading True if the loading area should be shown, otherwise false. */ function toggleLaunchArea(loading){ if(loading){ launch_details.style.display = 'flex' launch_content.style.display = 'none' } else { launch_details.style.display = 'none' launch_content.style.display = 'inline-flex' } } /** * Set the details text of the loading area. * * @param {string} details The new text for the loading details. */ function setLaunchDetails(details){ launch_details_text.innerHTML = details } /** * Set the value of the loading progress bar and display that value. * * @param {number} percent Percentage (0-100) */ function setLaunchPercentage(percent){ launch_progress.setAttribute('max', 100) launch_progress.setAttribute('value', percent) launch_progress_label.innerHTML = percent + '%' } /** * Set the value of the OS progress bar and display that on the UI. * * @param {number} percent Percentage (0-100) */ async function setDownloadPercentage(percent){ await window.api.xwindow.setProgressBar(percent/100) setLaunchPercentage(percent) } /** * Enable or disable the launch button. * * @param {boolean} val True to enable, false to disable. */ function setLaunchEnabled(val){ document.getElementById('launch_button').disabled = !val } // Bind launch button document.getElementById('launch_button').addEventListener('click', async e => { loggerLanding.info('Launching game..') try { const server = (await DistroAPI.getDistribution()).getServerById(ConfigManager.getSelectedServer()) const jExe = ConfigManager.getJavaExecutable(ConfigManager.getSelectedServer()) if(jExe == null){ await asyncSystemScan(server.effectiveJavaOptions) } else { setLaunchDetails(Lang.queryJS('landing.launch.pleaseWait')) toggleLaunchArea(true) setLaunchPercentage(0, 100) const details = await validateSelectedJvm(ensureJavaDirIsRoot(jExe), server.effectiveJavaOptions.supported) if(details != null){ loggerLanding.info('Jvm Details', details) await dlAsync() } else { await asyncSystemScan(server.effectiveJavaOptions) } } } catch(err) { loggerLanding.error('Unhandled error in during launch process.', err) showLaunchFailure(Lang.queryJS('landing.launch.failureTitle'), Lang.queryJS('landing.launch.failureText')) } }) // Bind settings button document.getElementById('settingsMediaButton').onclick = async e => { await prepareSettings() switchView(getCurrentView(), VIEWS.settings) } // Bind avatar overlay button. document.getElementById('avatarOverlay').onclick = async e => { await prepareSettings() switchView(getCurrentView(), VIEWS.settings, 500, 500, () => { settingsNavItemListener(document.getElementById('settingsNavAccount'), false) }) } // Bind selected account function updateSelectedAccount(authUser){ let username = Lang.queryJS('landing.selectedAccount.noAccountSelected') if(authUser != null){ if(authUser.displayName != null){ username = authUser.displayName } if(authUser.uuid != null){ document.getElementById('avatarContainer').style.backgroundImage = `url('https://mc-heads.net/body/${authUser.uuid}/right')` } } user_text.innerHTML = username } updateSelectedAccount(ConfigManager.getSelectedAccount()) // Bind selected server function updateSelectedServer(serv){ if(getCurrentView() === VIEWS.settings){ fullSettingsSave() } ConfigManager.setSelectedServer(serv != null ? serv.rawServer.id : null) ConfigManager.save() server_selection_button.innerHTML = '• ' + (serv != null ? serv.rawServer.name : Lang.queryJS('landing.noSelection')) if(getCurrentView() === VIEWS.settings){ animateSettingsTabRefresh() } setLaunchEnabled(serv != null) } // Real text is set in uibinder.js on distributionIndexDone. server_selection_button.innerHTML = '• ' + Lang.queryJS('landing.selectedServer.loading') server_selection_button.onclick = async e => { e.target.blur() await toggleServerSelection(true) } // Update Mojang Status Color const refreshMojangStatuses = async function(){ loggerLanding.info('Refreshing Mojang Statuses..') let status = 'grey' let tooltipEssentialHTML = '' let tooltipNonEssentialHTML = '' const response = await MojangRestAPI.status() let statuses if(response.responseStatus === RestResponseStatus.SUCCESS) { statuses = response.data } else { loggerLanding.warn('Unable to refresh Mojang service status.') statuses = MojangRestAPI.getDefaultStatuses() } let greenCount = 0 let greyCount = 0 for(let i=0; i ${service.name} ` if(service.essential){ tooltipEssentialHTML += tooltipHTML } else { tooltipNonEssentialHTML += tooltipHTML } 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 = MojangRestAPI.statusToHex(status) } const refreshServerStatus = async (fade = false) => { loggerLanding.info('Refreshing Server Status') const serv = (await DistroAPI.getDistribution()).getServerById(ConfigManager.getSelectedServer()) let pLabel = Lang.queryJS('landing.serverStatus.server') let pVal = Lang.queryJS('landing.serverStatus.offline') try { const servStat = await getServerStatus(47, serv.hostname, serv.port) console.log(servStat) pLabel = Lang.queryJS('landing.serverStatus.players') pVal = servStat.players.online + '/' + servStat.players.max } catch (err) { loggerLanding.warn('Unable to refresh server status, assuming offline.') loggerLanding.debug(err) } if(fade){ $('#server_status_wrapper').fadeOut(250, () => { document.getElementById('landingPlayerLabel').innerHTML = pLabel document.getElementById('player_count').innerHTML = pVal $('#server_status_wrapper').fadeIn(500) }) } else { document.getElementById('landingPlayerLabel').innerHTML = pLabel document.getElementById('player_count').innerHTML = pVal } } refreshMojangStatuses() // Server Status is refreshed in uibinder.js on distributionIndexDone. // Refresh statuses every hour. The status page itself refreshes every day so... // eslint-disable-next-line no-unused-vars let mojangStatusListener = setInterval(() => refreshMojangStatuses(true), 60*60*1000) // Set refresh rate to once every 5 minutes. // eslint-disable-next-line no-unused-vars let serverStatusListener = setInterval(() => refreshServerStatus(true), 300000) /** * Shows an error overlay, toggles off the launch area. * * @param {string} title The overlay title. * @param {string} desc The overlay description. */ function showLaunchFailure(title, desc){ setOverlayContent( title, desc, Lang.queryJS('landing.launch.okay') ) setOverlayHandler(null) toggleOverlay(true) toggleLaunchArea(false) } /* System (Java) Scan */ /** * Asynchronously scan the system for valid Java installations. * * @param {boolean} launchAfter Whether we should begin to launch after scanning. */ async function asyncSystemScan(effectiveJavaOptions, launchAfter = true){ setLaunchDetails(Lang.queryJS('landing.systemScan.checking')) toggleLaunchArea(true) setLaunchPercentage(0, 100) const jvmDetails = await discoverBestJvmInstallation( ConfigManager.getDataDirectory(), effectiveJavaOptions.supported ) if(jvmDetails == null) { // If the result is null, no valid Java installation was found. // Show this information to the user. setOverlayContent( Lang.queryJS('landing.systemScan.noCompatibleJava'), Lang.queryJS('landing.systemScan.installJavaMessage', { 'major': effectiveJavaOptions.suggestedMajor }), Lang.queryJS('landing.systemScan.installJava'), Lang.queryJS('landing.systemScan.installJavaManually') ) setOverlayHandler(() => { setLaunchDetails(Lang.queryJS('landing.systemScan.javaDownloadPrepare')) toggleOverlay(false) try { downloadJava(effectiveJavaOptions, launchAfter) } catch(err) { loggerLanding.error('Unhandled error in Java Download', err) showLaunchFailure(Lang.queryJS('landing.systemScan.javaDownloadFailureTitle'), Lang.queryJS('landing.systemScan.javaDownloadFailureText')) } }) setDismissHandler(() => { $('#overlayContent').fadeOut(250, () => { //$('#overlayDismiss').toggle(false) setOverlayContent( Lang.queryJS('landing.systemScan.javaRequired', { 'major': effectiveJavaOptions.suggestedMajor }), Lang.queryJS('landing.systemScan.javaRequiredMessage', { 'major': effectiveJavaOptions.suggestedMajor }), Lang.queryJS('landing.systemScan.javaRequiredDismiss'), Lang.queryJS('landing.systemScan.javaRequiredCancel') ) setOverlayHandler(() => { toggleLaunchArea(false) toggleOverlay(false) }) setDismissHandler(() => { toggleOverlay(false, true) asyncSystemScan(effectiveJavaOptions, launchAfter) }) $('#overlayContent').fadeIn(250) }) }) toggleOverlay(true, true) } else { // Java installation found, use this to launch the game. const javaExec = javaExecFromRoot(jvmDetails.path) ConfigManager.setJavaExecutable(ConfigManager.getSelectedServer(), javaExec) ConfigManager.save() // We need to make sure that the updated value is on the settings UI. // Just incase the settings UI is already open. settingsJavaExecVal.value = javaExec await populateJavaExecDetails(settingsJavaExecVal.value) // TODO Callback hell, refactor // TODO Move this out, separate concerns. if(launchAfter){ await dlAsync() } } } async function downloadJava(effectiveJavaOptions, launchAfter = true) { // TODO Error handling. // asset can be null. const asset = await latestOpenJDK( effectiveJavaOptions.suggestedMajor, ConfigManager.getDataDirectory(), effectiveJavaOptions.distribution) if(asset == null) { throw new Error(Lang.queryJS('landing.downloadJava.findJdkFailure')) } let received = 0 await downloadFile(asset.url, asset.path, async ({ transferred }) => { received = transferred await setDownloadPercentage(Math.trunc((transferred/asset.size)*100)) }) await setDownloadPercentage(100) if(received != asset.size) { loggerLanding.warn(`Java Download: Expected ${asset.size} bytes but received ${received}`) if(!await validateLocalFile(asset.path, asset.algo, asset.hash)) { log.error(`Hashes do not match, ${asset.id} may be corrupted.`) // Don't know how this could happen, but report it. throw new Error(Lang.queryJS('landing.downloadJava.javaDownloadCorruptedError')) } } // Extract // Show installing progress bar. await window.api.xwindow.setProgressBar(2) // Wait for extration to complete. const eLStr = Lang.queryJS('landing.downloadJava.extractingJava') let dotStr = '' setLaunchDetails(eLStr) const extractListener = setInterval(() => { if(dotStr.length >= 3){ dotStr = '' } else { dotStr += '.' } setLaunchDetails(eLStr + dotStr) }, 750) const newJavaExec = await extractJdk(asset.path) // Extraction complete, remove the loading from the OS progress bar. await window.api.xwindow.setProgressBar(-1) // Extraction completed successfully. ConfigManager.setJavaExecutable(ConfigManager.getSelectedServer(), newJavaExec) ConfigManager.save() clearInterval(extractListener) setLaunchDetails(Lang.queryJS('landing.downloadJava.javaInstalled')) // TODO Callback hell // Refactor the launch functions asyncSystemScan(effectiveJavaOptions, launchAfter) } // Keep reference to Minecraft Process let proc // Is DiscordRPC enabled let hasRPC = false // Joined server regex // Change this if your server uses something different. const GAME_JOINED_REGEX = /\[.+\]: Sound engine started/ const GAME_LAUNCH_REGEX = /^\[.+\]: (?:MinecraftForge .+ Initialized|ModLauncher .+ starting: .+)$/ const MIN_LINGER = 5000 async function dlAsync(login = true) { // Login parameter is temporary for debug purposes. Allows testing the validation/downloads without // launching the game. const loggerLaunchSuite = LoggerUtil.getLogger('LaunchSuite') setLaunchDetails(Lang.queryJS('landing.dlAsync.loadingServerInfo')) let distro try { distro = await DistroAPI.refreshDistributionOrFallback() onDistroRefresh(distro) } catch(err) { loggerLaunchSuite.error('Unable to refresh distribution index.', err) showLaunchFailure(Lang.queryJS('landing.dlAsync.fatalError'), Lang.queryJS('landing.dlAsync.unableToLoadDistributionIndex')) return } const serv = distro.getServerById(ConfigManager.getSelectedServer()) if(login) { if(ConfigManager.getSelectedAccount() == null){ loggerLanding.error('You must be logged into an account.') return } } setLaunchDetails(Lang.queryJS('landing.dlAsync.pleaseWait')) toggleLaunchArea(true) setLaunchPercentage(0, 100) const fullRepairModule = new FullRepair( ConfigManager.getCommonDirectory(), ConfigManager.getInstanceDirectory(), ConfigManager.getLauncherDirectory(), ConfigManager.getSelectedServer(), DistroAPI.isDevMode() ) fullRepairModule.spawnReceiver() fullRepairModule.childProcess.on('error', (err) => { loggerLaunchSuite.error('Error during launch', err) showLaunchFailure(Lang.queryJS('landing.dlAsync.errorDuringLaunchTitle'), err.message || Lang.queryJS('landing.dlAsync.errorDuringLaunchText')) }) fullRepairModule.childProcess.on('close', (code, _signal) => { if(code !== 0){ loggerLaunchSuite.error(`Full Repair Module exited with code ${code}, assuming error.`) showLaunchFailure(Lang.queryJS('landing.dlAsync.errorDuringLaunchTitle'), Lang.queryJS('landing.dlAsync.seeConsoleForDetails')) } }) loggerLaunchSuite.info('Validating files.') setLaunchDetails(Lang.queryJS('landing.dlAsync.validatingFileIntegrity')) let invalidFileCount = 0 try { invalidFileCount = await fullRepairModule.verifyFiles(percent => { setLaunchPercentage(percent) }) setLaunchPercentage(100) } catch (err) { loggerLaunchSuite.error('Error during file validation.') showLaunchFailure(Lang.queryJS('landing.dlAsync.errorDuringFileVerificationTitle'), err.displayable || Lang.queryJS('landing.dlAsync.seeConsoleForDetails')) return } if(invalidFileCount > 0) { loggerLaunchSuite.info('Downloading files.') setLaunchDetails(Lang.queryJS('landing.dlAsync.downloadingFiles')) setLaunchPercentage(0) try { await fullRepairModule.download(async percent => { await setDownloadPercentage(percent) }) await setDownloadPercentage(100) } catch(err) { loggerLaunchSuite.error('Error during file download.') showLaunchFailure(Lang.queryJS('landing.dlAsync.errorDuringFileDownloadTitle'), err.displayable || Lang.queryJS('landing.dlAsync.seeConsoleForDetails')) return } } else { loggerLaunchSuite.info('No invalid files, skipping download.') } // Remove download bar. await window.api.xwindow.setProgressBar(-1) fullRepairModule.destroyReceiver() setLaunchDetails(Lang.queryJS('landing.dlAsync.preparingToLaunch')) const mojangIndexProcessor = new MojangIndexProcessor( ConfigManager.getCommonDirectory(), serv.rawServer.minecraftVersion) const distributionIndexProcessor = new DistributionIndexProcessor( ConfigManager.getCommonDirectory(), distro, serv.rawServer.id ) const forgeData = await distributionIndexProcessor.loadForgeVersionJson(serv) const versionData = await mojangIndexProcessor.getVersionJson() if(login) { const authUser = ConfigManager.getSelectedAccount() loggerLaunchSuite.info(`Sending selected account (${authUser.displayName}) to ProcessBuilder.`) let pb = new ProcessBuilder(serv, versionData, forgeData, authUser, await window.api.app.getVersion()) setLaunchDetails(Lang.queryJS('landing.dlAsync.launchingGame')) // const SERVER_JOINED_REGEX = /\[.+\]: \[CHAT\] [a-zA-Z0-9_]{1,16} joined the game/ const SERVER_JOINED_REGEX = new RegExp(`\\[.+\\]: \\[CHAT\\] ${authUser.displayName} joined the game`) const onLoadComplete = () => { toggleLaunchArea(false) if(hasRPC){ DiscordWrapper.updateDetails('Loading game..') proc.stdout.on('data', gameStateChange) } proc.stdout.removeListener('data', tempListener) proc.stderr.removeListener('data', gameErrorListener) } const start = Date.now() // Attach a temporary listener to the client output. // Will wait for a certain bit of text meaning that // the client application has started, and we can hide // the progress bar stuff. const tempListener = function(data){ if(GAME_LAUNCH_REGEX.test(data.trim())){ const diff = Date.now()-start if(diff < MIN_LINGER) { setTimeout(onLoadComplete, MIN_LINGER-diff) } else { onLoadComplete() } } } // Listener for Discord RPC. const gameStateChange = function(data){ data = data.trim() if(SERVER_JOINED_REGEX.test(data)){ DiscordWrapper.updateDetails('Exploring the Realm!') } else if(GAME_JOINED_REGEX.test(data)){ DiscordWrapper.updateDetails('Sailing to Westeros!') } } const gameErrorListener = function(data){ data = data.trim() if(data.indexOf('Could not find or load main class net.minecraft.launchwrapper.Launch') > -1){ loggerLaunchSuite.error('Game launch failed, LaunchWrapper was not downloaded properly.') showLaunchFailure(Lang.queryJS('landing.dlAsync.errorDuringLaunchTitle'), Lang.queryJS('landing.dlAsync.launchWrapperNotDownloaded')) } } try { // Build Minecraft process. proc = pb.build() // Bind listeners to stdout. proc.stdout.on('data', tempListener) proc.stderr.on('data', gameErrorListener) setLaunchDetails(Lang.queryJS('landing.dlAsync.doneEnjoyServer')) // Init Discord Hook if(distro.rawDistribution.discord != null && serv.rawServerdiscord != null){ DiscordWrapper.initRPC(distro.rawDistribution.discord, serv.rawServer.discord) hasRPC = true proc.on('close', (code, signal) => { loggerLaunchSuite.info('Shutting down Discord Rich Presence..') DiscordWrapper.shutdownRPC() hasRPC = false proc = null }) } } catch(err) { loggerLaunchSuite.error('Error during launch', err) showLaunchFailure(Lang.queryJS('landing.dlAsync.errorDuringLaunchTitle'), Lang.queryJS('landing.dlAsync.checkConsoleForDetails')) } } } /** * News Loading Functions */ // DOM Cache const newsContent = document.getElementById('newsContent') const newsArticleTitle = document.getElementById('newsArticleTitle') const newsArticleDate = document.getElementById('newsArticleDate') const newsArticleAuthor = document.getElementById('newsArticleAuthor') const newsArticleComments = document.getElementById('newsArticleComments') const newsNavigationStatus = document.getElementById('newsNavigationStatus') const newsArticleContentScrollable = document.getElementById('newsArticleContentScrollable') const nELoadSpan = document.getElementById('nELoadSpan') // News slide caches. let newsActive = false let newsGlideCount = 0 /** * Show the news UI via a slide animation. * * @param {boolean} up True to slide up, otherwise false. */ function slide_(up){ const lCUpper = document.querySelector('#landingContainer > #upper') const lCLLeft = document.querySelector('#landingContainer > #lower > #left') const lCLCenter = document.querySelector('#landingContainer > #lower > #center') const lCLRight = document.querySelector('#landingContainer > #lower > #right') const newsBtn = document.querySelector('#landingContainer > #lower > #center #content') const landingContainer = document.getElementById('landingContainer') const newsContainer = document.querySelector('#landingContainer > #newsContainer') newsGlideCount++ if(up){ lCUpper.style.top = '-200vh' lCLLeft.style.top = '-200vh' lCLCenter.style.top = '-200vh' lCLRight.style.top = '-200vh' newsBtn.style.top = '130vh' newsContainer.style.top = '0px' //date.toLocaleDateString('en-US', {month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric'}) //landingContainer.style.background = 'rgba(29, 29, 29, 0.55)' landingContainer.style.background = 'rgba(0, 0, 0, 0.50)' setTimeout(() => { if(newsGlideCount === 1){ lCLCenter.style.transition = 'none' newsBtn.style.transition = 'none' } newsGlideCount-- }, 2000) } else { setTimeout(() => { newsGlideCount-- }, 2000) landingContainer.style.background = null lCLCenter.style.transition = null newsBtn.style.transition = null newsContainer.style.top = '100%' lCUpper.style.top = '0px' lCLLeft.style.top = '0px' lCLCenter.style.top = '0px' lCLRight.style.top = '0px' newsBtn.style.top = '10px' } } // Bind news button. document.getElementById('newsButton').onclick = () => { // Toggle tabbing. if(newsActive){ $('#landingContainer *').removeAttr('tabindex') $('#newsContainer *').attr('tabindex', '-1') } else { $('#landingContainer *').attr('tabindex', '-1') $('#newsContainer, #newsContainer *, #lower, #lower #center *').removeAttr('tabindex') if(newsAlertShown){ $('#newsButtonAlert').fadeOut(2000) newsAlertShown = false ConfigManager.setNewsCacheDismissed(true) ConfigManager.save() } } slide_(!newsActive) newsActive = !newsActive } // Array to store article meta. let newsArr = null // News load animation listener. let newsLoadingListener = null /** * Set the news loading animation. * * @param {boolean} val True to set loading animation, otherwise false. */ function setNewsLoading(val){ if(val){ const nLStr = Lang.queryJS('landing.news.checking') let dotStr = '..' nELoadSpan.innerHTML = nLStr + dotStr newsLoadingListener = setInterval(() => { if(dotStr.length >= 3){ dotStr = '' } else { dotStr += '.' } nELoadSpan.innerHTML = nLStr + dotStr }, 750) } else { if(newsLoadingListener != null){ clearInterval(newsLoadingListener) newsLoadingListener = null } } } // Bind retry button. newsErrorRetry.onclick = () => { $('#newsErrorFailed').fadeOut(250, () => { initNews() $('#newsErrorLoading').fadeIn(250) }) } newsArticleContentScrollable.onscroll = (e) => { if(e.target.scrollTop > Number.parseFloat($('.newsArticleSpacerTop').css('height'))){ newsContent.setAttribute('scrolled', '') } else { newsContent.removeAttribute('scrolled') } } /** * Reload the news without restarting. * * @returns {Promise.} A promise which resolves when the news * content has finished loading and transitioning. */ function reloadNews(){ return new Promise((resolve, reject) => { $('#newsContent').fadeOut(250, () => { $('#newsErrorLoading').fadeIn(250) initNews().then(() => { resolve() }) }) }) } let newsAlertShown = false /** * Show the news alert indicating there is new news. */ function showNewsAlert(){ newsAlertShown = true $(newsButtonAlert).fadeIn(250) } async function digestMessage(str) { const msgUint8 = new TextEncoder().encode(str) const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8) const hashArray = Array.from(new Uint8Array(hashBuffer)) const hashHex = hashArray .map((b) => b.toString(16).padStart(2, '0')) .join('') return hashHex } /** * Initialize News UI. This will load the news and prepare * the UI accordingly. * * @returns {Promise.} A promise which resolves when the news * content has finished loading and transitioning. */ async function initNews(){ setNewsLoading(true) const news = await loadNews() newsArr = news?.articles || null if(newsArr == null){ // News Loading Failed setNewsLoading(false) await $('#newsErrorLoading').fadeOut(250).promise() await $('#newsErrorFailed').fadeIn(250).promise() } else if(newsArr.length === 0) { // No News Articles setNewsLoading(false) ConfigManager.setNewsCache({ date: null, content: null, dismissed: false }) ConfigManager.save() await $('#newsErrorLoading').fadeOut(250).promise() await $('#newsErrorNone').fadeIn(250).promise() } else { // Success setNewsLoading(false) const lN = newsArr[0] const cached = ConfigManager.getNewsCache() let newHash = await digestMessage(lN.content) let newDate = new Date(lN.date) let isNew = false if(cached.date != null && cached.content != null){ if(new Date(cached.date) >= newDate){ // Compare Content if(cached.content !== newHash){ isNew = true showNewsAlert() } else { if(!cached.dismissed){ isNew = true showNewsAlert() } } } else { isNew = true showNewsAlert() } } else { isNew = true showNewsAlert() } if(isNew){ ConfigManager.setNewsCache({ date: newDate.getTime(), content: newHash, dismissed: false }) ConfigManager.save() } const switchHandler = (forward) => { let cArt = parseInt(newsContent.getAttribute('article')) let nxtArt = forward ? (cArt >= newsArr.length-1 ? 0 : cArt + 1) : (cArt <= 0 ? newsArr.length-1 : cArt - 1) displayArticle(newsArr[nxtArt], nxtArt+1) } document.getElementById('newsNavigateRight').onclick = () => { switchHandler(true) } document.getElementById('newsNavigateLeft').onclick = () => { switchHandler(false) } await $('#newsErrorContainer').fadeOut(250).promise() displayArticle(newsArr[0], 1) await $('#newsContent').fadeIn(250).promise() } } /** * Add keyboard controls to the news UI. Left and right arrows toggle * between articles. If you are on the landing page, the up arrow will * open the news UI. */ document.addEventListener('keydown', (e) => { if(newsActive){ if(e.key === 'ArrowRight' || e.key === 'ArrowLeft'){ document.getElementById(e.key === 'ArrowRight' ? 'newsNavigateRight' : 'newsNavigateLeft').click() } // Interferes with scrolling an article using the down arrow. // Not sure of a straight forward solution at this point. // if(e.key === 'ArrowDown'){ // document.getElementById('newsButton').click() // } } else { if(getCurrentView() === VIEWS.landing){ if(e.key === 'ArrowUp'){ document.getElementById('newsButton').click() } } } }) /** * Display a news article on the UI. * * @param {Object} articleObject The article meta object. * @param {number} index The article index. */ function displayArticle(articleObject, index){ newsArticleTitle.innerHTML = articleObject.title newsArticleTitle.href = articleObject.link newsArticleAuthor.innerHTML = 'by ' + articleObject.author newsArticleDate.innerHTML = articleObject.date newsArticleComments.innerHTML = articleObject.comments newsArticleComments.href = articleObject.commentsLink newsArticleContentScrollable.innerHTML = '
' + articleObject.content + '
' Array.from(newsArticleContentScrollable.getElementsByClassName('bbCodeSpoilerButton')).forEach(v => { v.onclick = () => { const text = v.parentElement.getElementsByClassName('bbCodeSpoilerText')[0] text.style.display = text.style.display === 'block' ? 'none' : 'block' } }) newsNavigationStatus.innerHTML = Lang.query('ejs.landing.newsNavigationStatus', {currentPage: index, totalPages: newsArr.length}) newsContent.setAttribute('article', index-1) } /** * Load news information from the RSS feed specified in the * distribution index. */ async function loadNews(){ const distroData = await DistroAPI.getDistribution() if(!distroData.rawDistribution.rss) { loggerLanding.debug('No RSS feed provided.') return null } const promise = new Promise((resolve, reject) => { const newsFeed = distroData.rawDistribution.rss const newsHost = new URL(newsFeed).origin + '/' $.ajax({ url: newsFeed, success: (data) => { const items = $(data).find('item') const articles = [] for(let i=0; i { resolve({ articles: null }) }) }) return await promise }