diff --git a/app/scripts/background.js b/app/scripts/background.js index f3c0b52b3..8aa886594 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -1,41 +1,57 @@ const urlUtil = require('url') -const extend = require('xtend') const Dnode = require('dnode') const eos = require('end-of-stream') +const Migrator = require('pojo-migrator') +const migrations = require('./lib/migrations') +const LocalStorageStore = require('./lib/observable/local-storage') const PortStream = require('./lib/port-stream.js') const notification = require('./lib/notifications.js') const messageManager = require('./lib/message-manager') const setupMultiplex = require('./lib/stream-utils.js').setupMultiplex const MetamaskController = require('./metamask-controller') const extension = require('./lib/extension') +const firstTimeState = require('./first-time-state') const STORAGE_KEY = 'metamask-config' const METAMASK_DEBUG = 'GULP_METAMASK_DEBUG' -var popupIsOpen = false +let popupIsOpen = false +// +// State and Persistence +// + +// state persistence + +let dataStore = new LocalStorageStore({ storageKey: STORAGE_KEY }) +// initial state for first time users +if (!dataStore.get()) { + dataStore.put({ meta: { version: 0 }, data: firstTimeState }) +} + +// migrations + +let migrator = new Migrator({ + migrations, + // Data persistence methods + loadData: () => dataStore.get(), + setData: (newState) => dataStore.put(newState), +}) + +// +// MetaMask Controller +// + const controller = new MetamaskController({ // User confirmation callbacks: showUnconfirmedMessage: triggerUi, unlockAccountMessage: triggerUi, showUnapprovedTx: triggerUi, // initial state - initState: loadData(), + initState: migrator.getData(), }) // setup state persistence -controller.store.subscribe(setData) - -const txManager = controller.txManager -function triggerUi () { - if (!popupIsOpen) notification.show() -} -// On first install, open a window to MetaMask website to how-it-works. - -extension.runtime.onInstalled.addListener(function (details) { - if ((details.reason === 'install') && (!METAMASK_DEBUG)) { - extension.tabs.create({url: 'https://metamask.io/#how-it-works'}) - } -}) +controller.store.subscribe((newState) => migrator.saveData(newState)) // // connect to other contexts @@ -94,11 +110,23 @@ function setupControllerConnection (stream) { } // -// plugin badge text +// User Interface setup // -txManager.on('updateBadge', updateBadge) +// popup trigger +function triggerUi () { + if (!popupIsOpen) notification.show() +} +// On first install, open a window to MetaMask website to how-it-works. +extension.runtime.onInstalled.addListener(function (details) { + if ((details.reason === 'install') && (!METAMASK_DEBUG)) { + extension.tabs.create({url: 'https://metamask.io/#how-it-works'}) + } +}) + +// plugin badge text +controller.txManager.on('updateBadge', updateBadge) function updateBadge () { var label = '' var unapprovedTxCount = controller.txManager.unapprovedTxCount @@ -111,33 +139,3 @@ function updateBadge () { extension.browserAction.setBadgeText({ text: label }) extension.browserAction.setBadgeBackgroundColor({ color: '#506F8B' }) } - -// data :: setters/getters - -function loadData () { - let defaultData = { - meta: { - version: 0, - }, - data: { - config: { - provider: { - type: 'testnet', - }, - }, - }, - } - - var persisted - try { - persisted = JSON.parse(window.localStorage[STORAGE_KEY]) - } catch (err) { - persisted = null - } - - return extend(defaultData, persisted) -} - -function setData (data) { - window.localStorage[STORAGE_KEY] = JSON.stringify(data) -} diff --git a/app/scripts/first-time-state.js b/app/scripts/first-time-state.js new file mode 100644 index 000000000..3196981ba --- /dev/null +++ b/app/scripts/first-time-state.js @@ -0,0 +1,11 @@ +// +// The default state of MetaMask +// + +module.exports = { + config: { + provider: { + type: 'testnet', + }, + }, +} \ No newline at end of file diff --git a/app/scripts/lib/config-manager.js b/app/scripts/lib/config-manager.js index 01e6ccc3c..6d7305377 100644 --- a/app/scripts/lib/config-manager.js +++ b/app/scripts/lib/config-manager.js @@ -1,6 +1,4 @@ -const Migrator = require('pojo-migrator') const MetamaskConfig = require('../config.js') -const migrations = require('./migrations') const ethUtil = require('ethereumjs-util') const normalize = require('./sig-util').normalize @@ -20,38 +18,17 @@ function ConfigManager (opts) { // ConfigManager is observable and will emit updates this._subs = [] this.store = opts.store - - /* The migrator exported on the config-manager - * has two methods the user should be concerned with: - * - * getData(), which returns the app-consumable data object - * saveData(), which persists the app-consumable data object. - */ - this.migrator = new Migrator({ - - // Migrations must start at version 1 or later. - // They are objects with a `version` number - // and a `migrate` function. - // - // The `migrate` function receives the previous - // config data format, and returns the new one. - migrations: migrations, - - // Data persistence methods - loadData: () => this.store.get(), - setData: (value) => this.store.put(value), - }) } ConfigManager.prototype.setConfig = function (config) { - var data = this.migrator.getData() + var data = this.store.get() data.config = config this.setData(data) this._emitUpdates(config) } ConfigManager.prototype.getConfig = function () { - var data = this.migrator.getData() + var data = this.store.get() if ('config' in data) { return data.config } else { @@ -94,15 +71,15 @@ ConfigManager.prototype.getProvider = function () { } ConfigManager.prototype.setData = function (data) { - this.migrator.saveData(data) + this.store.put(data) } ConfigManager.prototype.getData = function () { - return this.migrator.getData() + return this.store.get() } ConfigManager.prototype.setWallet = function (wallet) { - var data = this.migrator.getData() + var data = this.store.get() data.wallet = wallet this.setData(data) } @@ -119,11 +96,11 @@ ConfigManager.prototype.getVault = function () { } ConfigManager.prototype.getKeychains = function () { - return this.migrator.getData().keychains || [] + return this.store.get().keychains || [] } ConfigManager.prototype.setKeychains = function (keychains) { - var data = this.migrator.getData() + var data = this.store.get() data.keychains = keychains this.setData(data) } @@ -140,19 +117,19 @@ ConfigManager.prototype.setSelectedAccount = function (address) { } ConfigManager.prototype.getWallet = function () { - return this.migrator.getData().wallet + return this.store.get().wallet } // Takes a boolean ConfigManager.prototype.setShowSeedWords = function (should) { - var data = this.migrator.getData() + var data = this.store.get() data.showSeedWords = should this.setData(data) } ConfigManager.prototype.getShouldShowSeedWords = function () { - var data = this.migrator.getData() + var data = this.store.get() return data.showSeedWords } @@ -187,7 +164,7 @@ ConfigManager.prototype.getCurrentRpcAddress = function () { } ConfigManager.prototype.setData = function (data) { - this.migrator.saveData(data) + this.store.put(data) } // @@ -195,7 +172,7 @@ ConfigManager.prototype.setData = function (data) { // ConfigManager.prototype.getTxList = function () { - var data = this.migrator.getData() + var data = this.store.get() if (data.transactions !== undefined) { return data.transactions } else { @@ -204,7 +181,7 @@ ConfigManager.prototype.getTxList = function () { } ConfigManager.prototype.setTxList = function (txList) { - var data = this.migrator.getData() + var data = this.store.get() data.transactions = txList this.setData(data) } diff --git a/app/scripts/lib/migrations.js b/app/scripts/lib/migrations.js index f026cbe53..12f60def1 100644 --- a/app/scripts/lib/migrations.js +++ b/app/scripts/lib/migrations.js @@ -1,3 +1,16 @@ +/* The migrator has two methods the user should be concerned with: + * + * getData(), which returns the app-consumable data object + * saveData(), which persists the app-consumable data object. + */ + +// Migrations must start at version 1 or later. +// They are objects with a `version` number +// and a `migrate` function. +// +// The `migrate` function receives the previous +// config data format, and returns the new one. + module.exports = [ require('../migrations/002'), require('../migrations/003'), diff --git a/app/scripts/lib/observable/host.js b/app/scripts/lib/observable/host.js index 69f674be8..d1b110503 100644 --- a/app/scripts/lib/observable/host.js +++ b/app/scripts/lib/observable/host.js @@ -12,14 +12,14 @@ class HostStore extends ObservableStore { constructor (initState, opts) { super(initState) - this.opts = opts || {} + this._opts = opts || {} } createStream () { const self = this // setup remotely exposed api let remoteApi = {} - if (!self.opts.readOnly) { + if (!self._opts.readOnly) { remoteApi.put = (newState) => self.put(newState) } // listen for connection to remote diff --git a/app/scripts/lib/observable/index.js b/app/scripts/lib/observable/index.js index d193e5554..1ff112e95 100644 --- a/app/scripts/lib/observable/index.js +++ b/app/scripts/lib/observable/index.js @@ -7,22 +7,30 @@ class ObservableStore extends EventEmitter { this._state = initialState } + // wrapper around internal get get () { return this._state } - + + // wrapper around internal put put (newState) { this._put(newState) } + // subscribe to changes subscribe (handler) { this.on('update', handler) } + // unsubscribe to changes unsubscribe (handler) { this.removeListener('update', handler) } + // + // private + // + _put (newState) { this._state = newState this.emit('update', newState) diff --git a/app/scripts/lib/observable/local-storage.js b/app/scripts/lib/observable/local-storage.js new file mode 100644 index 000000000..6ed3860f6 --- /dev/null +++ b/app/scripts/lib/observable/local-storage.js @@ -0,0 +1,37 @@ +const ObservableStore = require('./index') + +// +// LocalStorageStore +// +// uses localStorage instead of a cache +// + +class LocalStorageStore extends ObservableStore { + + constructor (opts) { + super() + delete this._state + + this._opts = opts || {} + if (!this._opts.storageKey) { + throw new Error('LocalStorageStore - no "storageKey" specified') + } + this._storageKey = this._opts.storageKey + } + + get() { + try { + return JSON.parse(global.localStorage[this._storageKey]) + } catch (err) { + return undefined + } + } + + _put(newState) { + global.localStorage[this._storageKey] = JSON.stringify(newState) + this.emit('update', newState) + } + +} + +module.exports = LocalStorageStore diff --git a/app/scripts/lib/observable/remote.js b/app/scripts/lib/observable/remote.js index b5a3254a2..603f6f0b8 100644 --- a/app/scripts/lib/observable/remote.js +++ b/app/scripts/lib/observable/remote.js @@ -12,7 +12,7 @@ class RemoteStore extends ObservableStore { constructor (initState, opts) { super(initState) - this.opts = opts || {} + this._opts = opts || {} this._remote = null } diff --git a/app/scripts/lib/observable/util/sync.js b/app/scripts/lib/observable/util/sync.js new file mode 100644 index 000000000..c61feb02e --- /dev/null +++ b/app/scripts/lib/observable/util/sync.js @@ -0,0 +1,24 @@ + +// +// synchronizeStore(inStore, outStore, stateTransform) +// +// keeps outStore synchronized with inStore, via an optional stateTransform +// + +module.exports = synchronizeStore + + +function synchronizeStore(inStore, outStore, stateTransform) { + stateTransform = stateTransform || transformNoop + const initState = stateTransform(inStore.get()) + outStore.put(initState) + inStore.subscribe((inState) => { + const outState = stateTransform(inState) + outStore.put(outState) + }) + return outStore +} + +function transformNoop(state) { + return state +} \ No newline at end of file diff --git a/app/scripts/lib/observable/util/transform.js b/app/scripts/lib/observable/util/transform.js deleted file mode 100644 index 87946f402..000000000 --- a/app/scripts/lib/observable/util/transform.js +++ /dev/null @@ -1,13 +0,0 @@ - -module.exports = transformStore - - -function transformStore(inStore, outStore, stateTransform) { - const initState = stateTransform(inStore.get()) - outStore.put(initState) - inStore.subscribe((inState) => { - const outState = stateTransform(inState) - outStore.put(outState) - }) - return outStore -} \ No newline at end of file diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 8e0eaf54c..e15844a56 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -14,7 +14,7 @@ const nodeify = require('./lib/nodeify') const IdStoreMigrator = require('./lib/idStore-migrator') const ObservableStore = require('./lib/observable/') const HostStore = require('./lib/observable/host') -const transformStore = require('./lib/observable/util/transform') +const synchronizeStore = require('./lib/observable/util/sync') const version = require('../manifest.json').version module.exports = class MetamaskController extends EventEmitter { @@ -244,12 +244,12 @@ module.exports = class MetamaskController extends EventEmitter { var publicConfigStore = new HostStore(initPublicState, { readOnly: true }) // sync publicConfigStore with transform - transformStore(this.store, publicConfigStore, selectPublicState) + synchronizeStore(this.store, publicConfigStore, selectPublicState) function selectPublicState(state) { let result = { selectedAccount: undefined } try { - result.selectedAccount = state.data.config.selectedAccount + result.selectedAccount = state.config.selectedAccount } catch (err) { console.warn('Error in "selectPublicState": ' + err.message) } diff --git a/mock-dev.js b/mock-dev.js index dfd0b4961..a404c64b0 100644 --- a/mock-dev.js +++ b/mock-dev.js @@ -12,6 +12,9 @@ * To use, run `npm run mock`. */ +// pollyfill localStorage for non-browser environments +if (!global.localStorage) global.localStorage + const extend = require('xtend') const render = require('react-dom').render const h = require('react-hyperscript') @@ -21,93 +24,62 @@ const actions = require('./ui/app/actions') const states = require('./development/states') const Selector = require('./development/selector') const MetamaskController = require('./app/scripts/metamask-controller') +const firstTimeState = require('./app/scripts/first-time-state') +const LocalStorageStore = require('./app/scripts/lib/observable/local-storage') +const synchronizeStore = require('./app/scripts/lib/observable/util/sync') const extension = require('./development/mockExtension') +const noop = function () {} +const STORAGE_KEY = 'metamask-config' + +// // Query String +// + const qs = require('qs') let queryString = qs.parse(window.location.href.split('#')[1]) let selectedView = queryString.view || 'first time' const firstState = states[selectedView] updateQueryParams(selectedView) -// CSS -const MetaMaskUiCss = require('./ui/css') -const injectCss = require('inject-css') - - function updateQueryParams(newView) { queryString.view = newView const params = qs.stringify(queryString) window.location.href = window.location.href.split('#')[0] + `#${params}` } -const noop = function () {} +// +// CSS +// + +const MetaMaskUiCss = require('./ui/css') +const injectCss = require('inject-css') + +// +// MetaMask Controller +// + +let dataStore = new LocalStorageStore({ storageKey: STORAGE_KEY }) +// initial state for first time users +if (!dataStore.get()) { + dataStore.put(firstTimeState) +} + const controller = new MetamaskController({ // User confirmation callbacks: showUnconfirmedMessage: noop, unlockAccountMessage: noop, showUnapprovedTx: noop, // initial state - initState: loadData(), + initState: dataStore.get(), }) // setup state persistence -controller.store.subscribe(setData) +synchronizeStore(controller.store, dataStore) -// Stub out localStorage for non-browser environments -if (!window.localStorage) { - window.localStorage = {} -} -const STORAGE_KEY = 'metamask-config' -function loadData () { - var oldData = getOldStyleData() - var newData - try { - newData = JSON.parse(window.localStorage[STORAGE_KEY]) - } catch (e) {} - - var data = extend({ - meta: { - version: 0, - }, - data: { - config: { - provider: { - type: 'testnet', - }, - }, - }, - }, oldData || null, newData || null) - return data -} - -function setData (data) { - window.localStorage[STORAGE_KEY] = JSON.stringify(data) -} - -function getOldStyleData () { - var config, wallet, seedWords - - var result = { - meta: { version: 0 }, - data: {}, - } - - try { - config = JSON.parse(window.localStorage['config']) - result.data.config = config - } catch (e) {} - try { - wallet = JSON.parse(window.localStorage['lightwallet']) - result.data.wallet = wallet - } catch (e) {} - try { - seedWords = window.localStorage['seedWords'] - result.data.seedWords = seedWords - } catch (e) {} - - return result -} +// +// User Interface +// actions._setBackgroundConnection(controller.getApi()) actions.update = function(stateName) { diff --git a/test/integration/lib/idStore-migrator-test.js b/test/integration/lib/idStore-migrator-test.js index 1ceaac442..d95cfb401 100644 --- a/test/integration/lib/idStore-migrator-test.js +++ b/test/integration/lib/idStore-migrator-test.js @@ -4,8 +4,8 @@ const IdStoreMigrator = require('../../../app/scripts/lib/idStore-migrator') const SimpleKeyring = require('../../../app/scripts/keyrings/simple') const normalize = require('../../../app/scripts/lib/sig-util').normalize -const oldStyleVault = require('../mocks/oldVault.json') -const badStyleVault = require('../mocks/badVault.json') +const oldStyleVault = require('../mocks/oldVault.json').data +const badStyleVault = require('../mocks/badVault.json').data const PASSWORD = '12345678' const FIRST_ADDRESS = '0x4dd5d356c5A016A220bCD69e82e5AF680a430d00'.toLowerCase() @@ -14,15 +14,10 @@ const SEED = 'fringe damage bounce extend tunnel afraid alert sound all soldier QUnit.module('Old Style Vaults', { beforeEach: function () { - let store = new ObservableStore(oldStyleVault) - - this.configManager = new ConfigManager({ - store: store, - }) - - this.migrator = new IdStoreMigrator({ - configManager: this.configManager, - }) + let managers = managersFromInitState(oldStyleVault) + + this.configManager = managers.configManager + this.migrator = managers.migrator } }) @@ -35,6 +30,7 @@ QUnit.test('migrator:migratedVaultForPassword', function (assert) { this.migrator.migratedVaultForPassword(PASSWORD) .then((result) => { + assert.ok(result, 'migratedVaultForPassword returned result') const { serialized, lostAccounts } = result assert.equal(serialized.data.mnemonic, SEED, 'seed phrase recovered') assert.equal(lostAccounts.length, 0, 'no lost accounts') @@ -44,15 +40,10 @@ QUnit.test('migrator:migratedVaultForPassword', function (assert) { QUnit.module('Old Style Vaults with bad HD seed', { beforeEach: function () { - let store = new ObservableStore(badStyleVault) - - this.configManager = new ConfigManager({ - store: store, - }) - - this.migrator = new IdStoreMigrator({ - configManager: this.configManager, - }) + let managers = managersFromInitState(badStyleVault) + + this.configManager = managers.configManager + this.migrator = managers.migrator } }) @@ -61,6 +52,7 @@ QUnit.test('migrator:migratedVaultForPassword', function (assert) { this.migrator.migratedVaultForPassword(PASSWORD) .then((result) => { + assert.ok(result, 'migratedVaultForPassword returned result') const { serialized, lostAccounts } = result assert.equal(lostAccounts.length, 1, 'one lost account') @@ -86,3 +78,15 @@ QUnit.test('migrator:migratedVaultForPassword', function (assert) { }) }) +function managersFromInitState(initState){ + + let configManager = new ConfigManager({ + store: new ObservableStore(initState), + }) + + let migrator = new IdStoreMigrator({ + configManager: configManager, + }) + + return { configManager, migrator } +} \ No newline at end of file diff --git a/test/lib/mock-config-manager.js b/test/lib/mock-config-manager.js index 0e84aa001..c62d91da9 100644 --- a/test/lib/mock-config-manager.js +++ b/test/lib/mock-config-manager.js @@ -1,61 +1,11 @@ const ConfigManager = require('../../app/scripts/lib/config-manager') -const ObservableStore = require('../../app/scripts/lib/observable/') +const LocalStorageStore = require('../../app/scripts/lib/observable/local-storage') +const firstTimeState = require('../../app/scripts/first-time-state') const STORAGE_KEY = 'metamask-config' -const extend = require('xtend') module.exports = function() { - let store = new ObservableStore(loadData()) - store.subscribe(setData) - return new ConfigManager({ store }) -} - -function loadData () { - var oldData = getOldStyleData() - var newData - - try { - newData = JSON.parse(window.localStorage[STORAGE_KEY]) - } catch (e) {} - - var data = extend({ - meta: { - version: 0, - }, - data: { - config: { - provider: { - type: 'testnet', - }, - }, - }, - }, oldData || null, newData || null) - return data -} - -function getOldStyleData () { - var config, wallet, seedWords - - var result = { - meta: { version: 0 }, - data: {}, - } - - try { - config = JSON.parse(window.localStorage['config']) - result.data.config = config - } catch (e) {} - try { - wallet = JSON.parse(window.localStorage['lightwallet']) - result.data.wallet = wallet - } catch (e) {} - try { - seedWords = window.localStorage['seedWords'] - result.data.seedWords = seedWords - } catch (e) {} - - return result -} - -function setData (data) { - window.localStorage[STORAGE_KEY] = JSON.stringify(data) -} + let dataStore = new LocalStorageStore({ storageKey: STORAGE_KEY }) + // initial state for first time users + if (!dataStore.get()) dataStore.put(firstTimeState) + return new ConfigManager({ store: dataStore }) +} \ No newline at end of file diff --git a/test/unit/config-manager-test.js b/test/unit/config-manager-test.js index 77d431d5f..83b242a8b 100644 --- a/test/unit/config-manager-test.js +++ b/test/unit/config-manager-test.js @@ -1,5 +1,8 @@ // polyfill fetch global.fetch = global.fetch || require('isomorphic-fetch') +// pollyfill localStorage support into JSDom +global.localStorage = global.localStorage || polyfillLocalStorage() + const assert = require('assert') const extend = require('xtend') const rp = require('request-promise') @@ -11,7 +14,7 @@ describe('config-manager', function() { var configManager beforeEach(function() { - window.localStorage = {} // Hacking localStorage support into JSDom + global.localStorage.clear() configManager = configManagerGen() }) @@ -132,7 +135,6 @@ describe('config-manager', function() { }) describe('#setConfig', function() { - window.localStorage = {} // Hacking localStorage support into JSDom it('should set the config key', function () { var testConfig = { @@ -238,3 +240,7 @@ describe('config-manager', function() { }) }) }) + +function polyfillLocalStorage(){ + return Object.create({ clear: function(){ global.localStorage = polyfillLocalStorage() } }) +}