diff options
| -rw-r--r-- | gui/package-lock.json | 44 | ||||
| -rw-r--r-- | gui/package.json | 2 | ||||
| -rw-r--r-- | gui/src/renderer/app.tsx | 20 | ||||
| -rw-r--r-- | gui/src/renderer/lib/history.ts | 112 | ||||
| -rw-r--r-- | gui/src/renderer/redux/store.ts | 2 | ||||
| -rw-r--r-- | gui/test/history.spec.ts | 168 |
6 files changed, 295 insertions, 53 deletions
diff --git a/gui/package-lock.json b/gui/package-lock.json index 249ef3ddda..9507dcc279 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -604,9 +604,9 @@ "dev": true }, "@types/history": { - "version": "4.7.5", - "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.5.tgz", - "integrity": "sha512-wLD/Aq2VggCJXSjxEwrMafIP51Z+13H78nXIX0ABEuIGhmB5sNGbR113MOKo+yfw+RDo1ZU3DM6yfnnRF/+ouw==", + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz", + "integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==", "dev": true }, "@types/hoist-non-react-statics": { @@ -6872,18 +6872,6 @@ "integrity": "sha512-OrVKYz70LHsnCgmbXctv/bfuvntIKDz177h0Co37DQ5jamGZLVmoCVMtjMtNZY3X9DrCcKfklHPNeA0uPZhSJg==", "dev": true }, - "history": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/history/-/history-4.7.2.tgz", - "integrity": "sha512-1zkBRWW6XweO0NBcjiphtVJVsIQ+SXF29z9DVkceeaSLVMFXHool+fdCZD4spDCfZJCILPILc3bm7Bc+HRi0nA==", - "requires": { - "invariant": "^2.2.1", - "loose-envify": "^1.2.0", - "resolve-pathname": "^2.2.0", - "value-equal": "^0.4.0", - "warning": "^3.0.0" - } - }, "hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -7269,14 +7257,6 @@ "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", "dev": true }, - "invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "requires": { - "loose-envify": "^1.0.0" - } - }, "invert-kv": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", @@ -10616,11 +10596,6 @@ "value-or-function": "^3.0.0" } }, - "resolve-pathname": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-2.2.0.tgz", - "integrity": "sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg==" - }, "resolve-url": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", @@ -12869,11 +12844,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "value-equal": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-0.4.0.tgz", - "integrity": "sha512-x+cYdNnaA3CxvMaTX0INdTCN8m8aF2uY9BvEqmxuYp8bL09cs/kWVQPVGcA35fMktdOsP69IgU7wFj/61dJHEw==" - }, "value-or-function": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-3.0.0.tgz", @@ -12963,14 +12933,6 @@ } } }, - "warning": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz", - "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=", - "requires": { - "loose-envify": "^1.0.0" - } - }, "warning-symbol": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/warning-symbol/-/warning-symbol-0.1.0.tgz", diff --git a/gui/package.json b/gui/package.json index 22593150b3..734b3f8a66 100644 --- a/gui/package.json +++ b/gui/package.json @@ -19,7 +19,6 @@ "electron-log": "^4.1.1", "gettext-parser": "^4.0.3", "google-protobuf": "^4.0.0-rc.2", - "history": "^4.6.1", "linux-app-list": "^1.0.1", "mkdirp": "^1.0.3", "moment": "^2.24.0", @@ -47,6 +46,7 @@ "@types/enzyme-adapter-react-16": "^1.0.3", "@types/gettext-parser": "^4.0.0", "@types/google-protobuf": "^3.7.2", + "@types/history": "^4.7.8", "@types/mkdirp": "^1.0.0", "@types/mocha": "^5.2.6", "@types/node": "^10.12.3", diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index b7c9b1a187..4d7b9e9931 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -5,7 +5,6 @@ import { } from 'connected-react-router'; import { ipcRenderer, shell, webFrame } from 'electron'; import log from 'electron-log'; -import { createMemoryHistory } from 'history'; import * as React from 'react'; import { Provider } from 'react-redux'; import { bindActionCreators } from 'redux'; @@ -29,6 +28,7 @@ import { IpcRendererEventChannel, IRelayListPair } from '../shared/ipc-event-cha import ISplitTunnelingApplication from '../shared/linux-split-tunneling-application'; import { getRendererLogFile, setupLogging } from '../shared/logging'; import consumePromise from '../shared/promise'; +import History from './lib/history'; import { AccountToken, @@ -76,8 +76,8 @@ const SUPPORTED_LOCALE_LIST = [ ]; export default class AppRenderer { - private memoryHistory = createMemoryHistory(); - private reduxStore = configureStore(this.memoryHistory); + private history = new History('/'); + private reduxStore = configureStore(this.history); private reduxActions = { account: bindActionCreators(accountActions, this.reduxStore.dispatch), connection: bindActionCreators(connectionActions, this.reduxStore.dispatch), @@ -229,7 +229,7 @@ export default class AppRenderer { return ( <AppContext.Provider value={{ app: this }}> <Provider store={this.reduxStore}> - <ConnectedRouter history={this.memoryHistory}> + <ConnectedRouter history={this.history}> <ErrorBoundary> <AppRoutes /> </ErrorBoundary> @@ -450,7 +450,7 @@ export default class AppRenderer { private redirectToConnect() { // Redirect the user after some time to allow for the 'Logged in' screen to be visible - this.loginTimer = global.setTimeout(() => this.memoryHistory.replace('/connect'), 1000); + this.loginTimer = global.setTimeout(() => this.history.resetWith('/connect'), 1000); } private loadTranslations(locale: string) { @@ -530,12 +530,12 @@ export default class AppRenderer { this.connectedToDaemon = true; if (this.settings.accountToken) { - this.memoryHistory.replace('/connect'); + this.history.resetWith('/connect'); // try to autoconnect the tunnel await this.autoConnect(); } else { - this.memoryHistory.replace('/login'); + this.history.resetWith('/login'); // show window when account is not set ipcRenderer.send('show-window'); @@ -548,7 +548,7 @@ export default class AppRenderer { this.connectedToDaemon = false; if (error && wasConnected) { - this.memoryHistory.replace('/'); + this.history.resetWith('/'); } } @@ -660,12 +660,12 @@ export default class AppRenderer { clearTimeout(this.loginTimer); } reduxAccount.loggedOut(); - this.memoryHistory.replace('/login'); + this.history.resetWith('/login'); } else if (newAccount && oldAccount !== newAccount && !this.doingLogin) { reduxAccount.updateAccountToken(newAccount); reduxAccount.loggedIn(); if (!oldAccount) { - this.memoryHistory.replace('/connect'); + this.history.resetWith('/connect'); } } diff --git a/gui/src/renderer/lib/history.ts b/gui/src/renderer/lib/history.ts new file mode 100644 index 0000000000..b726e72bbb --- /dev/null +++ b/gui/src/renderer/lib/history.ts @@ -0,0 +1,112 @@ +import { Location, Action, LocationListener, LocationDescriptor } from 'history'; + +// It currently isn't possible to implement this correctly with support for a generic state. State +// can be added as a generic type (<S = unknown>) after this issue has been resolved: +// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/49060 +type S = unknown; +export default class History { + private listeners: LocationListener<S>[] = []; + private entries: Location<S>[]; + private index = 0; + private lastAction: Action = 'POP'; + + public constructor(location: string | Location<S>, state?: S) { + this.entries = [this.createLocation(location, state)]; + } + + public get location(): Location<S> { + return this.entries[this.index]; + } + + public get length(): number { + return this.entries.length; + } + + public get action(): Action { + return this.lastAction; + } + + public push = (nextLocation: LocationDescriptor<S>, nextState?: S) => { + const location = this.createLocation(nextLocation, nextState); + this.lastAction = 'PUSH'; + this.index += 1; + this.entries.splice(this.index, this.entries.length - this.index, location); + this.notify(); + }; + + public replace = (nextLocation: LocationDescriptor<S>, nextState?: S) => { + this.entries[this.index] = this.createLocation(nextLocation, nextState); + this.lastAction = 'REPLACE'; + this.notify(); + }; + + public go = (n: number) => { + if (this.canGo(n)) { + this.index += n; + this.lastAction = 'POP'; + this.notify(); + } + }; + + public goBack = () => this.go(-1); + public goForward = () => this.go(1); + + public reset = () => { + this.lastAction = 'POP'; + this.index = 0; + this.notify(); + }; + + public resetWith = (nextLocation: LocationDescriptor<S>, nextState?: S) => { + this.entries = [this.createLocation(nextLocation, nextState)]; + this.lastAction = 'REPLACE'; + this.index = 0; + this.notify(); + }; + + public canGo(n: number) { + const nextIndex = this.index + n; + return nextIndex >= 0 && nextIndex < this.entries.length; + } + + public listen(callback: LocationListener<S>) { + this.listeners.push(callback); + return () => (this.listeners = this.listeners.filter((listener) => listener !== callback)); + } + + public block(): () => void { + throw Error('Not implemented'); + } + + public createHref(): string { + throw Error('Not implemented'); + } + + private notify() { + this.listeners.forEach((listener) => listener(this.location, this.action)); + } + + private createLocation(location: LocationDescriptor<S>, state?: S): Location<S> { + if (typeof location === 'object') { + return { + pathname: location.pathname ?? this.location.pathname, + search: location.search ?? '', + hash: location.hash ?? '', + state: location.state, + key: location.key ?? this.getRandomKey(), + }; + } else { + return { + pathname: location, + search: '', + hash: '', + state, + key: this.getRandomKey(), + }; + } + } + + private getRandomKey() { + return Math.random().toString(36).substr(8); + } +} diff --git a/gui/src/renderer/redux/store.ts b/gui/src/renderer/redux/store.ts index bedaddf1bd..43ebc312ef 100644 --- a/gui/src/renderer/redux/store.ts +++ b/gui/src/renderer/redux/store.ts @@ -14,7 +14,7 @@ import userInterfaceReducer, { IUserInterfaceReduxState } from './userinterface/ import versionActions, { VersionAction } from './version/actions'; import versionReducer, { IVersionReduxState } from './version/reducers'; -import { History } from 'history'; +import History from '../lib/history'; export interface IReduxState { account: IAccountReduxState; diff --git a/gui/test/history.spec.ts b/gui/test/history.spec.ts new file mode 100644 index 0000000000..1bf00dd4ba --- /dev/null +++ b/gui/test/history.spec.ts @@ -0,0 +1,168 @@ +import { expect, spy } from 'chai'; +import { it, describe, beforeEach } from 'mocha'; +import History from '../src/renderer/lib/history'; + +const BASE_PATH = '/'; +const FIRST_PATH = '/first-path'; +const SECOND_PATH = '/second-path'; +const THIRD_PATH = '/third-path'; +const FOURTH_PATH = '/fourth-path'; +const FIFTH_PATH = '/fifth-path'; +const SIXTH_PATH = '/sixth-path'; + +describe('History', () => { + let history: History; + + beforeEach(() => { + history = new History(BASE_PATH); + history.push(FIRST_PATH); + history.push(SECOND_PATH); + history.push(THIRD_PATH); + history.push(FOURTH_PATH); + }); + + it('should start at the correct location', () => { + const history2 = new History(BASE_PATH); + + expect(history2.location.pathname).to.equal(BASE_PATH); + expect(history2.length).to.equal(1); + expect(history.location.pathname).to.equal(FOURTH_PATH); + expect(history.length).to.equal(5); + }); + + it('should go back', () => { + history.goBack(); + expect(history.location.pathname).to.equal(THIRD_PATH); + expect(history.length).to.equal(5); + }); + + it('should go back three entries', () => { + history.go(-3); + expect(history.location.pathname).to.equal(FIRST_PATH); + expect(history.length).to.equal(5); + }); + + it('should go forward', () => { + history.go(-3); + history.goForward(); + expect(history.location.pathname).to.equal(SECOND_PATH); + expect(history.length).to.equal(5); + }); + + it('should go forward two entries', () => { + history.go(-3); + history.go(2); + expect(history.location.pathname).to.equal(THIRD_PATH); + expect(history.length).to.equal(5); + }); + + it('should fail to go forward', () => { + history.goForward(); + expect(history.location.pathname).to.equal(FOURTH_PATH); + expect(history.length).to.equal(5); + }); + + it('should push', () => { + history.push(FIFTH_PATH); + history.goBack(); + expect(history.location.pathname).to.equal(FOURTH_PATH); + expect(history.length).to.equal(6); + }); + + it('should replace', () => { + history.replace(FIFTH_PATH); + history.goBack(); + expect(history.location.pathname).to.equal(THIRD_PATH); + expect(history.length).to.equal(5); + }); + + it('should fail to go backwards further than base path', () => { + history.go(-5); + expect(history.location.pathname).to.equal(FOURTH_PATH); + expect(history.length).to.equal(5); + }); + + it('should go backward to base path', () => { + history.reset(); + expect(history.location.pathname).to.equal(BASE_PATH); + expect(history.length).to.equal(5); + }); + + it('should reset entries with path', () => { + history.resetWith(THIRD_PATH); + expect(history.location.pathname).to.equal(THIRD_PATH); + expect(history.length).to.equal(1); + + history.goBack(); + expect(history.location.pathname).to.equal(THIRD_PATH); + expect(history.length).to.equal(1); + + history.goForward(); + expect(history.location.pathname).to.equal(THIRD_PATH); + expect(history.length).to.equal(1); + }); + + it('should fail to go forward after navigating', () => { + history.goBack(); + history.push(FIFTH_PATH); + history.goForward(); + expect(history.location.pathname).to.equal(FIFTH_PATH); + + history.goBack(); + history.replace(SIXTH_PATH); + history.goForward(); + expect(history.location.pathname).to.equal(FIFTH_PATH); + }); + + it('should add a listener', () => { + const listenerA = spy(); + history.listen(listenerA); + history.goBack(); + history.goForward(); + + const listenerB = spy(); + history.listen(listenerB); + history.reset(); + history.push(FIRST_PATH); + history.replace(SECOND_PATH); + + expect(listenerA).to.have.been.called.exactly(5); + expect(listenerB).to.have.been.called.exactly(3); + }); + + it('should remove a listener', () => { + const listenerA = spy(); + const removeListenerA = history.listen(listenerA); + history.goBack(); + history.goForward(); + + const listenerB = spy(); + history.listen(listenerB); + history.reset(); + + removeListenerA(); + history.push(FIRST_PATH); + history.replace(SECOND_PATH); + + expect(listenerA).to.have.been.called.exactly(3); + expect(listenerB).to.have.been.called.exactly(3); + }); + + it('should only remove listener once', () => { + const listenerA = spy(); + const removeListenerA = history.listen(listenerA); + history.goBack(); + + const listenerB = spy(); + history.listen(listenerB); + history.goForward(); + + removeListenerA(); + removeListenerA(); + + history.reset(); + + expect(listenerA).to.have.been.called.exactly(2); + expect(listenerB).to.have.been.called.exactly(2); + }); +}); |
