mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-12-23 09:52:26 +01:00
Merge branch 'master' into new-currency-test
This commit is contained in:
commit
cb8856597c
22
CHANGELOG.md
22
CHANGELOG.md
@ -2,12 +2,34 @@
|
||||
|
||||
## Current Master
|
||||
|
||||
- Add ability to export private keys as a file.
|
||||
- Add ability to export seed words as a file.
|
||||
- Changed state logs to a file download than a clipboard copy.
|
||||
- Fix link to support center.
|
||||
|
||||
## 3.10.0 2017-9-11
|
||||
|
||||
- Readded loose keyring label back into the account list.
|
||||
- Remove cryptonator from chrome permissions.
|
||||
- Add info on token contract addresses.
|
||||
- Add validation preventing users from inputting their own addresses as token tracking addresses.
|
||||
- Added button to reject all transactions (thanks to davidp94! https://github.com/davidp94)
|
||||
|
||||
## 3.9.13 2017-9-8
|
||||
|
||||
- Changed the way we initialize the inpage provider to fix a bug affecting some developers.
|
||||
|
||||
## 3.9.12 2017-9-6
|
||||
|
||||
- Fix bug that prevented Web3 1.0 compatibility
|
||||
- Make eth_sign deprecation warning less noisy
|
||||
- Add useful link to eth_sign deprecation warning.
|
||||
- Fix bug with network version serialization over synchronous RPC
|
||||
- Add MetaMask version to state logs.
|
||||
- Add the total amount of tokens when multiple tokens are added under the token list
|
||||
- Use HTTPS links for Etherscan.
|
||||
- Update Support center link to new one with HTTPS.
|
||||
- Make web3 deprecation notice more useful by linking to a descriptive article.
|
||||
|
||||
## 3.9.11 2017-8-24
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "MetaMask",
|
||||
"short_name": "Metamask",
|
||||
"version": "3.9.11",
|
||||
"version": "3.10.0",
|
||||
"manifest_version": 2,
|
||||
"author": "https://metamask.io",
|
||||
"description": "Ethereum Browser Extension",
|
||||
@ -57,8 +57,7 @@
|
||||
"permissions": [
|
||||
"storage",
|
||||
"clipboardWrite",
|
||||
"http://localhost:8545/",
|
||||
"https://api.cryptonator.com/"
|
||||
"http://localhost:8545/"
|
||||
],
|
||||
"web_accessible_resources": [
|
||||
"scripts/inpage.js"
|
||||
|
@ -171,9 +171,9 @@ class KeyringController extends EventEmitter {
|
||||
return this.setupAccounts(checkedAccounts)
|
||||
})
|
||||
.then(() => this.persistAllKeyrings())
|
||||
.then(() => this._updateMemStoreKeyrings())
|
||||
.then(() => this.fullUpdate())
|
||||
.then(() => {
|
||||
this._updateMemStoreKeyrings()
|
||||
return keyring
|
||||
})
|
||||
}
|
||||
@ -208,6 +208,7 @@ class KeyringController extends EventEmitter {
|
||||
return selectedKeyring.addAccounts(1)
|
||||
.then(this.setupAccounts.bind(this))
|
||||
.then(this.persistAllKeyrings.bind(this))
|
||||
.then(this._updateMemStoreKeyrings.bind(this))
|
||||
.then(this.fullUpdate.bind(this))
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,7 @@ function setupDappAutoReload (web3, observable) {
|
||||
get: (_web3, key) => {
|
||||
// show warning once on web3 access
|
||||
if (!hasBeenWarned && key !== 'currentProvider') {
|
||||
console.warn('MetaMask: web3 will be deprecated in the near future in favor of the ethereumProvider \nhttps://github.com/ethereum/mist/releases/tag/v0.9.0')
|
||||
console.warn('MetaMask: web3 will be deprecated in the near future in favor of the ethereumProvider \nhttps://github.com/MetaMask/faq/blob/master/detecting_metamask.md#web3-deprecation')
|
||||
hasBeenWarned = true
|
||||
}
|
||||
// get the time of use
|
||||
|
@ -40,30 +40,37 @@ function MetamaskInpageProvider (connectionStream) {
|
||||
// start and stop polling to unblock first block lock
|
||||
|
||||
self.idMap = {}
|
||||
// handle sendAsync requests via asyncProvider
|
||||
self.sendAsync = function (payload, cb) {
|
||||
}
|
||||
|
||||
// handle sendAsync requests via asyncProvider
|
||||
// also remap ids inbound and outbound
|
||||
MetamaskInpageProvider.prototype.sendAsync = function (payload, cb) {
|
||||
const self = this
|
||||
|
||||
// rewrite request ids
|
||||
var request = eachJsonMessage(payload, (message) => {
|
||||
var newId = createRandomId()
|
||||
const request = eachJsonMessage(payload, (_message) => {
|
||||
const message = Object.assign({}, _message)
|
||||
const newId = createRandomId()
|
||||
self.idMap[newId] = message.id
|
||||
message.id = newId
|
||||
return message
|
||||
})
|
||||
|
||||
// forward to asyncProvider
|
||||
asyncProvider.sendAsync(request, function (err, res) {
|
||||
self.asyncProvider.sendAsync(request, (err, _res) => {
|
||||
if (err) return cb(err)
|
||||
// transform messages to original ids
|
||||
eachJsonMessage(res, (message) => {
|
||||
var oldId = self.idMap[message.id]
|
||||
const res = eachJsonMessage(_res, (message) => {
|
||||
const oldId = self.idMap[message.id]
|
||||
delete self.idMap[message.id]
|
||||
message.id = oldId
|
||||
return message
|
||||
})
|
||||
cb(null, res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
MetamaskInpageProvider.prototype.send = function (payload) {
|
||||
const self = this
|
||||
|
||||
@ -109,10 +116,6 @@ MetamaskInpageProvider.prototype.send = function (payload) {
|
||||
}
|
||||
}
|
||||
|
||||
MetamaskInpageProvider.prototype.sendAsync = function () {
|
||||
throw new Error('MetamaskInpageProvider - sendAsync not overwritten')
|
||||
}
|
||||
|
||||
MetamaskInpageProvider.prototype.isConnected = function () {
|
||||
return true
|
||||
}
|
||||
|
15
circle.yml
15
circle.yml
@ -1,10 +1,17 @@
|
||||
machine:
|
||||
node:
|
||||
version: 8.1.4
|
||||
dependencies:
|
||||
pre:
|
||||
- "npm i -g testem"
|
||||
- "npm i -g mocha"
|
||||
test:
|
||||
override:
|
||||
- "npm run ci"
|
||||
dependencies:
|
||||
pre:
|
||||
- sudo apt-get update
|
||||
# get latest stable firefox
|
||||
- sudo apt-get install firefox
|
||||
- firefox_cmd=`which firefox`; sudo rm -f $firefox_cmd; sudo ln -s `which firefox.ubuntu` $firefox_cmd
|
||||
# get latest stable chrome
|
||||
- wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
|
||||
- sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
|
||||
- sudo apt-get update
|
||||
- sudo apt-get install google-chrome-stable
|
@ -1,7 +1,7 @@
|
||||
const createStore = require('redux').createStore
|
||||
const applyMiddleware = require('redux').applyMiddleware
|
||||
const thunkMiddleware = require('redux-thunk')
|
||||
const createLogger = require('redux-logger')
|
||||
const thunkMiddleware = require('redux-thunk').default
|
||||
const createLogger = require('redux-logger').createLogger
|
||||
const rootReducer = require('../ui/app/reducers')
|
||||
|
||||
module.exports = configureStore
|
||||
|
61
karma.conf.js
Normal file
61
karma.conf.js
Normal file
@ -0,0 +1,61 @@
|
||||
// Karma configuration
|
||||
// Generated on Mon Sep 11 2017 18:45:48 GMT-0700 (PDT)
|
||||
|
||||
module.exports = function(config) {
|
||||
config.set({
|
||||
// base path that will be used to resolve all patterns (eg. files, exclude)
|
||||
basePath: process.cwd(),
|
||||
|
||||
browserConsoleLogOptions: {
|
||||
terminal: false,
|
||||
},
|
||||
|
||||
// frameworks to use
|
||||
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
|
||||
frameworks: ['qunit'],
|
||||
|
||||
// list of files / patterns to load in the browser
|
||||
files: [
|
||||
'development/bundle.js',
|
||||
'test/integration/jquery-3.1.0.min.js',
|
||||
'test/integration/bundle.js',
|
||||
{ pattern: 'dist/chrome/images/**/*.*', watched: false, included: false, served: true },
|
||||
{ pattern: 'dist/chrome/fonts/**/*.*', watched: false, included: false, served: true },
|
||||
],
|
||||
|
||||
proxies: {
|
||||
'/images/': '/base/dist/chrome/images/',
|
||||
'/fonts/': '/base/dist/chrome/fonts/',
|
||||
},
|
||||
|
||||
// test results reporter to use
|
||||
// possible values: 'dots', 'progress'
|
||||
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
|
||||
reporters: ['progress'],
|
||||
|
||||
// web server port
|
||||
port: 9876,
|
||||
|
||||
// enable / disable colors in the output (reporters and logs)
|
||||
colors: true,
|
||||
|
||||
// level of logging
|
||||
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
|
||||
logLevel: config.LOG_INFO,
|
||||
|
||||
// enable / disable watching file and executing tests whenever any file changes
|
||||
autoWatch: false,
|
||||
|
||||
// start these browsers
|
||||
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
|
||||
browsers: ['Chrome', 'Firefox'],
|
||||
|
||||
// Continuous Integration mode
|
||||
// if true, Karma captures browsers, runs the tests and exits
|
||||
singleRun: true,
|
||||
|
||||
// Concurrency level
|
||||
// how many browser should be started simultaneous
|
||||
concurrency: Infinity
|
||||
})
|
||||
}
|
17
mock-dev.js
17
mock-dev.js
@ -85,13 +85,20 @@ actions.update = function(stateName) {
|
||||
var css = MetaMaskUiCss()
|
||||
injectCss(css)
|
||||
|
||||
const container = document.querySelector('#app-content')
|
||||
|
||||
// parse opts
|
||||
var store = configureStore(firstState)
|
||||
|
||||
// start app
|
||||
render(
|
||||
startApp()
|
||||
|
||||
function startApp(){
|
||||
const body = document.body
|
||||
const container = document.createElement('div')
|
||||
container.id = 'app-content'
|
||||
body.appendChild(container)
|
||||
console.log('container', container)
|
||||
|
||||
render(
|
||||
h('.super-dev-container', [
|
||||
|
||||
h('button', {
|
||||
@ -120,5 +127,5 @@ render(
|
||||
]),
|
||||
|
||||
]
|
||||
), container)
|
||||
|
||||
), container)
|
||||
}
|
||||
|
12
package.json
12
package.json
@ -12,8 +12,8 @@
|
||||
"test": "npm run lint && npm run test-unit && npm run test-integration",
|
||||
"test-unit": "METAMASK_ENV=test mocha --require test/helper.js --recursive \"test/unit/**/*.js\"",
|
||||
"single-test": "METAMASK_ENV=test mocha --require test/helper.js",
|
||||
"test-integration": "npm run buildMock && npm run buildCiUnits && testem ci -P 2",
|
||||
"test-coverage": "nyc npm run test-unit && nyc report --reporter=text-lcov | coveralls",
|
||||
"test-integration": "npm run buildMock && npm run buildCiUnits && karma start",
|
||||
"test-coverage": "nyc npm run test-unit && if [ $COVERALLS_REPO_TOKEN ]; then nyc report --reporter=text-lcov | coveralls; fi",
|
||||
"ci": "npm run lint && npm run test-coverage && npm run test-integration",
|
||||
"lint": "gulp lint",
|
||||
"buildCiUnits": "node test/integration/index.js",
|
||||
@ -22,7 +22,6 @@
|
||||
"ui": "npm run genStates && beefy ui-dev.js:bundle.js --live --open --index=./development/index.html --cwd ./",
|
||||
"mock": "beefy mock-dev.js:bundle.js --live --open --index=./development/index.html --cwd ./",
|
||||
"buildMock": "npm run genStates && browserify ./mock-dev.js -o ./development/bundle.js",
|
||||
"testem": "npm run buildMock && testem",
|
||||
"announce": "node development/announcer.js",
|
||||
"generateNotice": "node notices/notice-generator.js",
|
||||
"deleteNotice": "node notices/notice-delete.js",
|
||||
@ -138,7 +137,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.24.1",
|
||||
"babel-eslint": "^7.2.3",
|
||||
"babel-eslint": "^8.0.0",
|
||||
"babel-plugin-transform-async-to-generator": "^6.24.1",
|
||||
"babel-plugin-transform-runtime": "^6.23.0",
|
||||
"babel-polyfill": "^6.23.0",
|
||||
@ -172,6 +171,11 @@
|
||||
"jsdom-global": "^3.0.2",
|
||||
"jshint-stylish": "~2.2.1",
|
||||
"json-rpc-engine": "^3.0.1",
|
||||
"karma": "^1.7.1",
|
||||
"karma-chrome-launcher": "^2.2.0",
|
||||
"karma-cli": "^1.0.1",
|
||||
"karma-firefox-launcher": "^1.0.1",
|
||||
"karma-qunit": "^1.2.1",
|
||||
"lodash.assign": "^4.0.6",
|
||||
"mocha": "^3.4.2",
|
||||
"mocha-eslint": "^4.0.0",
|
||||
|
@ -1,7 +0,0 @@
|
||||
function wait(time) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
setTimeout(function () {
|
||||
resolve()
|
||||
}, time * 3 || 1500)
|
||||
})
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const pump = require('pump')
|
||||
const browserify = require('browserify')
|
||||
const tests = fs.readdirSync(path.join(__dirname, 'lib'))
|
||||
const bundlePath = path.join(__dirname, 'bundle.js')
|
||||
@ -9,11 +10,17 @@ const b = browserify()
|
||||
const writeStream = fs.createWriteStream(bundlePath)
|
||||
|
||||
tests.forEach(function (fileName) {
|
||||
b.add(path.join(__dirname, 'lib', fileName))
|
||||
const filePath = path.join(__dirname, 'lib', fileName)
|
||||
console.log(`bundling test "${filePath}"`)
|
||||
b.add(filePath)
|
||||
})
|
||||
|
||||
b.bundle()
|
||||
.pipe(writeStream)
|
||||
.on('error', (err) => {
|
||||
throw err
|
||||
})
|
||||
pump(
|
||||
b.bundle(),
|
||||
writeStream,
|
||||
(err) => {
|
||||
if (err) throw err
|
||||
console.log(`Integration test build completed: "${bundlePath}"`)
|
||||
process.exit(0)
|
||||
}
|
||||
)
|
@ -2,125 +2,137 @@ const PASSWORD = 'password123'
|
||||
|
||||
QUnit.module('first time usage')
|
||||
|
||||
QUnit.test('render init screen', function (assert) {
|
||||
var done = assert.async()
|
||||
let app
|
||||
|
||||
wait().then(function() {
|
||||
app = $('iframe').contents().find('#app-content .mock-app-root')
|
||||
|
||||
const recurseNotices = function () {
|
||||
let button = app.find('button')
|
||||
if (button.html() === 'Accept') {
|
||||
let termsPage = app.find('.markdown')[0]
|
||||
termsPage.scrollTop = termsPage.scrollHeight
|
||||
return wait().then(() => {
|
||||
button.click()
|
||||
return wait()
|
||||
}).then(() => {
|
||||
return recurseNotices()
|
||||
})
|
||||
} else {
|
||||
return wait()
|
||||
}
|
||||
}
|
||||
return recurseNotices()
|
||||
}).then(function() {
|
||||
// Scroll through terms
|
||||
var title = app.find('h1').text()
|
||||
assert.equal(title, 'MetaMask', 'title screen')
|
||||
|
||||
// enter password
|
||||
var pwBox = app.find('#password-box')[0]
|
||||
var confBox = app.find('#password-box-confirm')[0]
|
||||
pwBox.value = PASSWORD
|
||||
confBox.value = PASSWORD
|
||||
|
||||
return wait()
|
||||
}).then(function() {
|
||||
|
||||
// create vault
|
||||
var createButton = app.find('button.primary')[0]
|
||||
createButton.click()
|
||||
|
||||
return wait(1500)
|
||||
}).then(function() {
|
||||
|
||||
var created = app.find('h3')[0]
|
||||
assert.equal(created.textContent, 'Vault Created', 'Vault created screen')
|
||||
|
||||
// Agree button
|
||||
var button = app.find('button')[0]
|
||||
assert.ok(button, 'button present')
|
||||
button.click()
|
||||
|
||||
return wait(1000)
|
||||
}).then(function() {
|
||||
|
||||
var detail = app.find('.account-detail-section')[0]
|
||||
assert.ok(detail, 'Account detail section loaded.')
|
||||
|
||||
var sandwich = app.find('.sandwich-expando')[0]
|
||||
sandwich.click()
|
||||
|
||||
return wait()
|
||||
}).then(function() {
|
||||
|
||||
var sandwich = app.find('.menu-droppo')[0]
|
||||
var children = sandwich.children
|
||||
var lock = children[children.length - 2]
|
||||
assert.ok(lock, 'Lock menu item found')
|
||||
lock.click()
|
||||
|
||||
return wait(1000)
|
||||
}).then(function() {
|
||||
|
||||
var pwBox = app.find('#password-box')[0]
|
||||
pwBox.value = PASSWORD
|
||||
|
||||
var createButton = app.find('button.primary')[0]
|
||||
createButton.click()
|
||||
|
||||
return wait(1000)
|
||||
}).then(function() {
|
||||
|
||||
var detail = app.find('.account-detail-section')[0]
|
||||
assert.ok(detail, 'Account detail section loaded again.')
|
||||
|
||||
return wait()
|
||||
}).then(function (){
|
||||
|
||||
var qrButton = app.find('.fa.fa-ellipsis-h')[0] // open account settings dropdown
|
||||
qrButton.click()
|
||||
|
||||
return wait(1000)
|
||||
}).then(function (){
|
||||
|
||||
var qrButton = app.find('.dropdown-menu-item')[1] // qr code item
|
||||
qrButton.click()
|
||||
|
||||
return wait(1000)
|
||||
}).then(function (){
|
||||
|
||||
var qrHeader = app.find('.qr-header')[0]
|
||||
var qrContainer = app.find('#qr-container')[0]
|
||||
assert.equal(qrHeader.textContent, 'Account 1', 'Should show account label.')
|
||||
assert.ok(qrContainer, 'QR Container found')
|
||||
|
||||
return wait()
|
||||
}).then(function (){
|
||||
|
||||
var networkMenu = app.find('.network-indicator')[0]
|
||||
networkMenu.click()
|
||||
|
||||
return wait()
|
||||
}).then(function (){
|
||||
|
||||
var networkMenu = app.find('.network-indicator')[0]
|
||||
var children = networkMenu.children
|
||||
children.length[3]
|
||||
assert.ok(children, 'All network options present')
|
||||
|
||||
QUnit.test('render init screen', (assert) => {
|
||||
const done = assert.async()
|
||||
runFirstTimeUsageTest(assert).then(done).catch((err) => {
|
||||
assert.notOk(err, `Error was thrown: ${err.stack}`)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
// QUnit.testDone(({ module, name, total, passed, failed, skipped, todo, runtime }) => {
|
||||
// if (failed > 0) {
|
||||
// const app = $('iframe').contents()[0].documentElement
|
||||
// console.warn('Test failures - dumping DOM:')
|
||||
// console.log(app.innerHTML)
|
||||
// }
|
||||
// })
|
||||
|
||||
async function runFirstTimeUsageTest(assert, done) {
|
||||
|
||||
await timeout()
|
||||
|
||||
const app = $('#app-content .mock-app-root')
|
||||
|
||||
// recurse notices
|
||||
while (true) {
|
||||
const button = app.find('button')
|
||||
if (button.html() === 'Accept') {
|
||||
// still notices to accept
|
||||
const termsPage = app.find('.markdown')[0]
|
||||
termsPage.scrollTop = termsPage.scrollHeight
|
||||
await timeout()
|
||||
button.click()
|
||||
await timeout()
|
||||
} else {
|
||||
// exit loop
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await timeout()
|
||||
|
||||
// Scroll through terms
|
||||
const title = app.find('h1').text()
|
||||
assert.equal(title, 'MetaMask', 'title screen')
|
||||
|
||||
// enter password
|
||||
const pwBox = app.find('#password-box')[0]
|
||||
const confBox = app.find('#password-box-confirm')[0]
|
||||
pwBox.value = PASSWORD
|
||||
confBox.value = PASSWORD
|
||||
|
||||
await timeout()
|
||||
|
||||
// create vault
|
||||
const createButton = app.find('button.primary')[0]
|
||||
createButton.click()
|
||||
|
||||
await timeout(1500)
|
||||
|
||||
const created = app.find('h3')[0]
|
||||
assert.equal(created.textContent, 'Vault Created', 'Vault created screen')
|
||||
|
||||
// Agree button
|
||||
const button = app.find('button')[0]
|
||||
assert.ok(button, 'button present')
|
||||
button.click()
|
||||
|
||||
await timeout(1000)
|
||||
|
||||
const detail = app.find('.account-detail-section')[0]
|
||||
assert.ok(detail, 'Account detail section loaded.')
|
||||
|
||||
const sandwich = app.find('.sandwich-expando')[0]
|
||||
sandwich.click()
|
||||
|
||||
await timeout()
|
||||
|
||||
const menu = app.find('.menu-droppo')[0]
|
||||
const children = menu.children
|
||||
const lock = children[children.length - 2]
|
||||
assert.ok(lock, 'Lock menu item found')
|
||||
lock.click()
|
||||
|
||||
await timeout(1000)
|
||||
|
||||
const pwBox2 = app.find('#password-box')[0]
|
||||
pwBox2.value = PASSWORD
|
||||
|
||||
const createButton2 = app.find('button.primary')[0]
|
||||
createButton2.click()
|
||||
|
||||
await timeout(1000)
|
||||
|
||||
const detail2 = app.find('.account-detail-section')[0]
|
||||
assert.ok(detail2, 'Account detail section loaded again.')
|
||||
|
||||
await timeout()
|
||||
|
||||
// open account settings dropdown
|
||||
const qrButton = app.find('.fa.fa-ellipsis-h')[0]
|
||||
qrButton.click()
|
||||
|
||||
await timeout(1000)
|
||||
|
||||
// qr code item
|
||||
const qrButton2 = app.find('.dropdown-menu-item')[1]
|
||||
qrButton2.click()
|
||||
|
||||
await timeout(1000)
|
||||
|
||||
const qrHeader = app.find('.qr-header')[0]
|
||||
const qrContainer = app.find('#qr-container')[0]
|
||||
assert.equal(qrHeader.textContent, 'Account 1', 'Should show account label.')
|
||||
assert.ok(qrContainer, 'QR Container found')
|
||||
|
||||
await timeout()
|
||||
|
||||
const networkMenu = app.find('.network-indicator')[0]
|
||||
networkMenu.click()
|
||||
|
||||
await timeout()
|
||||
|
||||
const networkMenu2 = app.find('.network-indicator')[0]
|
||||
const children2 = networkMenu2.children
|
||||
children2.length[3]
|
||||
assert.ok(children2, 'All network options present')
|
||||
}
|
||||
|
||||
function timeout(time) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
setTimeout(function () {
|
||||
resolve()
|
||||
}, time * 3 || 1500)
|
||||
})
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
const createStore = require('redux').createStore
|
||||
const applyMiddleware = require('redux').applyMiddleware
|
||||
const thunkMiddleware = require('redux-thunk')
|
||||
const createLogger = require('redux-logger')
|
||||
const thunkMiddleware = require('redux-thunk').default
|
||||
const createLogger = require('redux-logger').createLogger
|
||||
const rootReducer = function () {}
|
||||
|
||||
module.exports = configureStore
|
||||
|
@ -162,6 +162,25 @@ describe('Nonce Tracker', function () {
|
||||
await nonceLock.releaseLock()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Faq issue 67', function () {
|
||||
beforeEach(function () {
|
||||
const txGen = new MockTxGen()
|
||||
const confirmedTxs = txGen.generate({ status: 'confirmed' }, { count: 64 })
|
||||
const pendingTxs = txGen.generate({
|
||||
status: 'submitted',
|
||||
}, { count: 10 })
|
||||
// 0x40 is 64 in hex:
|
||||
nonceTracker = generateNonceTrackerWith(pendingTxs, [], '0x40')
|
||||
})
|
||||
|
||||
it('should return nonce after network nonce', async function () {
|
||||
this.timeout(15000)
|
||||
const nonceLock = await nonceTracker.getNonceLock('0x7d3517b0d011698406d6e0aed8453f0be2697926')
|
||||
assert.equal(nonceLock.nextNonce, '74', `nonce should be 74 got ${nonceLock.nextNonce}`)
|
||||
await nonceLock.releaseLock()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
10
testem.yml
10
testem.yml
@ -1,10 +0,0 @@
|
||||
launch_in_dev:
|
||||
- Chrome
|
||||
- Firefox
|
||||
launch_in_ci:
|
||||
- Chrome
|
||||
- Firefox
|
||||
framework:
|
||||
- qunit
|
||||
before_tests: "npm run buildCiUnits"
|
||||
test_page: "test/integration/index.html"
|
@ -104,6 +104,7 @@ var actions = {
|
||||
txError: txError,
|
||||
nextTx: nextTx,
|
||||
previousTx: previousTx,
|
||||
cancelAllTx: cancelAllTx,
|
||||
viewPendingTx: viewPendingTx,
|
||||
VIEW_PENDING_TX: 'VIEW_PENDING_TX',
|
||||
// app messages
|
||||
@ -457,6 +458,16 @@ function cancelTx (txData) {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAllTx (txsData) {
|
||||
return (dispatch) => {
|
||||
txsData.forEach((txData, i) => {
|
||||
background.cancelTransaction(txData.id, () => {
|
||||
dispatch(actions.completedTx(txData.id))
|
||||
i === txsData.length - 1 ? dispatch(actions.goHome()) : null
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
//
|
||||
// initialize screen
|
||||
//
|
||||
|
@ -3,6 +3,8 @@ const Component = require('react').Component
|
||||
const h = require('react-hyperscript')
|
||||
const connect = require('react-redux').connect
|
||||
const actions = require('./actions')
|
||||
const Tooltip = require('./components/tooltip.js')
|
||||
|
||||
|
||||
const ethUtil = require('ethereumjs-util')
|
||||
const abi = require('human-standard-token-abi')
|
||||
@ -15,6 +17,7 @@ module.exports = connect(mapStateToProps)(AddTokenScreen)
|
||||
|
||||
function mapStateToProps (state) {
|
||||
return {
|
||||
identities: state.metamask.identities,
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,15 +67,25 @@ AddTokenScreen.prototype.render = function () {
|
||||
}, [
|
||||
|
||||
h('div', [
|
||||
h('span', {
|
||||
h(Tooltip, {
|
||||
position: 'top',
|
||||
title: 'The contract of the actual token contract. Click for more info.',
|
||||
}, [
|
||||
h('a', {
|
||||
style: { fontWeight: 'bold', paddingRight: '10px'},
|
||||
}, 'Token Address'),
|
||||
href: 'https://consensyssupport.happyfox.com/staff/kb/article/24-what-is-a-token-contract-address',
|
||||
target: '_blank',
|
||||
}, [
|
||||
h('span', 'Token Contract Address '),
|
||||
h('i.fa.fa-question-circle'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
h('section.flex-row.flex-center', [
|
||||
h('input#token-address', {
|
||||
name: 'address',
|
||||
placeholder: 'Token Address',
|
||||
placeholder: 'Token Contract Address',
|
||||
onChange: this.tokenAddressDidChange.bind(this),
|
||||
style: {
|
||||
width: 'inherit',
|
||||
@ -171,7 +184,9 @@ AddTokenScreen.prototype.tokenAddressDidChange = function (event) {
|
||||
AddTokenScreen.prototype.validateInputs = function () {
|
||||
let msg = ''
|
||||
const state = this.state
|
||||
const identitiesList = Object.keys(this.props.identities)
|
||||
const { address, symbol, decimals } = state
|
||||
const standardAddress = ethUtil.addHexPrefix(address).toLowerCase()
|
||||
|
||||
const validAddress = ethUtil.isValidAddress(address)
|
||||
if (!validAddress) {
|
||||
@ -189,7 +204,12 @@ AddTokenScreen.prototype.validateInputs = function () {
|
||||
msg += 'Symbol must be between 0 and 10 characters.'
|
||||
}
|
||||
|
||||
const isValid = validAddress && validDecimals
|
||||
const ownAddress = identitiesList.includes(standardAddress)
|
||||
if (ownAddress) {
|
||||
msg = 'Personal address detected. Input the token contract address.'
|
||||
}
|
||||
|
||||
const isValid = validAddress && validDecimals && !ownAddress
|
||||
|
||||
if (!isValid) {
|
||||
this.setState({
|
||||
@ -216,4 +236,3 @@ AddTokenScreen.prototype.attemptToAutoFillTokenParams = async function (address)
|
||||
this.setState({ symbol: symbol[0], decimals: decimals[0].toString() })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -42,6 +42,7 @@ function mapStateToProps (state) {
|
||||
identities,
|
||||
accounts,
|
||||
address,
|
||||
keyrings,
|
||||
} = state.metamask
|
||||
const selected = address || Object.keys(accounts)[0]
|
||||
|
||||
@ -69,6 +70,7 @@ function mapStateToProps (state) {
|
||||
// state needed to get account dropdown temporarily rendering from app bar
|
||||
identities,
|
||||
selected,
|
||||
keyrings,
|
||||
}
|
||||
}
|
||||
|
||||
@ -187,6 +189,7 @@ App.prototype.renderAppBar = function () {
|
||||
identities: this.props.identities,
|
||||
selected: this.props.currentView.context,
|
||||
network: this.props.network,
|
||||
keyrings: this.props.keyrings,
|
||||
}, []),
|
||||
|
||||
// hamburger
|
||||
|
@ -22,12 +22,19 @@ class AccountDropdowns extends Component {
|
||||
}
|
||||
|
||||
renderAccounts () {
|
||||
const { identities, selected } = this.props
|
||||
const { identities, selected, keyrings } = this.props
|
||||
|
||||
return Object.keys(identities).map((key, index) => {
|
||||
const identity = identities[key]
|
||||
const isSelected = identity.address === selected
|
||||
|
||||
const simpleAddress = identity.address.substring(2).toLowerCase()
|
||||
|
||||
const keyring = keyrings.find((kr) => {
|
||||
return kr.accounts.includes(simpleAddress) ||
|
||||
kr.accounts.includes(identity.address)
|
||||
})
|
||||
|
||||
return h(
|
||||
DropdownMenuItem,
|
||||
{
|
||||
@ -51,6 +58,7 @@ class AccountDropdowns extends Component {
|
||||
},
|
||||
},
|
||||
),
|
||||
this.indicateIfLoose(keyring),
|
||||
h('span', {
|
||||
style: {
|
||||
marginLeft: '20px',
|
||||
@ -67,6 +75,14 @@ class AccountDropdowns extends Component {
|
||||
})
|
||||
}
|
||||
|
||||
indicateIfLoose (keyring) {
|
||||
try { // Sometimes keyrings aren't loaded yet:
|
||||
const type = keyring.type
|
||||
const isLoose = type !== 'HD Key Tree'
|
||||
return isLoose ? h('.keyring-label', 'LOOSE') : null
|
||||
} catch (e) { return }
|
||||
}
|
||||
|
||||
renderAccountSelector () {
|
||||
const { actions } = this.props
|
||||
const { accountSelectorActive } = this.state
|
||||
@ -145,6 +161,8 @@ class AccountDropdowns extends Component {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
renderAccountOptions () {
|
||||
const { actions } = this.props
|
||||
const { optionsMenuActive } = this.state
|
||||
@ -278,6 +296,7 @@ AccountDropdowns.defaultProps = {
|
||||
AccountDropdowns.propTypes = {
|
||||
identities: PropTypes.objectOf(PropTypes.object),
|
||||
selected: PropTypes.string,
|
||||
keyrings: PropTypes.array,
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch) => {
|
||||
|
@ -1,6 +1,7 @@
|
||||
const Component = require('react').Component
|
||||
const h = require('react-hyperscript')
|
||||
const inherits = require('util').inherits
|
||||
const exportAsFile = require('../util').exportAsFile
|
||||
const copyToClipboard = require('copy-to-clipboard')
|
||||
const actions = require('../actions')
|
||||
const ethUtil = require('ethereumjs-util')
|
||||
@ -20,20 +21,21 @@ function mapStateToProps (state) {
|
||||
}
|
||||
|
||||
ExportAccountView.prototype.render = function () {
|
||||
var state = this.props
|
||||
var accountDetail = state.accountDetail
|
||||
const state = this.props
|
||||
const accountDetail = state.accountDetail
|
||||
const nickname = state.identities[state.address].name
|
||||
|
||||
if (!accountDetail) return h('div')
|
||||
var accountExport = accountDetail.accountExport
|
||||
const accountExport = accountDetail.accountExport
|
||||
|
||||
var notExporting = accountExport === 'none'
|
||||
var exportRequested = accountExport === 'requested'
|
||||
var accountExported = accountExport === 'completed'
|
||||
const notExporting = accountExport === 'none'
|
||||
const exportRequested = accountExport === 'requested'
|
||||
const accountExported = accountExport === 'completed'
|
||||
|
||||
if (notExporting) return h('div')
|
||||
|
||||
if (exportRequested) {
|
||||
var warning = `Export private keys at your own risk.`
|
||||
const warning = `Export private keys at your own risk.`
|
||||
return (
|
||||
h('div', {
|
||||
style: {
|
||||
@ -89,6 +91,8 @@ ExportAccountView.prototype.render = function () {
|
||||
}
|
||||
|
||||
if (accountExported) {
|
||||
const plainKey = ethUtil.stripHexPrefix(accountDetail.privateKey)
|
||||
|
||||
return h('div.privateKey', {
|
||||
style: {
|
||||
margin: '0 20px',
|
||||
@ -105,10 +109,16 @@ ExportAccountView.prototype.render = function () {
|
||||
onClick: function (event) {
|
||||
copyToClipboard(ethUtil.stripHexPrefix(accountDetail.privateKey))
|
||||
},
|
||||
}, ethUtil.stripHexPrefix(accountDetail.privateKey)),
|
||||
}, plainKey),
|
||||
h('button', {
|
||||
onClick: () => this.props.dispatch(actions.backToAccountDetail(this.props.address)),
|
||||
}, 'Done'),
|
||||
h('button', {
|
||||
style: {
|
||||
marginLeft: '10px',
|
||||
},
|
||||
onClick: () => exportAsFile(`MetaMask ${nickname} Private Key`, plainKey),
|
||||
}, 'Save as File'),
|
||||
])
|
||||
}
|
||||
}
|
||||
@ -117,6 +127,6 @@ ExportAccountView.prototype.onExportKeyPress = function (event) {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
|
||||
var input = document.getElementById('exportAccount').value
|
||||
const input = document.getElementById('exportAccount').value
|
||||
this.props.dispatch(actions.exportAccount(input, this.props.address))
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ class Dropdown extends Component {
|
||||
'style',
|
||||
`
|
||||
li.dropdown-menu-item:hover { color:rgb(225, 225, 225); }
|
||||
li.dropdown-menu-item { color: rgb(185, 185, 185); }
|
||||
li.dropdown-menu-item { color: rgb(185, 185, 185); position: relative }
|
||||
`
|
||||
),
|
||||
...children,
|
||||
|
@ -22,7 +22,7 @@ Network.prototype.render = function () {
|
||||
let iconName, hoverText
|
||||
|
||||
if (networkNumber === 'loading') {
|
||||
return h('span', {
|
||||
return h('span.pointer', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@ -37,7 +37,7 @@ Network.prototype.render = function () {
|
||||
},
|
||||
src: 'images/loading.svg',
|
||||
}),
|
||||
h('i.fa.fa-sort-desc'),
|
||||
h('i.fa.fa-caret-down'),
|
||||
])
|
||||
} else if (providerName === 'mainnet') {
|
||||
hoverText = 'Main Ethereum Network'
|
||||
@ -73,7 +73,8 @@ Network.prototype.render = function () {
|
||||
style: {
|
||||
color: '#039396',
|
||||
}},
|
||||
'Ethereum Main Net'),
|
||||
'Main Network'),
|
||||
h('i.fa.fa-caret-down.fa-lg'),
|
||||
])
|
||||
case 'ropsten-test-network':
|
||||
return h('.network-indicator', [
|
||||
@ -83,6 +84,7 @@ Network.prototype.render = function () {
|
||||
color: '#ff6666',
|
||||
}},
|
||||
'Ropsten Test Net'),
|
||||
h('i.fa.fa-caret-down.fa-lg'),
|
||||
])
|
||||
case 'kovan-test-network':
|
||||
return h('.network-indicator', [
|
||||
@ -92,6 +94,7 @@ Network.prototype.render = function () {
|
||||
color: '#690496',
|
||||
}},
|
||||
'Kovan Test Net'),
|
||||
h('i.fa.fa-caret-down.fa-lg'),
|
||||
])
|
||||
case 'rinkeby-test-network':
|
||||
return h('.network-indicator', [
|
||||
@ -101,6 +104,7 @@ Network.prototype.render = function () {
|
||||
color: '#e7a218',
|
||||
}},
|
||||
'Rinkeby Test Net'),
|
||||
h('i.fa.fa-caret-down.fa-lg'),
|
||||
])
|
||||
default:
|
||||
return h('.network-indicator', [
|
||||
@ -116,6 +120,7 @@ Network.prototype.render = function () {
|
||||
color: '#AEAEAE',
|
||||
}},
|
||||
'Private Network'),
|
||||
h('i.fa.fa-caret-down.fa-lg'),
|
||||
])
|
||||
}
|
||||
})(),
|
||||
|
@ -35,10 +35,21 @@ PendingMsg.prototype.render = function () {
|
||||
style: {
|
||||
margin: '10px',
|
||||
},
|
||||
}, `Signing this message can have
|
||||
}, [
|
||||
`Signing this message can have
|
||||
dangerous side effects. Only sign messages from
|
||||
sites you fully trust with your entire account.
|
||||
This dangerous method will be removed in a future version.`),
|
||||
This dangerous method will be removed in a future version. `,
|
||||
h('a', {
|
||||
href: 'https://medium.com/metamask/the-new-secure-way-to-sign-data-in-your-browser-6af9dd2a1527',
|
||||
style: { color: 'rgb(247, 134, 28)' },
|
||||
onClick: (event) => {
|
||||
event.preventDefault()
|
||||
const url = 'https://medium.com/metamask/the-new-secure-way-to-sign-data-in-your-browser-6af9dd2a1527'
|
||||
global.platform.openWindow({ url })
|
||||
},
|
||||
}, 'Read more here.'),
|
||||
]),
|
||||
|
||||
// message details
|
||||
h(PendingTxDetails, state),
|
||||
|
@ -66,6 +66,8 @@ PendingTx.prototype.render = function () {
|
||||
|
||||
const balanceBn = hexToBn(balance)
|
||||
const insufficientBalance = balanceBn.lt(maxCost)
|
||||
const buyDisabled = insufficientBalance || !this.state.valid || !isValidAddress || this.state.submitting
|
||||
const showRejectAll = props.unconfTxListLength > 1
|
||||
|
||||
this.inputs = []
|
||||
|
||||
@ -297,14 +299,6 @@ PendingTx.prototype.render = function () {
|
||||
margin: '14px 25px',
|
||||
},
|
||||
}, [
|
||||
|
||||
|
||||
insufficientBalance ?
|
||||
h('button.btn-green', {
|
||||
onClick: props.buyEth,
|
||||
}, 'Buy Ether')
|
||||
: null,
|
||||
|
||||
h('button', {
|
||||
onClick: (event) => {
|
||||
this.resetGasFields()
|
||||
@ -312,18 +306,30 @@ PendingTx.prototype.render = function () {
|
||||
},
|
||||
}, 'Reset'),
|
||||
|
||||
// Accept Button
|
||||
// Accept Button or Buy Button
|
||||
insufficientBalance ? h('button.btn-green', { onClick: props.buyEth }, 'Buy Ether') :
|
||||
h('input.confirm.btn-green', {
|
||||
type: 'submit',
|
||||
value: 'SUBMIT',
|
||||
style: { marginLeft: '10px' },
|
||||
disabled: insufficientBalance || !this.state.valid || !isValidAddress || this.state.submitting,
|
||||
disabled: buyDisabled,
|
||||
}),
|
||||
|
||||
h('button.cancel.btn-red', {
|
||||
onClick: props.cancelTransaction,
|
||||
}, 'Reject'),
|
||||
]),
|
||||
showRejectAll ? h('.flex-row.flex-space-around.conf-buttons', {
|
||||
style: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
margin: '14px 25px',
|
||||
},
|
||||
}, [
|
||||
h('button.cancel.btn-red', {
|
||||
onClick: props.cancelAllTransactions,
|
||||
}, 'Reject All'),
|
||||
]) : null,
|
||||
]),
|
||||
])
|
||||
)
|
||||
|
@ -52,6 +52,8 @@ ConfirmTxScreen.prototype.render = function () {
|
||||
log.info(`rendering a combined ${unconfTxList.length} unconf msg & txs`)
|
||||
if (unconfTxList.length === 0) return h(Loading, { isLoading: true })
|
||||
|
||||
const unconfTxListLength = unconfTxList.length
|
||||
|
||||
return (
|
||||
|
||||
h('.flex-column.flex-grow', [
|
||||
@ -101,10 +103,12 @@ ConfirmTxScreen.prototype.render = function () {
|
||||
conversionRate,
|
||||
currentCurrency,
|
||||
blockGasLimit,
|
||||
unconfTxListLength,
|
||||
// Actions
|
||||
buyEth: this.buyEth.bind(this, txParams.from || props.selectedAddress),
|
||||
sendTransaction: this.sendTransaction.bind(this),
|
||||
cancelTransaction: this.cancelTransaction.bind(this, txData),
|
||||
cancelAllTransactions: this.cancelAllTransactions.bind(this, unconfTxList),
|
||||
signMessage: this.signMessage.bind(this, txData),
|
||||
signPersonalMessage: this.signPersonalMessage.bind(this, txData),
|
||||
cancelMessage: this.cancelMessage.bind(this, txData),
|
||||
@ -151,6 +155,12 @@ ConfirmTxScreen.prototype.cancelTransaction = function (txData, event) {
|
||||
this.props.dispatch(actions.cancelTx(txData))
|
||||
}
|
||||
|
||||
ConfirmTxScreen.prototype.cancelAllTransactions = function (unconfTxList, event) {
|
||||
this.stopPropagation(event)
|
||||
event.preventDefault()
|
||||
this.props.dispatch(actions.cancelAllTx(unconfTxList))
|
||||
}
|
||||
|
||||
ConfirmTxScreen.prototype.signMessage = function (msgData, event) {
|
||||
log.info('conf-tx.js: signing message')
|
||||
var params = msgData.msgParams
|
||||
|
@ -5,7 +5,8 @@ const connect = require('react-redux').connect
|
||||
const actions = require('./actions')
|
||||
const infuraCurrencies = require('./infura-conversion.json').symbols
|
||||
const validUrl = require('valid-url')
|
||||
const copyToClipboard = require('copy-to-clipboard')
|
||||
const exportAsFile = require('./util').exportAsFile
|
||||
|
||||
|
||||
module.exports = connect(mapStateToProps)(ConfigScreen)
|
||||
|
||||
@ -110,9 +111,9 @@ ConfigScreen.prototype.render = function () {
|
||||
alignSelf: 'center',
|
||||
},
|
||||
onClick (event) {
|
||||
copyToClipboard(window.logState())
|
||||
exportAsFile('MetaMask State Logs', window.logState())
|
||||
},
|
||||
}, 'Copy State Logs'),
|
||||
}, 'Download State Logs'),
|
||||
]),
|
||||
|
||||
h('hr.horizontal-line'),
|
||||
|
@ -215,12 +215,13 @@ hr.horizontal-line {
|
||||
z-index: 1;
|
||||
font-size: 11px;
|
||||
background: rgba(255,0,0,0.8);
|
||||
bottom: -47px;
|
||||
color: white;
|
||||
bottom: 0px;
|
||||
left: -8px;
|
||||
border-radius: 10px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
position: relative;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
@ -103,7 +103,7 @@ InfoScreen.prototype.render = function () {
|
||||
[
|
||||
h('div.fa.fa-support', [
|
||||
h('a.info', {
|
||||
href: 'https://support.metamask.com',
|
||||
href: 'https://support.metamask.io',
|
||||
target: '_blank',
|
||||
}, 'Visit our Support Center'),
|
||||
]),
|
||||
|
@ -3,6 +3,7 @@ const Component = require('react').Component
|
||||
const connect = require('react-redux').connect
|
||||
const h = require('react-hyperscript')
|
||||
const actions = require('../../actions')
|
||||
const exportAsFile = require('../../util').exportAsFile
|
||||
|
||||
module.exports = connect(mapStateToProps)(CreateVaultCompleteScreen)
|
||||
|
||||
@ -65,8 +66,17 @@ CreateVaultCompleteScreen.prototype.render = function () {
|
||||
style: {
|
||||
margin: '24px',
|
||||
fontSize: '0.9em',
|
||||
marginBottom: '10px',
|
||||
},
|
||||
}, 'I\'ve copied it somewhere safe'),
|
||||
|
||||
h('button.primary', {
|
||||
onClick: () => exportAsFile(`MetaMask Seed Words`, seed),
|
||||
style: {
|
||||
margin: '10px',
|
||||
fontSize: '0.9em',
|
||||
},
|
||||
}, 'Save Seed Words As File'),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
@ -80,7 +80,7 @@ UnlockScreen.prototype.render = function () {
|
||||
color: 'rgb(247, 134, 28)',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
}, 'I forgot my password.'),
|
||||
}, 'Restore from seed phrase'),
|
||||
]),
|
||||
])
|
||||
)
|
||||
|
@ -36,6 +36,7 @@ module.exports = {
|
||||
valueTable: valueTable,
|
||||
bnTable: bnTable,
|
||||
isHex: isHex,
|
||||
exportAsFile: exportAsFile,
|
||||
}
|
||||
|
||||
function valuesFor (obj) {
|
||||
@ -215,3 +216,18 @@ function readableDate (ms) {
|
||||
function isHex (str) {
|
||||
return Boolean(str.match(/^(0x)?[0-9a-fA-F]+$/))
|
||||
}
|
||||
|
||||
function exportAsFile (filename, data) {
|
||||
// source: https://stackoverflow.com/a/33542499 by Ludovic Feltz
|
||||
const blob = new Blob([data], {type: 'text/csv'})
|
||||
if (window.navigator.msSaveOrOpenBlob) {
|
||||
window.navigator.msSaveBlob(blob, filename)
|
||||
} else {
|
||||
const elem = window.document.createElement('a')
|
||||
elem.href = window.URL.createObjectURL(blob)
|
||||
elem.download = filename
|
||||
document.body.appendChild(elem)
|
||||
elem.click()
|
||||
document.body.removeChild(elem)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user