Initial work on distro load logic.

Added new FATAL view to display information when a distro load fails. This replaces
the overlay behavior used on v1. The fatal view will eventually do an update check
and allow the user to update the app. This solves a potential issue of a user using
a very outdated launcher version, and the distro failing as a result.

Added new wrapper classes to store the distribution in the redux store.
ts-refactor
Daniel Scalzi 2020-08-29 01:12:39 -04:00
parent dbc49f51dd
commit dc00e6104b
No known key found for this signature in database
GPG Key ID: D18EA3FB4B142A57
22 changed files with 1028 additions and 450 deletions

View File

@ -1,4 +1,4 @@
<p align="center"><img src="./app/assets/images/SealCircle.png" width="150px" height="150px" alt="aventium softworks"></p>
<p align="center"><img src="./static/images/SealCircle.png" width="150px" height="150px" alt="aventium softworks"></p>
<h1 align="center">Helios Launcher</h1>

6
package-lock.json generated
View File

@ -1445,6 +1445,12 @@
"integrity": "sha512-Ee0vt82qcg05OeJrQZ/YN+NQwaBCnAul1rVLYaMLPkwR5f44WC3BpBQNvn5Z3Axu9szaVOHqXEDBI+uAXAiyrg==",
"dev": true
},
"@types/electron-devtools-installer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/electron-devtools-installer/-/electron-devtools-installer-2.2.0.tgz",
"integrity": "sha512-HJNxpaOXuykCK4rQ6FOMxAA0NLFYsf7FiPFGmab0iQmtVBHSAfxzy3MRFpLTTDDWbV0yD2YsHOQvdu8yCqtCfw==",
"dev": true
},
"@types/eslint-visitor-keys": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz",

View File

@ -53,6 +53,7 @@
"@types/chai": "^4.2.12",
"@types/chai-as-promised": "^7.1.3",
"@types/discord-rpc": "^3.0.4",
"@types/electron-devtools-installer": "^2.2.0",
"@types/fs-extra": "^9.0.1",
"@types/jquery": "^3.5.1",
"@types/lodash": "^4.14.160",

View File

@ -1,14 +1,16 @@
import { IndexProcessor } from '../model/engine/IndexProcessor'
import got, { HTTPError, RequestError, ParseError, TimeoutError } from 'got'
import { LoggerUtil } from 'common/logging/loggerutil'
import { pathExists, readFile, ensureDir, writeFile, readJson } from 'fs-extra'
import { MojangVersionManifest } from '../model/mojang/VersionManifest'
import { calculateHash, getVersionJsonPath, validateLocalFile, getLibraryDir, getVersionJarPath } from 'common/util/FileUtils'
import got from 'got'
import { dirname, join } from 'path'
import { VersionJson, AssetIndex, LibraryArtifact } from '../model/mojang/VersionJson'
import { AssetGuardError } from '../model/engine/AssetGuardError'
import { Asset } from '../model/engine/Asset'
import { isLibraryCompatible, getMojangOS } from 'common/util/MojangUtils'
import { ensureDir, pathExists, readFile, readJson, writeFile } from 'fs-extra'
import { Asset } from 'common/asset/model/engine/Asset'
import { AssetGuardError } from 'common/asset/model/engine/AssetGuardError'
import { IndexProcessor } from 'common/asset/model/engine/IndexProcessor'
import { MojangVersionManifest } from 'common/asset/model/mojang/VersionManifest'
import { handleGotError } from 'common/got/RestResponse'
import { AssetIndex, LibraryArtifact, VersionJson } from 'common/asset/model/mojang/VersionJson'
import { calculateHash, getLibraryDir, getVersionJarPath, getVersionJsonPath, validateLocalFile } from 'common/util/FileUtils'
import { getMojangOS, isLibraryCompatible } from 'common/util/MojangUtils'
import { LoggerUtil } from 'common/logging/loggerutil'
export class MojangIndexProcessor extends IndexProcessor {
@ -16,7 +18,7 @@ export class MojangIndexProcessor extends IndexProcessor {
public static readonly VERSION_MANIFEST_ENDPOINT = 'https://launchermeta.mojang.com/mc/game/version_manifest.json'
public static readonly ASSET_RESOURCE_ENDPOINT = 'http://resources.download.minecraft.net'
private readonly logger = LoggerUtil.getLogger('MojangIndexProcessor')
private static readonly logger = LoggerUtil.getLogger('MojangIndexProcessor')
private versionJson!: VersionJson
private assetIndex!: AssetIndex
@ -24,26 +26,6 @@ export class MojangIndexProcessor extends IndexProcessor {
responseType: 'json'
})
private handleGotError<T>(operation: string, error: RequestError, dataProvider: () => T): T {
if(error instanceof HTTPError) {
this.logger.error(`Error during ${operation} request (HTTP Response ${error.response.statusCode})`, error)
this.logger.debug('Response Details:')
this.logger.debug('Body:', error.response.body)
this.logger.debug('Headers:', error.response.headers)
} else if(Object.getPrototypeOf(error) instanceof RequestError) {
this.logger.error(`${operation} request recieved no response (${error.code}).`, error)
} else if(error instanceof TimeoutError) {
this.logger.error(`${operation} request timed out (${error.timings.phases.total}ms).`)
} else if(error instanceof ParseError) {
this.logger.error(`${operation} request recieved unexepected body (Parse Error).`)
} else {
// CacheError, ReadError, MaxRedirectsError, UnsupportedProtocolError, CancelError
this.logger.error(`Error during ${operation} request.`, error)
}
return dataProvider()
}
private assetPath: string
constructor(commonDir: string, protected version: string) {
@ -148,7 +130,7 @@ export class MojangIndexProcessor extends IndexProcessor {
return res.body
} catch(error) {
return this.handleGotError(url, error, () => null)
return handleGotError(url, error, MojangIndexProcessor.logger, () => null).data
}
}
@ -158,7 +140,7 @@ export class MojangIndexProcessor extends IndexProcessor {
const res = await this.client.get<MojangVersionManifest>(MojangIndexProcessor.VERSION_MANIFEST_ENDPOINT)
return res.body
} catch(error) {
return this.handleGotError('Load Mojang Version Manifest', error, () => null)
return handleGotError('Load Mojang Version Manifest', error, MojangIndexProcessor.logger, () => null).data
}
}
@ -187,7 +169,7 @@ export class MojangIndexProcessor extends IndexProcessor {
// TODO progress tracker
// TODO type return object
public async validate(): Promise<any> {
public async validate(): Promise<{[category: string]: Asset[]}> {
const assets = await this.validateAssets(this.assetIndex)
const libraries = await this.validateLibraries(this.versionJson)

View File

@ -5,6 +5,9 @@ import { LoggerUtil } from 'common/logging/loggerutil'
import { RestResponse, handleGotError, RestResponseStatus } from 'common/got/RestResponse'
import { pathExists, readFile, writeFile } from 'fs-extra'
// TODO Option to check endpoint for hash of distro for local compare
// Useful if distro is large (MBs)
export class DistributionAPI {
private static readonly logger = LoggerUtil.getLogger('DistributionAPI')

View File

@ -0,0 +1,195 @@
import { Distribution, Server, Module, Type, Required as HeliosRequired } from 'helios-distribution-types'
import { MavenComponents, MavenUtil } from 'common/util/MavenUtil'
import { join } from 'path'
export class HeliosDistribution {
private mainServerIndex: number
public readonly servers: HeliosServer[]
constructor(
public readonly rawDistribution: Distribution
) {
this.servers = this.rawDistribution.servers.map(s => new HeliosServer(s))
this.mainServerIndex = this.indexOfMainServer()
}
private indexOfMainServer(): number {
for(let i=0; i<this.servers.length; i++) {
if(this.servers[i].rawServer.mainServer) {
return i
}
}
return 0
}
public getMainServer(): HeliosServer | null {
return this.mainServerIndex < this.servers.length ? this.servers[this.mainServerIndex] : null
}
public getServerById(id: string): HeliosServer | null {
return this.servers.find(s => s.rawServer.id === id) || null
}
}
export class HeliosServer {
public readonly modules: HeliosModule[]
constructor(
public readonly rawServer: Server
) {
this.modules = rawServer.modules.map(m => new HeliosModule(m, rawServer.id))
}
}
export class HeliosModule {
public readonly subModules: HeliosModule[]
private readonly mavenComponents: Readonly<MavenComponents>
private readonly required: Readonly<Required<HeliosRequired>>
private readonly localPath: string
constructor(
public readonly rawModule: Module,
private readonly serverId: string
) {
this.mavenComponents = this.resolveMavenComponents()
this.required = this.resolveRequired()
this.localPath = this.resolveLocalPath()
if(this.rawModule.subModules != null) {
this.subModules = this.rawModule.subModules.map(m => new HeliosModule(m, serverId))
} else {
this.subModules = []
}
}
private resolveMavenComponents(): MavenComponents {
// Files need not have a maven identifier if they provide a path.
if(this.rawModule.type === Type.File && this.rawModule.artifact.path != null) {
return null! as MavenComponents
}
// Version Manifests never provide a maven identifier.
if(this.rawModule.type === Type.VersionManifest) {
return null! as MavenComponents
}
const isMavenId = MavenUtil.isMavenIdentifier(this.rawModule.id)
if(!isMavenId) {
if(this.rawModule.type !== Type.File) {
throw new Error(`Module ${this.rawModule.name} (${this.rawModule.id}) of type ${this.rawModule.type} must have a valid maven identifier!`)
} else {
throw new Error(`Module ${this.rawModule.name} (${this.rawModule.id}) of type ${this.rawModule.type} must either declare an artifact path or have a valid maven identifier!`)
}
}
try {
return MavenUtil.getMavenComponents(this.rawModule.id)
} catch(err) {
throw new Error(`Failed to resolve maven components for module ${this.rawModule.name} (${this.rawModule.id}) of type ${this.rawModule.type}. Reason: ${err.message}`)
}
}
private resolveRequired(): Required<HeliosRequired> {
if(this.rawModule.required == null) {
return {
value: true,
def: true
}
} else {
return {
value: this.rawModule.required.value ?? true,
def: this.rawModule.required.def ?? true
}
}
}
private resolveLocalPath(): string {
// Version Manifests have a pre-determined path.
if(this.rawModule.type === Type.VersionManifest) {
return join('TODO_COMMON_DIR', 'versions', this.rawModule.id, `${this.rawModule.id}.json`)
}
const relativePath = this.rawModule.artifact.path ?? MavenUtil.mavenComponentsAsNormalizedPath(
this.mavenComponents.group,
this.mavenComponents.artifact,
this.mavenComponents.version,
this.mavenComponents.classifier,
this.mavenComponents.extension
)
switch (this.rawModule.type) {
case Type.Library:
case Type.Forge:
case Type.ForgeHosted:
case Type.LiteLoader:
return join('TODO_COMMON_DIR', 'libraries', relativePath)
case Type.ForgeMod:
case Type.LiteMod:
return join('TODO_COMMON_DIR', 'modstore', relativePath)
case Type.File:
default:
return join('TODO_INSTANCE_DIR', this.serverId, relativePath)
}
}
public hasMavenComponents(): boolean {
return this.mavenComponents != null
}
public getMavenComponents(): Readonly<MavenComponents> {
return this.mavenComponents
}
public getRequired(): Readonly<Required<HeliosRequired>> {
return this.required
}
public getPath(): string {
return this.localPath
}
public getMavenIdentifier(): string {
return MavenUtil.mavenComponentsToIdentifier(
this.mavenComponents.group,
this.mavenComponents.artifact,
this.mavenComponents.version,
this.mavenComponents.classifier,
this.mavenComponents.extension
)
}
public getExtensionlessMavenIdentifier(): string {
return MavenUtil.mavenComponentsToExtensionlessIdentifier(
this.mavenComponents.group,
this.mavenComponents.artifact,
this.mavenComponents.version,
this.mavenComponents.classifier
)
}
public getVersionlessMavenIdentifier(): string {
return MavenUtil.mavenComponentsToVersionlessIdentifier(
this.mavenComponents.group,
this.mavenComponents.artifact
)
}
public hasSubModules(): boolean {
return this.subModules.length > 0
}
}

View File

@ -0,0 +1,107 @@
import { normalize } from 'path'
import { URL } from 'url'
export interface MavenComponents {
group: string
artifact: string
version: string
classifier?: string
extension: string
}
export class MavenUtil {
public static readonly ID_REGEX = /(.+):(.+):([^@]+)()(?:@{1}(.+)$)?/
public static readonly ID_REGEX_WITH_CLASSIFIER = /(.+):(.+):(?:([^@]+)(?:-([a-zA-Z]+)))(?:@{1}(.+)$)?/
public static mavenComponentsToIdentifier(
group: string,
artifact: string,
version: string,
classifier?: string,
extension?: string
): string {
return `${group}:${artifact}:${version}${classifier != null ? `:${classifier}` : ''}${extension != null ? `@${extension}` : ''}`
}
public static mavenComponentsToExtensionlessIdentifier(
group: string,
artifact: string,
version: string,
classifier?: string
): string {
return MavenUtil.mavenComponentsToIdentifier(group, artifact, version, classifier)
}
public static mavenComponentsToVersionlessIdentifier(
group: string,
artifact: string
): string {
return `${group}:${artifact}`
}
public static isMavenIdentifier(id: string): boolean {
return MavenUtil.ID_REGEX.test(id) || MavenUtil.ID_REGEX_WITH_CLASSIFIER.test(id)
}
public static getMavenComponents(id: string, extension = 'jar'): MavenComponents {
if (!MavenUtil.isMavenIdentifier(id)) {
throw new Error('Id is not a maven identifier.')
}
let result
if (MavenUtil.ID_REGEX_WITH_CLASSIFIER.test(id)) {
result = MavenUtil.ID_REGEX_WITH_CLASSIFIER.exec(id)
} else {
result = MavenUtil.ID_REGEX.exec(id)
}
if (result != null) {
return {
group: result[1],
artifact: result[2],
version: result[3],
classifier: result[4] || undefined,
extension: result[5] || extension
}
}
throw new Error('Failed to process maven data.')
}
public static mavenIdentifierAsPath(id: string, extension = 'jar'): string {
const tmp = MavenUtil.getMavenComponents(id, extension)
return MavenUtil.mavenComponentsAsPath(
tmp.group, tmp.artifact, tmp.version, tmp.classifier, tmp.extension
)
}
public static mavenComponentsAsPath(
group: string, artifact: string, version: string, classifier?: string, extension = 'jar'
): string {
return `${group.replace(/\./g, '/')}/${artifact}/${version}/${artifact}-${version}${classifier != null ? `-${classifier}` : ''}.${extension}`
}
public static mavenIdentifierToUrl(id: string, extension = 'jar'): URL {
return new URL(MavenUtil.mavenIdentifierAsPath(id, extension))
}
public static mavenComponentsToUrl(
group: string, artifact: string, version: string, classifier?: string, extension = 'jar'
): URL {
return new URL(MavenUtil.mavenComponentsAsPath(group, artifact, version, classifier, extension))
}
public static mavenIdentifierToPath(id: string, extension = 'jar'): string {
return normalize(MavenUtil.mavenIdentifierAsPath(id, extension))
}
public static mavenComponentsAsNormalizedPath(
group: string, artifact: string, version: string, classifier?: string, extension = 'jar'
): string {
return normalize(MavenUtil.mavenComponentsAsPath(group, artifact, version, classifier, extension))
}
}

View File

@ -8,14 +8,12 @@ import isdev from '../common/util/isdev'
declare const __static: string
const installExtensions = async () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const installer = require('electron-devtools-installer')
const { default: installExtension, REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } = await import('electron-devtools-installer')
const forceDownload = !!process.env.UPGRADE_EXTENSIONS
const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS']
const extensions = [REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS]
return Promise.all(
extensions.map(name => installer.default(installer[name], forceDownload))
).catch(console.log) // eslint-disable-line no-console
return installExtension(extensions, forceDownload).catch(console.log) // eslint-disable-line no-console
}
// Setup auto updater.
@ -145,6 +143,12 @@ async function createWindow() {
win.removeMenu()
win.resizable = true
// win.webContents.on('new-window', (e, url) => {
// if(url != win!.webContents.getURL()) {
// e.preventDefault()
// shell.openExternal(url)
// }
// })
if (process.env.NODE_ENV !== 'production') {
// Open DevTools, see https://github.com/electron/electron/issues/12438 for why we wait for dom-ready

View File

@ -8,17 +8,21 @@ import Landing from './landing/Landing'
import Login from './login/Login'
import Loader from './loader/Loader'
import Settings from './settings/Settings'
import Overlay from './overlay/Overlay'
import Fatal from './fatal/Fatal'
import { StoreType } from '../redux/store'
import { CSSTransition } from 'react-transition-group'
import { ViewActionDispatch } from '../redux/actions/viewActions'
import { throttle } from 'lodash'
import { readdir } from 'fs-extra'
import { join } from 'path'
import Overlay from './overlay/Overlay'
import { AppActionDispatch } from '../redux/actions/appActions'
import { OverlayPushAction, OverlayActionDispatch } from '../redux/actions/overlayActions'
import { DistributionAPI } from 'common/distribution/distribution'
import { DistributionAPI } from 'common/distribution/DistributionAPI'
import { getServerStatus } from 'common/mojang/net/ServerStatusAPI'
import { Distribution } from 'helios-distribution-types'
import { HeliosDistribution } from 'common/distribution/DistributionFactory'
import './Application.css'
@ -49,6 +53,7 @@ const mapState = (state: StoreType): Partial<ApplicationProps> => {
}
}
const mapDispatch = {
...AppActionDispatch,
...ViewActionDispatch,
...OverlayActionDispatch
}
@ -68,6 +73,8 @@ class Application extends React.Component<ApplicationProps & typeof mapDispatch,
}
getViewElement(): JSX.Element {
// TODO debug remove
console.log('loading', this.props.currentView, this.state.workingView)
switch(this.state.workingView) {
case View.WELCOME:
return <>
@ -85,6 +92,12 @@ class Application extends React.Component<ApplicationProps & typeof mapDispatch,
return <>
<Settings />
</>
case View.FATAL:
return <>
<Fatal />
</>
case View.NONE:
return <></>
}
}
@ -94,6 +107,8 @@ class Application extends React.Component<ApplicationProps & typeof mapDispatch,
}
private updateWorkingView = throttle(() => {
// TODO debug remove
console.log('Setting to', this.props.currentView)
this.setState({
...this.state,
workingView: this.props.currentView
@ -101,8 +116,14 @@ class Application extends React.Component<ApplicationProps & typeof mapDispatch,
}, 200)
private finishLoad = (): void => {
if(this.props.currentView !== View.FATAL) {
setBackground(this.bkid)
}
this.showMain()
}
private showMain = (): void => {
setBackground(this.bkid)
this.setState({
...this.state,
showMain: true
@ -113,23 +134,53 @@ class Application extends React.Component<ApplicationProps & typeof mapDispatch,
if(this.state.loading) {
const MIN_LOAD = 800
const start = Date.now()
this.bkid = Math.floor((Math.random() * (await readdir(join(__static, 'images', 'backgrounds'))).length))
const endLoad = () => {
// Initial distribution load.
const distroAPI = new DistributionAPI('C:\\Users\\user\\AppData\\Roaming\\Helios Launcher')
let rawDisto: Distribution
try {
rawDisto = await distroAPI.testLoad()
console.log('distro', distroAPI)
} catch(err) {
console.log('EXCEPTION IN DISTRO LOAD TODO TODO TODO', err)
rawDisto = null!
}
// Fatal error
if(rawDisto == null) {
this.props.setView(View.FATAL)
this.setState({
...this.state,
loading: false
loading: false,
workingView: View.FATAL
})
return
} else {
this.props.setDistribution(new HeliosDistribution(rawDisto))
}
// TODO Setup hook for distro refresh every ~ 5 mins.
// Pick a background id.
this.bkid = Math.floor((Math.random() * (await readdir(join(__static, 'images', 'backgrounds'))).length))
const endLoad = () => {
// TODO determine correct view
// either welcome, landing, or login
this.props.setView(View.LANDING)
this.setState({
...this.state,
loading: false,
workingView: View.LANDING
})
// TODO temp
setTimeout(() => {
//this.props.setView(View.WELCOME)
this.props.pushGenericOverlay({
title: 'Load Distribution',
description: 'This is a test. Will load the distribution.',
description: 'This is a test.',
dismissible: false,
acknowledgeCallback: async () => {
const distro = new DistributionAPI('C:\\Users\\user\\AppData\\Roaming\\Helios Launcher')
const x = await distro.testLoad()
console.log(x)
const serverStatus = await getServerStatus(47, 'play.hypixel.net', 25565)
console.log(serverStatus)
}
@ -206,7 +257,7 @@ class Application extends React.Component<ApplicationProps & typeof mapDispatch,
classNames="loader"
unmountOnExit
onEnter={this.initLoad}
onExited={this.showMain}
onExited={this.finishLoad}
>
<Loader />
</CSSTransition>

View File

@ -0,0 +1,124 @@
#fatalContainer {
position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}
#fatalContent {
display: flex;
flex-direction: column;
align-items: center;
top: 0;
height: 100%;
padding-top: 2.5rem;
box-sizing: border-box;
position: relative;
}
#fatalHeader {
display: flex;
align-items: center;
justify-content: center;
padding: 0 0 1rem 0;
width: 70%;
margin-bottom: .5rem;
border-bottom: 0.0625rem solid rgba(126, 126, 126, 0.57);
}
#fatalLeft {
display: flex;
}
#fatalErrorImg {
width: 3.125rem;
}
#fatalRight {
display: flex;
flex-direction: column;
padding-left: 0.625rem;
}
#fatalErrorLabel {
font-size: .75rem;
font-weight: bold;
}
#fatalErrorText {
font-size: 1.75rem;
}
#fatalBody {
width: 65%;
display: flex;
flex-direction: column;
align-items: center;
}
#fatalDescription {
text-align: justify;
font-size: 0.875rem;
}
#fatalChecklistContainer {
width: 100%;
font-size: 0.875rem;
}
/* Div which contains action buttons. */
#fatalActionContainer {
display: flex;
flex-direction: column;
justify-content: center;
padding-top: 2rem;
}
/* Fatal acknowledge button styles. */
#fatalAcknowledge {
background: none;
border: 0.0625rem solid #ffffff;
color: white;
font-family: 'Avenir Medium';
font-weight: bold;
border-radius: .125rem;
padding: 0 .6rem;
font-size: 1rem;
cursor: pointer;
transition: 0.25s ease;
}
#fatalAcknowledge:hover,
#fatalAcknowledge:focus {
box-shadow: 0 0 .625rem 0 #fff;
outline: none;
}
#fatalAcknowledge:active {
border-color: rgba(255, 255, 255, 0.75);
color: rgba(255, 255, 255, 0.75);
}
#fatalDismissWrapper {
display: flex;
justify-content: center;
}
/* Fatal dismiss option styles. */
#fatalDismiss {
font-weight: bold;
font-size: 0.875rem;
text-decoration: none;
padding-top: 0.375rem;
background: none;
border: none;
outline: none;
cursor: pointer;
color: rgba(202, 202, 202, 0.75);
transition: 0.25s ease;
}
#fatalDismiss:hover,
#fatalDismiss:focus {
color: rgba(255, 255, 255, 0.75);
}
#fatalDismiss:active {
color: rgba(165, 165, 165, 0.75);
}

View File

@ -0,0 +1,70 @@
import * as React from 'react'
import { remote, shell } from 'electron'
import './Fatal.css'
function closeHandler() {
const window = remote.getCurrentWindow()
window.close()
}
function openLatest() {
// TODO don't hardcode
shell.openExternal('https://github.com/dscalzi/HeliosLauncher/releases')
}
export default class Fatal extends React.Component {
render(): JSX.Element {
return (
<>
<div id="fatalContainer">
<div id="fatalContent">
<div id="fatalHeader">
<div id="fatalLeft">
<img id="fatalErrorImg" src="../images/SealCircleError.png"/>
</div>
<div id="fatalRight">
<span id="fatalErrorLabel">FATAL ERROR</span>
<span id="fatalErrorText">Failed to load Distribution Index</span>
</div>
</div>
<div id="fatalBody">
<h4>What Happened?</h4>
<p id="fatalDescription">
A connection could not be established to our servers to download the distribution index. No local copies were available to load.
The distribution index is an essential file which provides the latest server information. The launcher is unable to start without it.
</p>
{/* TODO When auto update is done, do a version check and auto/update here. */}
<div id="fatalChecklistContainer">
<ul>
<li>Ensure you are running the latest version of Helios Launcher.</li>
<li>Ensure you are connected to the internet.</li>
</ul>
</div>
<h4>Relaunch the application to try again.</h4>
<div id="fatalActionContainer">
<button onClick={openLatest} id="fatalAcknowledge">Latest Releaes</button>
<div id="fatalDismissWrapper">
<button onClick={closeHandler} id="fatalDismiss">Close Launcher</button>
</div>
</div>
</div>
</div>
</div>
</>
)
}
}

View File

@ -83,327 +83,6 @@
display: inline-flex;
}
/*******************************************************************************
* *
* Landing View (News Styles) *
* *
******************************************************************************/
/* Main container. */
#newsContainer {
position: absolute;
top: 100%;
height: 100%;
width: 100%;
transition: top 2s ease;
display: flex;
align-items: flex-end;
justify-content: center;
}
/* News content container. */
#newsContent {
height: 82vh;
width: 100%;
display: flex;
-webkit-user-select: initial;
position: relative;
}
/* Drop shadow displayed when content is scrolled out of view. */
#newsContent:before {
content: '';
background: linear-gradient(rgba(0, 0, 0, 0.25), transparent);
width: 100%;
height: 5px;
position: absolute;
opacity: 0;
transition: opacity 0.25s ease;
}
#newsContent[scrolled]:before {
opacity: 1;
}
/* News article status container (left). */
#newsStatusContainer {
width: calc(30% - 60px);
height: calc(100% - 30px);
padding: 15px 15px 15px 45px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
}
/* News status content. */
#newsStatusContent {
display: flex;
flex-direction: column;
align-items: flex-end;
}
/* News title wrapper. */
#newsTitleContainer {
display: flex;
max-width: 90%;
}
/* News article title styles. */
#newsArticleTitle {
font-size: 18px;
font-weight: bold;
font-family: 'Avenir Medium';
color: white;
text-decoration: none;
transition: 0.25s ease;
outline: none;
text-align: right;
}
#newsArticleTitle:hover,
#newsArticleTitle:focus {
text-shadow: 0 0 20px white;
}
#newsArticleTitle:active {
color: #c7c7c7;
text-shadow: 0 0 20px #c7c7c7;
}
/* News meta container. */
#newsMetaContainer {
display: flex;
flex-direction: column;
}
/* Date and author wrappers. */
#newsArticleDateWrapper,
#newsArticleAuthorWrapper {
display: flex;
justify-content: flex-end;
}
/* Date and author shared styles. */
#newsArticleDate,
#newsArticleAuthor {
display: inline-block;
font-size: 10px;
padding: 0 5px;
font-weight: bold;
border-radius: 2px;
}
/* Date styles. */
#newsArticleDate {
background: white;
color: black;
margin-top: 5px;
}
/* Author styles. */
#newsArticleAuthor {
background: #a02d2a;
}
/* News article comments styles. */
#newsArticleComments {
margin-top: 5px;
display: inline-block;
font-size: 10px;
color: #ffffff;
text-decoration: none;
transition: 0.25s ease;
outline: none;
text-align: right;
}
#newsArticleComments:focus,
#newsArticleComments:hover {
color: #e0e0e0;
}
#newsArticleComments:active {
color: #c7c7c7;
}
/* Article content container (right). */
#newsArticleContainer {
width: calc(100% - 25px);
height: 100%;
margin: 0 0 0 25px;
}
/* Article content styles. */
#newsArticleContentScrollable {
font-size: 12px;
overflow-y: scroll;
height: 100%;
padding: 0 15px 0 15px;
}
#newsArticleContentScrollable img,
#newsArticleContentScrollable iframe {
max-width: 95%;
display: block;
margin: 0 auto;
}
#newsArticleContentScrollable a {
color: rgba(202, 202, 202, 0.75);
transition: 0.25s ease;
outline: none;
}
#newsArticleContentScrollable a:hover,
#newsArticleContentScrollable a:focus {
color: rgba(255, 255, 255, 0.75);
}
#newsArticleContentScrollable a:active {
color: rgba(165, 165, 165, 0.75);
}
#newsArticleContentScrollable::-webkit-scrollbar {
width: 2px;
}
#newsArticleContentScrollable::-webkit-scrollbar-track {
display: none;
}
#newsArticleContentScrollable::-webkit-scrollbar-thumb {
border-radius: 10px;
box-shadow: inset 0 0 10px rgba(255, 255, 255, 0.50);
}
.bbCodeSpoilerButton {
background: none;
border: none;
outline: none;
cursor: pointer;
font-size: 16px;
transition: 0.25s ease;
width: 100%;
border-bottom: 1px solid white;
padding-bottom: 15px;
}
.bbCodeSpoilerButton:hover,
.bbCodeSpoilerButton:focus {
text-shadow: 0 0 20px #ffffff, 0 0 20px #ffffff, 0 0 20px #ffffff;
}
.bbCodeSpoilerButton:active {
color: #c7c7c7;
text-shadow: 0 0 20px #c7c7c7, 0 0 20px #c7c7c7, 0 0 20px #c7c7c7;
}
.bbCodeSpoilerText {
display: none;
padding: 15px 0;
border-bottom: 1px solid white;
}
#newsArticleContentWrapper {
width: 80%;
}
.newsArticleSpacerTop {
height: 15px;
}
/* Div to add spacing at the end of a news article. */
.newsArticleSpacerBot {
height: 30px;
}
/* News navigation container. */
#newsNavigationContainer {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 10px;
-webkit-user-select: none;
position: absolute;
bottom: 15px;
right: 0;
}
/* Navigation status span. */
#newsNavigationStatus {
font-size: 12px;
margin: 0 15px;
}
/* Left and right navigation button styles. */
#newsNavigateLeft,
#newsNavigateRight {
background: none;
border: none;
outline: none;
height: 20px;
cursor: pointer;
}
#newsNavigateLeft:hover #newsNavigationLeftSVG,
#newsNavigateLeft:focus #newsNavigationLeftSVG,
#newsNavigateRight:hover #newsNavigationRightSVG,
#newsNavigateRight:focus #newsNavigationRightSVG {
filter: drop-shadow(0px 0 2px #fff);
-webkit-filter: drop-shadow(0px 0 2px #fff);
}
#newsNavigateLeft:active #newsNavigationLeftSVG .arrowLine,
#newsNavigateRight:active #newsNavigationRightSVG .arrowLine {
stroke: #c7c7c7;
}
#newsNavigateLeft:active #newsNavigationLeftSVG,
#newsNavigateRight:active #newsNavigationRightSVG {
filter: drop-shadow(0px 0 2px #c7c7c7);
-webkit-filter: drop-shadow(0px 0 2px #c7c7c7);
}
#newsNavigateLeft:disabled #newsNavigationLeftSVG .arrowLine,
#newsNavigateRight:disabled #newsNavigationRightSVG .arrowLine {
stroke: rgba(255, 255, 255, 0.75);
}
#newsNavigationLeftSVG {
transform: rotate(-90deg);
width: 15px;
}
#newsNavigationRightSVG {
transform: rotate(90deg);
width: 15px;
}
/* News error (message) container. */
#newsErrorContainer {
height: 100%;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
#newsErrorFailed {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
/* News error content (message). */
.newsErrorContent {
font-size: 20px;
}
#newsErrorLoading {
display: flex;
width: 168.92px;
}
#nELoadSpan {
white-space: pre;
}
/* News error retry button styles. */
#newsErrorRetry {
font-size: 12px;
font-weight: bold;
cursor: pointer;
background: none;
border: none;
outline: none;
transition: 0.25s ease;
}
#newsErrorRetry:focus,
#newsErrorRetry:hover {
text-shadow: 0 0 20px white;
}
#newsErrorRetry:active {
color: #c7c7c7;
text-shadow: 0 0 20px #c7c7c7;
}
/*******************************************************************************
* *
* Landing View (Top Styles) *

View File

@ -1,4 +1,5 @@
import * as React from 'react'
import News from '../news/News'
import { MojangStatus, MojangStatusColor } from 'common/mojang/rest/internal/MojangStatus'
import { MojangResponse } from 'common/mojang/rest/internal/MojangResponse'
@ -99,12 +100,10 @@ export default class Landing extends React.Component<unknown, LandingState> {
const statuses: JSX.Element[] = []
for(const status of this.state.mojangStatuses.filter(s => s.essential === essential)) {
statuses.push(
<>
<div className="mojangStatusContainer">
<span className="mojangStatusIcon" style={{color: MojangRestAPI.statusToHex(status.status)}}>&#8226;</span>
<span className="mojangStatusName">{status.name}</span>
</div>
</>
<div className="mojangStatusContainer" key={status.service}>
<span className="mojangStatusIcon" style={{color: MojangRestAPI.statusToHex(status.status)}}>&#8226;</span>
<span className="mojangStatusName">{status.name}</span>
</div>
)
}
return statuses
@ -270,59 +269,7 @@ export default class Landing extends React.Component<unknown, LandingState> {
</div>
</div>
</div>
<div id="newsContainer">
<div id="newsContent" {...{article: '-1'}} style={{display: 'none'}}>
<div id="newsStatusContainer">
<div id="newsStatusContent">
<div id="newsTitleContainer">
<a id="newsArticleTitle" href="#">Lorem Ipsum</a>
</div>
<div id="newsMetaContainer">
<div id="newsArticleDateWrapper">
<span id="newsArticleDate">Mar 15, 44 BC, 9:14 AM</span>
</div>
<div id="newsArticleAuthorWrapper">
<span id="newsArticleAuthor">by Cicero</span>
</div>
<a href="#" id="newsArticleComments">0 Comments</a>
</div>
</div>
<div id="newsNavigationContainer">
<button id="newsNavigateLeft">
<svg id="newsNavigationLeftSVG" viewBox="0 0 24.87 13.97">
<polyline style={{transition: '0.25s ease'}} fill="none" stroke="#FFF" strokeWidth="2px" points="0.71 13.26 12.56 1.41 24.16 13.02"/>
</svg>
</button>
<span id="newsNavigationStatus">1 of 1</span>
<button id="newsNavigateRight">
<svg id="newsNavigationRightSVG" viewBox="0 0 24.87 13.97">
<polyline style={{transition: '0.25s ease'}} fill="none" stroke="#FFF" strokeWidth="2px" points="0.71 13.26 12.56 1.41 24.16 13.02"/>
</svg>
</button>
</div>
</div>
<div id="newsArticleContainer">
<div id="newsArticleContent">
<div id="newsArticleContentScrollable">
{/* Article Content */}
</div>
</div>
</div>
</div>
<div id="newsErrorContainer">
<div id="newsErrorLoading">
<span id="nELoadSpan" className="newsErrorContent">Checking for News..</span>
</div>
<div id="newsErrorFailed" style={{display: 'none'}}>
<span id="nEFailedSpan" className="newsErrorContent">Failed to Load News</span>
<button id="newsErrorRetry">Try Again</button>
</div>
<div id="newsErrorNone" style={{display: 'none'}}>
<span id="nENoneSpan" className="newsErrorContent">No News</span>
</div>
</div>
</div>
<script src="./assets/js/scripts/landing.js"></script>
<News />
</div>

View File

@ -0,0 +1,320 @@
/*******************************************************************************
* *
* Landing View (News Styles) *
* *
******************************************************************************/
/* Main container. */
#newsContainer {
position: absolute;
top: 100%;
height: 100%;
width: 100%;
transition: top 2s ease;
display: flex;
align-items: flex-end;
justify-content: center;
}
/* News content container. */
#newsContent {
height: 82vh;
width: 100%;
display: flex;
-webkit-user-select: initial;
position: relative;
}
/* Drop shadow displayed when content is scrolled out of view. */
#newsContent:before {
content: '';
background: linear-gradient(rgba(0, 0, 0, 0.25), transparent);
width: 100%;
height: 5px;
position: absolute;
opacity: 0;
transition: opacity 0.25s ease;
}
#newsContent[scrolled]:before {
opacity: 1;
}
/* News article status container (left). */
#newsStatusContainer {
width: calc(30% - 60px);
height: calc(100% - 30px);
padding: 15px 15px 15px 45px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
}
/* News status content. */
#newsStatusContent {
display: flex;
flex-direction: column;
align-items: flex-end;
}
/* News title wrapper. */
#newsTitleContainer {
display: flex;
max-width: 90%;
}
/* News article title styles. */
#newsArticleTitle {
font-size: 18px;
font-weight: bold;
font-family: 'Avenir Medium';
color: white;
text-decoration: none;
transition: 0.25s ease;
outline: none;
text-align: right;
}
#newsArticleTitle:hover,
#newsArticleTitle:focus {
text-shadow: 0 0 20px white;
}
#newsArticleTitle:active {
color: #c7c7c7;
text-shadow: 0 0 20px #c7c7c7;
}
/* News meta container. */
#newsMetaContainer {
display: flex;
flex-direction: column;
}
/* Date and author wrappers. */
#newsArticleDateWrapper,
#newsArticleAuthorWrapper {
display: flex;
justify-content: flex-end;
}
/* Date and author shared styles. */
#newsArticleDate,
#newsArticleAuthor {
display: inline-block;
font-size: 10px;
padding: 0 5px;
font-weight: bold;
border-radius: 2px;
}
/* Date styles. */
#newsArticleDate {
background: white;
color: black;
margin-top: 5px;
}
/* Author styles. */
#newsArticleAuthor {
background: #a02d2a;
}
/* News article comments styles. */
#newsArticleComments {
margin-top: 5px;
display: inline-block;
font-size: 10px;
color: #ffffff;
text-decoration: none;
transition: 0.25s ease;
outline: none;
text-align: right;
}
#newsArticleComments:focus,
#newsArticleComments:hover {
color: #e0e0e0;
}
#newsArticleComments:active {
color: #c7c7c7;
}
/* Article content container (right). */
#newsArticleContainer {
width: calc(100% - 25px);
height: 100%;
margin: 0 0 0 25px;
}
/* Article content styles. */
#newsArticleContentScrollable {
font-size: 12px;
overflow-y: scroll;
height: 100%;
padding: 0 15px 0 15px;
}
#newsArticleContentScrollable img,
#newsArticleContentScrollable iframe {
max-width: 95%;
display: block;
margin: 0 auto;
}
#newsArticleContentScrollable a {
color: rgba(202, 202, 202, 0.75);
transition: 0.25s ease;
outline: none;
}
#newsArticleContentScrollable a:hover,
#newsArticleContentScrollable a:focus {
color: rgba(255, 255, 255, 0.75);
}
#newsArticleContentScrollable a:active {
color: rgba(165, 165, 165, 0.75);
}
#newsArticleContentScrollable::-webkit-scrollbar {
width: 2px;
}
#newsArticleContentScrollable::-webkit-scrollbar-track {
display: none;
}
#newsArticleContentScrollable::-webkit-scrollbar-thumb {
border-radius: 10px;
box-shadow: inset 0 0 10px rgba(255, 255, 255, 0.50);
}
.bbCodeSpoilerButton {
background: none;
border: none;
outline: none;
cursor: pointer;
font-size: 16px;
transition: 0.25s ease;
width: 100%;
border-bottom: 1px solid white;
padding-bottom: 15px;
}
.bbCodeSpoilerButton:hover,
.bbCodeSpoilerButton:focus {
text-shadow: 0 0 20px #ffffff, 0 0 20px #ffffff, 0 0 20px #ffffff;
}
.bbCodeSpoilerButton:active {
color: #c7c7c7;
text-shadow: 0 0 20px #c7c7c7, 0 0 20px #c7c7c7, 0 0 20px #c7c7c7;
}
.bbCodeSpoilerText {
display: none;
padding: 15px 0;
border-bottom: 1px solid white;
}
#newsArticleContentWrapper {
width: 80%;
}
.newsArticleSpacerTop {
height: 15px;
}
/* Div to add spacing at the end of a news article. */
.newsArticleSpacerBot {
height: 30px;
}
/* News navigation container. */
#newsNavigationContainer {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 10px;
-webkit-user-select: none;
position: absolute;
bottom: 15px;
right: 0;
}
/* Navigation status span. */
#newsNavigationStatus {
font-size: 12px;
margin: 0 15px;
}
/* Left and right navigation button styles. */
#newsNavigateLeft,
#newsNavigateRight {
background: none;
border: none;
outline: none;
height: 20px;
cursor: pointer;
}
#newsNavigateLeft:hover #newsNavigationLeftSVG,
#newsNavigateLeft:focus #newsNavigationLeftSVG,
#newsNavigateRight:hover #newsNavigationRightSVG,
#newsNavigateRight:focus #newsNavigationRightSVG {
filter: drop-shadow(0px 0 2px #fff);
-webkit-filter: drop-shadow(0px 0 2px #fff);
}
#newsNavigateLeft:active #newsNavigationLeftSVG .arrowLine,
#newsNavigateRight:active #newsNavigationRightSVG .arrowLine {
stroke: #c7c7c7;
}
#newsNavigateLeft:active #newsNavigationLeftSVG,
#newsNavigateRight:active #newsNavigationRightSVG {
filter: drop-shadow(0px 0 2px #c7c7c7);
-webkit-filter: drop-shadow(0px 0 2px #c7c7c7);
}
#newsNavigateLeft:disabled #newsNavigationLeftSVG .arrowLine,
#newsNavigateRight:disabled #newsNavigationRightSVG .arrowLine {
stroke: rgba(255, 255, 255, 0.75);
}
#newsNavigationLeftSVG {
transform: rotate(-90deg);
width: 15px;
}
#newsNavigationRightSVG {
transform: rotate(90deg);
width: 15px;
}
/* News error (message) container. */
#newsErrorContainer {
height: 100%;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
#newsErrorFailed {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
/* News error content (message). */
.newsErrorContent {
font-size: 20px;
}
#newsErrorLoading {
display: flex;
width: 168.92px;
}
#nELoadSpan {
white-space: pre;
}
/* News error retry button styles. */
#newsErrorRetry {
font-size: 12px;
font-weight: bold;
cursor: pointer;
background: none;
border: none;
outline: none;
transition: 0.25s ease;
}
#newsErrorRetry:focus,
#newsErrorRetry:hover {
text-shadow: 0 0 20px white;
}
#newsErrorRetry:active {
color: #c7c7c7;
text-shadow: 0 0 20px #c7c7c7;
}

View File

@ -0,0 +1,70 @@
import * as React from 'react'
import './News.css'
export default class News extends React.Component {
render(): JSX.Element {
return (
<>
<div id="newsContainer">
<div id="newsContent" {...{article: '-1'}} style={{display: 'none'}}>
<div id="newsStatusContainer">
<div id="newsStatusContent">
<div id="newsTitleContainer">
<a id="newsArticleTitle" href="#">Lorem Ipsum</a>
</div>
<div id="newsMetaContainer">
<div id="newsArticleDateWrapper">
<span id="newsArticleDate">Mar 15, 44 BC, 9:14 AM</span>
</div>
<div id="newsArticleAuthorWrapper">
<span id="newsArticleAuthor">by Cicero</span>
</div>
<a href="#" id="newsArticleComments">0 Comments</a>
</div>
</div>
<div id="newsNavigationContainer">
<button id="newsNavigateLeft">
<svg id="newsNavigationLeftSVG" viewBox="0 0 24.87 13.97">
<polyline style={{transition: '0.25s ease'}} fill="none" stroke="#FFF" strokeWidth="2px" points="0.71 13.26 12.56 1.41 24.16 13.02"/>
</svg>
</button>
<span id="newsNavigationStatus">1 of 1</span>
<button id="newsNavigateRight">
<svg id="newsNavigationRightSVG" viewBox="0 0 24.87 13.97">
<polyline style={{transition: '0.25s ease'}} fill="none" stroke="#FFF" strokeWidth="2px" points="0.71 13.26 12.56 1.41 24.16 13.02"/>
</svg>
</button>
</div>
</div>
<div id="newsArticleContainer">
<div id="newsArticleContent">
<div id="newsArticleContentScrollable">
{/* Article Content */}
</div>
</div>
</div>
</div>
<div id="newsErrorContainer">
<div id="newsErrorLoading">
<span id="nELoadSpan" className="newsErrorContent">Checking for News..</span>
</div>
<div id="newsErrorFailed" style={{display: 'none'}}>
<span id="nEFailedSpan" className="newsErrorContent">Failed to Load News</span>
<button id="newsErrorRetry">Try Again</button>
</div>
<div id="newsErrorNone" style={{display: 'none'}}>
<span id="nENoneSpan" className="newsErrorContent">No News</span>
</div>
</div>
</div>
</>
)
}
}

View File

@ -1,11 +1,21 @@
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { AppContainer } from 'react-hot-loader'
import { Provider } from 'react-redux'
// import { shell } from 'electron'
import store from './redux/store'
import './index.css'
import Application from './components/Application'
import { Provider } from 'react-redux'
import './index.css'
// document.addEventListener('click', (event: MouseEvent) => {
// if ((event.target as HTMLElement)?.tagName === 'A' && (event.target as HTMLAnchorElement)?.href.startsWith('http')) {
// event.preventDefault()
// shell.openExternal((event.target as HTMLAnchorElement).href)
// }
// })
// Create main element
const mainElement = document.createElement('div')

View File

@ -2,5 +2,7 @@ export enum View {
LANDING = 'LANDING',
WELCOME = 'WELCOME',
LOGIN = 'LOGIN',
SETTINGS = 'SETTINGS'
SETTINGS = 'SETTINGS',
FATAL = 'FATAL',
NONE = 'NONE'
}

View File

@ -1,19 +1,24 @@
import { Action } from 'redux'
import { HeliosDistribution } from 'common/distribution/DistributionFactory'
export enum AppActionType {
ChangeLoadState = 'SET_LOADING'
SetDistribution = 'SET_DISTRIBUTION'
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface AppAction extends Action {}
export interface ChangeLoadStateAction extends AppAction {
payload: boolean
export interface SetDistributionAction extends AppAction {
payload: HeliosDistribution
}
export function setLoadingState(state: boolean): ChangeLoadStateAction {
export function setDistribution(distribution: HeliosDistribution): SetDistributionAction {
return {
type: AppActionType.ChangeLoadState,
payload: state
type: AppActionType.SetDistribution,
payload: distribution
}
}
export const AppActionDispatch = {
setDistribution: (d: HeliosDistribution): SetDistributionAction => setDistribution(d)
}

View File

@ -1,21 +1,21 @@
import { ChangeLoadStateAction, AppActionType, AppAction } from '../actions/appActions'
import { AppActionType, AppAction, SetDistributionAction } from '../actions/appActions'
import { Reducer } from 'redux'
import { HeliosDistribution } from 'common/distribution/DistributionFactory'
export interface AppState {
loading: boolean
distribution: HeliosDistribution | null
}
const defaultAppState: AppState = {
loading: true
distribution: null!
}
// TODO remove loading from global state. Keeping as an example...
const AppReducer: Reducer<AppState, AppAction> = (state = defaultAppState, action) => {
switch(action.type) {
case AppActionType.ChangeLoadState:
case AppActionType.SetDistribution:
return {
...state,
loading: (action as ChangeLoadStateAction).payload
distribution: (action as SetDistributionAction).payload
}
}
return state

View File

@ -2,7 +2,7 @@ import { Reducer } from 'redux'
import { View } from '../../meta/Views'
import { ChangeViewAction, ViewActionType } from '../actions/viewActions'
const defaultView = View.LANDING
const defaultView = View.NONE
const ViewReducer: Reducer<View, ChangeViewAction> = (state = defaultView, action) => {
switch(action.type) {

View File

@ -1,6 +1,8 @@
import { createStore } from 'redux'
import { createStore, StoreEnhancer } from 'redux'
import reducer from './reducers'
export type StoreType = ReturnType<typeof reducer>
export default createStore(reducer)
type Tmp = {__REDUX_DEVTOOLS_EXTENSION__?: () => StoreEnhancer}
export default createStore(reducer, (window as Tmp).__REDUX_DEVTOOLS_EXTENSION__ && (window as Tmp).__REDUX_DEVTOOLS_EXTENSION__!())

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB