浏览代码

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.
Daniel Scalzi 5 年之前
父节点
当前提交
dc00e6104b

+ 1 - 1
README.md

@@ -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 - 0
package-lock.json

@@ -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",

+ 1 - 0
package.json

@@ -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",

+ 16 - 34
src/common/asset/processor/MojangIndexProcessor.ts

@@ -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)

+ 3 - 0
src/common/distribution/distribution.ts → src/common/distribution/DistributionAPI.ts

@@ -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')

+ 195 - 0
src/common/distribution/DistributionFactory.ts

@@ -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
+    }
+
+}

+ 107 - 0
src/common/util/MavenUtil.ts

@@ -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))
+    }
+
+}

+ 10 - 6
src/main/index.ts

@@ -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

+ 60 - 9
src/renderer/components/Application.tsx

@@ -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()
+
+            // 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,
+                    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
+                    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>

+ 124 - 0
src/renderer/components/fatal/Fatal.css

@@ -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);
+}

+ 70 - 0
src/renderer/components/fatal/Fatal.tsx

@@ -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>
+
+            </>
+        )
+
+    }
+
+}

+ 0 - 321
src/renderer/components/landing/Landing.css

@@ -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)                                                   *

+ 6 - 59
src/renderer/components/landing/Landing.tsx

@@ -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>
 
 

+ 320 - 0
src/renderer/components/news/News.css

@@ -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;
+}

+ 70 - 0
src/renderer/components/news/News.tsx

@@ -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>
+
+            </>
+        )
+
+    }
+
+}

+ 12 - 2
src/renderer/index.tsx

@@ -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')

+ 3 - 1
src/renderer/meta/Views.ts

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

+ 11 - 6
src/renderer/redux/actions/appActions.ts

@@ -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)
 }

+ 6 - 6
src/renderer/redux/reducers/appReducer.ts

@@ -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

+ 1 - 1
src/renderer/redux/reducers/viewReducer.ts

@@ -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) {

+ 4 - 2
src/renderer/redux/store.ts

@@ -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__!())

二进制
static/images/SealCircleError.png