diff --git a/lib/cli/writer.js b/lib/cli/writer.js index c1f90633..1539c102 100644 --- a/lib/cli/writer.js +++ b/lib/cli/writer.js @@ -77,6 +77,7 @@ exports.writeImage = (imagePath, drive, options, onProgress) => { }, { check: options.validateWriteOnSuccess, transform: image.transform, + bmap: image.bmap, bytesToZeroOutFromTheBeginning: image.bytesToZeroOutFromTheBeginning }); }).then((writer) => { diff --git a/lib/gui/app.js b/lib/gui/app.js index 0ad7b2f3..5dd097d7 100644 --- a/lib/gui/app.js +++ b/lib/gui/app.js @@ -213,7 +213,9 @@ app.controller('HeaderController', function(SelectionStateModel, OSOpenExternalS * HeaderController.openHelpPage(); */ this.openHelpPage = () => { - OSOpenExternalService.open('https://github.com/resin-io/etcher/blob/master/SUPPORT.md'); + const DEFAULT_SUPPORT_URL = 'https://github.com/resin-io/etcher/blob/master/SUPPORT.md'; + const supportUrl = SelectionStateModel.getImageSupportUrl() || DEFAULT_SUPPORT_URL; + OSOpenExternalService.open(supportUrl); }; }); diff --git a/lib/gui/models/selection-state.js b/lib/gui/models/selection-state.js index 056373cb..04061eaf 100644 --- a/lib/gui/models/selection-state.js +++ b/lib/gui/models/selection-state.js @@ -142,6 +142,76 @@ SelectionStateModel.service('SelectionStateModel', function(DrivesModel) { return _.get(Store.getState().toJS(), 'selection.image.size'); }; + /** + * @summary Get image url + * @function + * @public + * + * @returns {String} image url + * + * @example + * const imageUrl = SelectionStateModel.getImageUrl(); + */ + this.getImageUrl = () => { + return _.get(Store.getState().toJS(), 'selection.image.url'); + }; + + /** + * @summary Get image name + * @function + * @public + * + * @returns {String} image name + * + * @example + * const imageName = SelectionStateModel.getImageName(); + */ + this.getImageName = () => { + return _.get(Store.getState().toJS(), 'selection.image.name'); + }; + + /** + * @summary Get image logo + * @function + * @public + * + * @returns {String} image logo + * + * @example + * const imageLogo = SelectionStateModel.getImageLogo(); + */ + this.getImageLogo = () => { + return _.get(Store.getState().toJS(), 'selection.image.logo'); + }; + + /** + * @summary Get image support url + * @function + * @public + * + * @returns {String} image support url + * + * @example + * const imageSupportUrl = SelectionStateModel.getImageSupportUrl(); + */ + this.getImageSupportUrl = () => { + return _.get(Store.getState().toJS(), 'selection.image.supportUrl'); + }; + + /** + * @summary Get image recommended drive size + * @function + * @public + * + * @returns {String} image recommended drive size + * + * @example + * const imageRecommendedDriveSize = SelectionStateModel.getImageRecommendedDriveSize(); + */ + this.getImageRecommendedDriveSize = () => { + return _.get(Store.getState().toJS(), 'selection.image.recommendedDriveSize'); + }; + /** * @summary Check if there is a selected drive * @function diff --git a/lib/gui/models/store.js b/lib/gui/models/store.js index 698acb21..d62598bc 100644 --- a/lib/gui/models/store.js +++ b/lib/gui/models/store.js @@ -249,13 +249,28 @@ const storeReducer = (state, action) => { throw new Error(`Invalid image size: ${action.data.size}`); } + if (action.data.url && !_.isString(action.data.url)) { + throw new Error(`Invalid image url: ${action.data.url}`); + } + + if (action.data.name && !_.isString(action.data.name)) { + throw new Error(`Invalid image name: ${action.data.name}`); + } + + if (action.data.logo && !_.isString(action.data.logo)) { + throw new Error(`Invalid image logo: ${action.data.logo}`); + } + const selectedDevice = state.getIn([ 'selection', 'drive' ]); const selectedDrive = state.get('availableDrives').find((drive) => { return drive.get('device') === selectedDevice; }); return _.attempt(() => { - if (selectedDrive && !constraints.isDriveLargeEnough(selectedDrive.toJS(), action.data)) { + if (selectedDrive && !_.every([ + constraints.isDriveLargeEnough(selectedDrive.toJS(), action.data), + constraints.isDriveSizeRecommended(selectedDrive.toJS(), action.data) + ])) { return storeReducer(state, { type: ACTIONS.REMOVE_DRIVE }); diff --git a/lib/gui/pages/main/controllers/image-selection.js b/lib/gui/pages/main/controllers/image-selection.js index 195876ec..de63e8e3 100644 --- a/lib/gui/pages/main/controllers/image-selection.js +++ b/lib/gui/pages/main/controllers/image-selection.js @@ -66,6 +66,12 @@ module.exports = function(SupportedFormatsModel, SelectionStateModel, AnalyticsS } SelectionStateModel.setImage(image); + + // An easy way so we can quickly identify if we're making use of + // certain features without printing pages of text to DevTools. + image.logo = Boolean(image.logo); + image.bmap = Boolean(image.bmap); + AnalyticsService.logEvent('Select image', image); }; diff --git a/lib/gui/pages/main/templates/main.tpl.html b/lib/gui/pages/main/templates/main.tpl.html index 60b65708..7593698c 100644 --- a/lib/gui/pages/main/templates/main.tpl.html +++ b/lib/gui/pages/main/templates/main.tpl.html @@ -43,7 +43,7 @@
- { return accumulator; }, []); +/** + * @summary Extract entry by path + * @function + * @private + * + * @param {String} archive - archive + * @param {String} filePath - entry file path + * @param {Object} options - options + * @param {Object} options.hooks - archive hooks + * @param {Object[]} options.entries - archive entries + * @param {*} [options.default] - entry default value + * @fulfil {*} contents + * @returns {Promise} + * + * extractEntryByPath('my/archive.zip', '_info/logo.svg', { + * hooks: { ... }, + * entries: [ ... ], + * default: '' + * }).then((contents) => { + * console.log(contents); + * }); + */ +const extractEntryByPath = (archive, filePath, options) => { + const fileEntry = _.find(options.entries, (entry) => { + return _.chain(entry.name) + .split('/') + .tail() + .join('/') + .value() === filePath; + }); + + if (!fileEntry) { + return Bluebird.resolve(options.default); + } + + return options.hooks.extractFile(archive, options.entries, fileEntry.name) + .then(rindle.extract); +}; + +/** + * @summary Extract archive metadata + * @function + * @private + * + * @param {String} archive - archive + * @param {String} basePath - metadata base path + * @param {Object} options - options + * @param {Object[]} options.entries - archive entries + * @param {Object} options.hooks - archive hooks + * @fulfil {Object} - metadata + * @returns {Promise} + * + * extractArchiveMetadata('my/archive.zip', '.meta', { + * hooks: { ... }, + * entries: [ ... ] + * }).then((metadata) => { + * console.log(metadata); + * }); + */ +const extractArchiveMetadata = (archive, basePath, options) => { + return Bluebird.props({ + logo: extractEntryByPath(archive, `${basePath}/logo.svg`, options), + instructions: extractEntryByPath(archive, `${basePath}/instructions.markdown`, options), + bmap: extractEntryByPath(archive, `${basePath}/image.bmap`, options), + manifest: _.attempt(() => { + return extractEntryByPath(archive, `${basePath}/manifest.json`, { + hooks: options.hooks, + entries: options.entries, + default: '{}' + }).then((manifest) => { + try { + return JSON.parse(manifest); + } catch (parseError) { + const error = new Error('Invalid archive manifest.json'); + error.description = 'The archive manifest.json file is not valid JSON.'; + throw error; + } + }); + }) + }).then((results) => { + return { + name: results.manifest.name, + version: results.manifest.version, + url: results.manifest.url, + supportUrl: results.manifest.supportUrl, + releaseNotesUrl: results.manifest.releaseNotesUrl, + checksumType: results.manifest.checksumType, + checksum: results.manifest.checksum, + bytesToZeroOutFromTheBeginning: results.manifest.bytesToZeroOutFromTheBeginning, + recommendedDriveSize: results.manifest.recommendedDriveSize, + logo: results.logo, + bmap: results.bmap, + instructions: results.instructions + }; + }); +}; + /** * @summary Extract image from archive * @function @@ -75,18 +182,25 @@ exports.extractImage = (archive, hooks) => { const imageEntry = _.first(imageEntries); - return hooks.extractFile(archive, entries, imageEntry.name).then((imageStream) => { - return { - stream: imageStream, - transform: new PassThroughStream(), - size: { - original: imageEntry.size, - final: { - estimation: false, - value: imageEntry.size - } + return Bluebird.props({ + imageStream: hooks.extractFile(archive, entries, imageEntry.name), + metadata: extractArchiveMetadata(archive, ARCHIVE_METADATA_BASE_PATH, { + entries, + hooks + }) + }).then((results) => { + results.metadata.stream = results.imageStream; + results.metadata.transform = new PassThroughStream(); + + results.metadata.size = { + original: imageEntry.size, + final: { + estimation: false, + value: imageEntry.size } }; + + return results.metadata; }); }); }; diff --git a/lib/image-stream/index.js b/lib/image-stream/index.js index ff04b2c8..da881cfa 100644 --- a/lib/image-stream/index.js +++ b/lib/image-stream/index.js @@ -99,7 +99,10 @@ exports.getFromFilePath = (file) => { * const imageStream = require('./lib/image-stream'); * * imageStream.getImageMetadata('path/to/rpi.img.xz').then((metadata) => { - * console.log(`The image original size is: ${metada.size.original}`); + * console.log(`The image display name is: ${metada.name}`); + * console.log(`The image url is: ${metada.url}`); + * console.log(`The image support url is: ${metada.supportUrl}`); + * console.log(`The image logo is: ${metada.logo}`); * }); */ exports.getImageMetadata = (file) => { diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9d15fd0a..ef6ac9e8 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -512,16 +512,16 @@ "from": "isarray@0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" }, - "isexe": { - "version": "1.1.2", - "from": "isexe@>=1.1.1 <2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-1.1.2.tgz" - }, "isstream": { "version": "0.1.2", "from": "isstream@>=0.1.1 <0.2.0", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz" }, + "isexe": { + "version": "1.1.2", + "from": "isexe@>=1.1.1 <2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-1.1.2.tgz" + }, "js-message": { "version": "1.0.5", "from": "js-message@>=1.0.5", @@ -1451,6 +1451,23 @@ "from": "restore-cursor@>=1.0.1 <2.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz" }, + "rindle": { + "version": "1.3.0", + "from": "rindle@>=1.3.0 <2.0.0", + "resolved": "https://registry.npmjs.org/rindle/-/rindle-1.3.0.tgz", + "dependencies": { + "bluebird": { + "version": "2.11.0", + "from": "bluebird@>=2.10.2 <3.0.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz" + }, + "lodash": { + "version": "3.10.1", + "from": "lodash@>=3.10.1 <4.0.0", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz" + } + } + }, "run-async": { "version": "0.1.0", "from": "run-async@>=0.1.0 <0.2.0", @@ -1575,6 +1592,11 @@ "from": "string-template@>=0.2.1 <0.3.0", "resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz" }, + "string-to-stream": { + "version": "1.1.0", + "from": "string-to-stream@>=1.0.1 <2.0.0", + "resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-1.1.0.tgz" + }, "string-width": { "version": "1.0.1", "from": "string-width@>=1.0.1 <2.0.0", diff --git a/package.json b/package.json index dba5e4e8..034bd711 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "redux-localstorage": "^0.4.1", "resin-cli-form": "^1.4.1", "resin-cli-visuals": "^1.2.8", + "rindle": "^1.3.0", "rx": "^4.1.0", "semver": "^5.1.0", "sudo-prompt": "^6.1.0", @@ -113,7 +114,6 @@ "jsonfile": "^2.3.1", "mochainon": "^1.0.0", "node-sass": "^3.8.0", - "rindle": "^1.3.0", "tmp": "0.0.31", "versionist": "^2.1.0" }, diff --git a/tests/gui/models/selection-state.spec.js b/tests/gui/models/selection-state.spec.js index a3c10d20..4a35113c 100644 --- a/tests/gui/models/selection-state.spec.js +++ b/tests/gui/models/selection-state.spec.js @@ -47,6 +47,26 @@ describe('Browser: SelectionState', function() { m.chai.expect(SelectionStateModel.getImageSize()).to.be.undefined; }); + it('getImageUrl() should return undefined', function() { + m.chai.expect(SelectionStateModel.getImageUrl()).to.be.undefined; + }); + + it('getImageName() should return undefined', function() { + m.chai.expect(SelectionStateModel.getImageName()).to.be.undefined; + }); + + it('getImageLogo() should return undefined', function() { + m.chai.expect(SelectionStateModel.getImageLogo()).to.be.undefined; + }); + + it('getImageSupportUrl() should return undefined', function() { + m.chai.expect(SelectionStateModel.getImageSupportUrl()).to.be.undefined; + }); + + it('getImageRecommendedDriveSize() should return undefined', function() { + m.chai.expect(SelectionStateModel.getImageRecommendedDriveSize()).to.be.undefined; + }); + it('hasDrive() should return false', function() { const hasDrive = SelectionStateModel.hasDrive(); m.chai.expect(hasDrive).to.be.false; @@ -199,7 +219,12 @@ describe('Browser: SelectionState', function() { beforeEach(function() { this.image = { path: 'foo.img', - size: 999999999 + size: 999999999, + recommendedDriveSize: 1000000000, + url: 'https://www.raspbian.org', + supportUrl: 'https://www.raspbian.org/forums/', + name: 'Raspbian', + logo: 'Raspbian' }; SelectionStateModel.setImage(this.image); @@ -250,6 +275,51 @@ describe('Browser: SelectionState', function() { }); + describe('.getImageUrl()', function() { + + it('should return the image url', function() { + const imageUrl = SelectionStateModel.getImageUrl(); + m.chai.expect(imageUrl).to.equal('https://www.raspbian.org'); + }); + + }); + + describe('.getImageName()', function() { + + it('should return the image name', function() { + const imageName = SelectionStateModel.getImageName(); + m.chai.expect(imageName).to.equal('Raspbian'); + }); + + }); + + describe('.getImageLogo()', function() { + + it('should return the image logo', function() { + const imageLogo = SelectionStateModel.getImageLogo(); + m.chai.expect(imageLogo).to.equal('Raspbian'); + }); + + }); + + describe('.getImageSupportUrl()', function() { + + it('should return the image support url', function() { + const imageSupportUrl = SelectionStateModel.getImageSupportUrl(); + m.chai.expect(imageSupportUrl).to.equal('https://www.raspbian.org/forums/'); + }); + + }); + + describe('.getImageRecommendedDriveSize()', function() { + + it('should return the image recommended drive size', function() { + const imageRecommendedDriveSize = SelectionStateModel.getImageRecommendedDriveSize(); + m.chai.expect(imageRecommendedDriveSize).to.equal(1000000000); + }); + + }); + describe('.hasImage()', function() { it('should return true', function() { @@ -340,6 +410,36 @@ describe('Browser: SelectionState', function() { }).to.throw('Invalid image size: 999999999'); }); + it('should throw if url is defined but its not a string', function() { + m.chai.expect(function() { + SelectionStateModel.setImage({ + path: 'foo.img', + size: 999999999, + url: 1234 + }); + }).to.throw('Invalid image url: 1234'); + }); + + it('should throw if name is defined but its not a string', function() { + m.chai.expect(function() { + SelectionStateModel.setImage({ + path: 'foo.img', + size: 999999999, + name: 1234 + }); + }).to.throw('Invalid image name: 1234'); + }); + + it('should throw if logo is defined but its not a string', function() { + m.chai.expect(function() { + SelectionStateModel.setImage({ + path: 'foo.img', + size: 999999999, + logo: 1234 + }); + }).to.throw('Invalid image logo: 1234'); + }); + it('should de-select a previously selected not-large-enough drive', function() { DrivesModel.setDrives([ { @@ -362,6 +462,29 @@ describe('Browser: SelectionState', function() { SelectionStateModel.removeImage(); }); + it('should de-select a previously selected not-recommended drive', function() { + DrivesModel.setDrives([ + { + device: '/dev/disk1', + name: 'USB Drive', + size: 1200000000, + protected: false + } + ]); + + SelectionStateModel.setDrive('/dev/disk1'); + m.chai.expect(SelectionStateModel.hasDrive()).to.be.true; + + SelectionStateModel.setImage({ + path: 'foo.img', + size: 999999999, + recommendedDriveSize: 1500000000 + }); + + m.chai.expect(SelectionStateModel.hasDrive()).to.be.false; + SelectionStateModel.removeImage(); + }); + }); }); diff --git a/tests/image-stream/data/metadata/zip/rpi-invalid-manifest.zip b/tests/image-stream/data/metadata/zip/rpi-invalid-manifest.zip new file mode 100644 index 00000000..21a0bbc8 Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-invalid-manifest.zip differ diff --git a/tests/image-stream/data/metadata/zip/rpi-with-bmap.zip b/tests/image-stream/data/metadata/zip/rpi-with-bmap.zip new file mode 100644 index 00000000..14cc086b Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-bmap.zip differ diff --git a/tests/image-stream/data/metadata/zip/rpi-with-instructions.zip b/tests/image-stream/data/metadata/zip/rpi-with-instructions.zip new file mode 100644 index 00000000..19c75692 Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-instructions.zip differ diff --git a/tests/image-stream/data/metadata/zip/rpi-with-logo.zip b/tests/image-stream/data/metadata/zip/rpi-with-logo.zip new file mode 100644 index 00000000..5b0158f6 Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-logo.zip differ diff --git a/tests/image-stream/data/metadata/zip/rpi-with-manifest.zip b/tests/image-stream/data/metadata/zip/rpi-with-manifest.zip new file mode 100644 index 00000000..15289b19 Binary files /dev/null and b/tests/image-stream/data/metadata/zip/rpi-with-manifest.zip differ diff --git a/tests/image-stream/metadata/zip.spec.js b/tests/image-stream/metadata/zip.spec.js new file mode 100644 index 00000000..2a07ae0c --- /dev/null +++ b/tests/image-stream/metadata/zip.spec.js @@ -0,0 +1,171 @@ +/* + * Copyright 2016 resin.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const m = require('mochainon'); +const path = require('path'); +const DATA_PATH = path.join(__dirname, '..', 'data'); +const IMAGES_PATH = path.join(DATA_PATH, 'images'); +const ZIP_PATH = path.join(DATA_PATH, 'metadata', 'zip'); +const tester = require('../tester'); +const imageStream = require('../../../lib/image-stream/index'); + +const testMetadataProperty = (archivePath, propertyName, expectedValue) => { + return imageStream.getFromFilePath(archivePath).then((image) => { + m.chai.expect(image[propertyName]).to.deep.equal(expectedValue); + + return imageStream.getImageMetadata(archivePath).then((metadata) => { + m.chai.expect(metadata[propertyName]).to.deep.equal(expectedValue); + }); + }); +}; + +describe('ImageStream: Metadata ZIP', function() { + + this.timeout(10000); + + describe('given an archive with an invalid `manifest.json`', function() { + + tester.expectError( + path.join(ZIP_PATH, 'rpi-invalid-manifest.zip'), + 'Invalid archive manifest.json'); + + describe('.getImageMetadata()', function() { + + it('should be rejected with an error', function(done) { + const image = path.join(ZIP_PATH, 'rpi-invalid-manifest.zip'); + + imageStream.getImageMetadata(image).catch((error) => { + m.chai.expect(error).to.be.an.instanceof(Error); + m.chai.expect(error.message).to.equal('Invalid archive manifest.json'); + done(); + }); + }); + + }); + + }); + + describe('given an archive with a `manifest.json`', function() { + + const archive = path.join(ZIP_PATH, 'rpi-with-manifest.zip'); + + tester.extractFromFilePath( + archive, + path.join(IMAGES_PATH, 'raspberrypi.img')); + + it('should read the manifest name property', function(done) { + testMetadataProperty(archive, 'name', 'Raspberry Pi').asCallback(done); + }); + + it('should read the manifest version property', function(done) { + testMetadataProperty(archive, 'version', '1.0.0').asCallback(done); + }); + + it('should read the manifest url property', function(done) { + testMetadataProperty(archive, 'url', 'https://www.raspberrypi.org').asCallback(done); + }); + + it('should read the manifest supportUrl property', function(done) { + const expectedValue = 'https://www.raspberrypi.org/forums/'; + testMetadataProperty(archive, 'supportUrl', expectedValue).asCallback(done); + }); + + it('should read the manifest releaseNotesUrl property', function(done) { + const expectedValue = 'http://downloads.raspberrypi.org/raspbian/release_notes.txt'; + testMetadataProperty(archive, 'releaseNotesUrl', expectedValue).asCallback(done); + }); + + it('should read the manifest checksumType property', function(done) { + testMetadataProperty(archive, 'checksumType', 'md5').asCallback(done); + }); + + it('should read the manifest checksum property', function(done) { + testMetadataProperty(archive, 'checksum', 'add060b285d512f56c175b76b7ef1bee').asCallback(done); + }); + + it('should read the manifest bytesToZeroOutFromTheBeginning property', function(done) { + testMetadataProperty(archive, 'bytesToZeroOutFromTheBeginning', 512).asCallback(done); + }); + + it('should read the manifest recommendedDriveSize property', function(done) { + testMetadataProperty(archive, 'recommendedDriveSize', 4294967296).asCallback(done); + }); + + }); + + describe('given an archive with a `logo.svg`', function() { + + const archive = path.join(ZIP_PATH, 'rpi-with-logo.zip'); + + const logo = [ + '', + ' Hello World', + '', + '' + ].join('\n'); + + it('should read the logo contents', function(done) { + testMetadataProperty(archive, 'logo', logo).asCallback(done); + }); + + }); + + describe('given an archive with a bmap file', function() { + + const archive = path.join(ZIP_PATH, 'rpi-with-bmap.zip'); + + const bmap = [ + '', + '', + ' 36864 ', + ' 4096 ', + ' 9 ', + ' 4 ', + ' d90f372215cbbef8801caca7b1dd7e587b2142cc ', + ' ', + ' 0-1 ', + ' 7-8 ', + ' ', + '', + '' + ].join('\n'); + + it('should read the bmap contents', function(done) { + testMetadataProperty(archive, 'bmap', bmap).asCallback(done); + }); + + }); + + describe('given an archive with instructions', function() { + + const archive = path.join(ZIP_PATH, 'rpi-with-instructions.zip'); + + const instructions = [ + '# Raspberry Pi Next Steps', + '', + 'Lorem ipsum dolor sit amet.', + '' + ].join('\n'); + + it('should read the instruction contents', function(done) { + testMetadataProperty(archive, 'instructions', instructions).asCallback(done); + }); + + }); + +});